diff --git a/loop/plan.md b/loop/plan.md index abcee85f..bae28a0c 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -22,7 +22,7 @@ - [x] **TEST-03** — Create frontend unit tests: app store. Write `neode-ui/src/stores/__tests__/app.test.ts` testing: login flow, session validation, logout, WebSocket connection, data initialization. Use `createTestingPinia()`. Target: 6+ test cases. **Acceptance**: all tests pass. -- [ ] **TEST-04** — Create frontend unit tests: container store. Write `neode-ui/src/stores/__tests__/container.test.ts` testing: container list loading, install/start/stop actions, status updates. Target: 5+ test cases. **Acceptance**: all tests pass. +- [x] **TEST-04** — Create frontend unit tests: container store. Write `neode-ui/src/stores/__tests__/container.test.ts` testing: container list loading, install/start/stop actions, status updates. Target: 5+ test cases. **Acceptance**: all tests pass. - [ ] **TEST-05** — Create frontend unit tests: router guards. Write `neode-ui/src/router/__tests__/guards.test.ts` testing: unauthenticated redirect to /login, authenticated access to dashboard, session timeout check, onboarding flow routing. Target: 6+ test cases. **Acceptance**: all tests pass. diff --git a/neode-ui/src/stores/__tests__/container.test.ts b/neode-ui/src/stores/__tests__/container.test.ts new file mode 100644 index 00000000..6fae57c0 --- /dev/null +++ b/neode-ui/src/stores/__tests__/container.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +vi.mock('@/api/container-client', () => ({ + containerClient: { + listContainers: vi.fn(), + getHealthStatus: vi.fn(), + installApp: vi.fn(), + startContainer: vi.fn(), + stopContainer: vi.fn(), + removeContainer: vi.fn(), + getContainerLogs: vi.fn(), + getContainerStatus: vi.fn(), + startBundledApp: vi.fn(), + stopBundledApp: vi.fn(), + }, +})) + +import { useContainerStore } from '../container' +import { containerClient } from '@/api/container-client' + +const mockedClient = vi.mocked(containerClient) + +const mockContainers = [ + { name: 'bitcoin-knots', state: 'running', status: 'Up 2 hours', image: 'bitcoinknots:29', lan_address: 'http://localhost:8332' }, + { name: 'lnd', state: 'stopped', status: 'Exited (0)', image: 'lnd:v0.18.4', lan_address: undefined }, + { name: 'mempool', state: 'running', status: 'Up 1 hour', image: 'mempool:latest', lan_address: 'http://localhost:8080' }, +] + +describe('useContainerStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('fetchContainers loads container list', async () => { + mockedClient.listContainers.mockResolvedValue(mockContainers) + const store = useContainerStore() + + await store.fetchContainers() + + expect(store.containers).toEqual(mockContainers) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('fetchContainers sets error on failure', async () => { + mockedClient.listContainers.mockRejectedValue(new Error('Connection refused')) + const store = useContainerStore() + + await store.fetchContainers() + + expect(store.error).toBe('Connection refused') + expect(store.loading).toBe(false) + }) + + it('runningContainers filters correctly', async () => { + mockedClient.listContainers.mockResolvedValue(mockContainers) + const store = useContainerStore() + + await store.fetchContainers() + + expect(store.runningContainers).toHaveLength(2) + expect(store.runningContainers.map(c => c.name)).toEqual(['bitcoin-knots', 'mempool']) + }) + + it('stoppedContainers filters correctly', async () => { + mockedClient.listContainers.mockResolvedValue(mockContainers) + const store = useContainerStore() + + await store.fetchContainers() + + expect(store.stoppedContainers).toHaveLength(1) + expect(store.stoppedContainers[0].name).toBe('lnd') + }) + + it('startContainer calls client and refreshes', async () => { + mockedClient.startContainer.mockResolvedValue(undefined) + mockedClient.listContainers.mockResolvedValue(mockContainers) + mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy' }) + const store = useContainerStore() + + await store.startContainer('bitcoin-knots') + + expect(mockedClient.startContainer).toHaveBeenCalledWith('bitcoin-knots') + expect(mockedClient.listContainers).toHaveBeenCalled() + expect(mockedClient.getHealthStatus).toHaveBeenCalled() + expect(store.isAppLoading('bitcoin-knots')).toBe(false) + }) + + it('stopContainer calls client and refreshes', async () => { + mockedClient.stopContainer.mockResolvedValue(undefined) + mockedClient.listContainers.mockResolvedValue(mockContainers) + const store = useContainerStore() + + await store.stopContainer('lnd') + + expect(mockedClient.stopContainer).toHaveBeenCalledWith('lnd') + expect(mockedClient.listContainers).toHaveBeenCalled() + expect(store.isAppLoading('lnd')).toBe(false) + }) + + it('getAppState returns correct states', async () => { + mockedClient.listContainers.mockResolvedValue(mockContainers) + const store = useContainerStore() + + await store.fetchContainers() + + expect(store.getAppState('bitcoin-knots')).toBe('running') + expect(store.getAppState('lnd')).toBe('stopped') + expect(store.getAppState('nonexistent')).toBe('not-installed') + }) + + it('isAppLoading tracks per-app loading state', async () => { + let resolveStart: (() => void) | undefined + mockedClient.startContainer.mockImplementation(() => new Promise(r => { resolveStart = r })) + mockedClient.listContainers.mockResolvedValue(mockContainers) + mockedClient.getHealthStatus.mockResolvedValue({}) + const store = useContainerStore() + + const startPromise = store.startContainer('bitcoin-knots') + + expect(store.isAppLoading('bitcoin-knots')).toBe(true) + expect(store.isAppLoading('lnd')).toBe(false) + + resolveStart!() + await startPromise + + expect(store.isAppLoading('bitcoin-knots')).toBe(false) + }) +})