From fe0413a124a07e8bede9bfae25f63f34866bd01a Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 10 Mar 2026 23:36:42 +0000 Subject: [PATCH] test: add app store unit tests with 11 test cases Co-Authored-By: Claude Opus 4.6 --- loop/plan.md | 2 +- neode-ui/src/stores/__tests__/app.test.ts | 175 ++++++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 neode-ui/src/stores/__tests__/app.test.ts diff --git a/loop/plan.md b/loop/plan.md index 045a413c..abcee85f 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -20,7 +20,7 @@ - [x] **TEST-02** — Create first frontend unit tests: RPC client. Write `neode-ui/src/api/__tests__/rpc-client.test.ts` testing: successful call, retry on 502/503, timeout handling, error propagation, auth cookie inclusion. Mock `fetch` globally. Target: 8+ test cases covering all branches in `rpc-client.ts` lines 25-87. **Acceptance**: all tests pass. -- [ ] **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. +- [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. diff --git a/neode-ui/src/stores/__tests__/app.test.ts b/neode-ui/src/stores/__tests__/app.test.ts new file mode 100644 index 00000000..78257688 --- /dev/null +++ b/neode-ui/src/stores/__tests__/app.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Mock the rpc-client module +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + login: vi.fn(), + logout: vi.fn(), + call: vi.fn(), + installPackage: vi.fn(), + uninstallPackage: vi.fn(), + startPackage: vi.fn(), + stopPackage: vi.fn(), + restartPackage: vi.fn(), + updateServer: vi.fn(), + restartServer: vi.fn(), + shutdownServer: vi.fn(), + getMetrics: vi.fn(), + getMarketplace: vi.fn(), + }, +})) + +// Mock the websocket module +vi.mock('@/api/websocket', () => ({ + wsClient: { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + subscribe: vi.fn(), + isConnected: vi.fn().mockReturnValue(false), + onConnectionStateChange: vi.fn(), + }, + applyDataPatch: vi.fn(), +})) + +import { useAppStore } from '../app' +import { rpcClient } from '@/api/rpc-client' +import { wsClient } from '@/api/websocket' + +const mockedRpc = vi.mocked(rpcClient) +const mockedWs = vi.mocked(wsClient) + +describe('useAppStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + mockedWs.isConnected.mockReturnValue(false) + }) + + it('starts with default unauthenticated state', () => { + const store = useAppStore() + expect(store.isAuthenticated).toBe(false) + expect(store.isConnected).toBe(false) + expect(store.isLoading).toBe(false) + expect(store.error).toBeNull() + expect(store.data).toBeNull() + }) + + it('login succeeds and sets authenticated state', async () => { + mockedRpc.login.mockResolvedValue(null) + const store = useAppStore() + + await store.login('password123') + + expect(store.isAuthenticated).toBe(true) + expect(localStorage.getItem('neode-auth')).toBe('true') + expect(store.data).not.toBeNull() + expect(store.isLoading).toBe(false) + }) + + it('login handles TOTP requirement', async () => { + mockedRpc.login.mockResolvedValue({ requires_totp: true }) + const store = useAppStore() + + const result = await store.login('password123') + + expect(result).toEqual({ requires_totp: true }) + expect(store.isAuthenticated).toBe(false) + }) + + it('login sets error on failure', async () => { + mockedRpc.login.mockRejectedValue(new Error('Invalid password')) + const store = useAppStore() + + await expect(store.login('wrong')).rejects.toThrow('Invalid password') + + expect(store.error).toBe('Invalid password') + expect(store.isAuthenticated).toBe(false) + expect(store.isLoading).toBe(false) + }) + + it('logout clears all state', async () => { + mockedRpc.login.mockResolvedValue(null) + mockedRpc.logout.mockResolvedValue(undefined) + const store = useAppStore() + + // Login first + await store.login('password123') + expect(store.isAuthenticated).toBe(true) + + // Then logout + await store.logout() + + expect(store.isAuthenticated).toBe(false) + expect(store.data).toBeNull() + expect(store.isConnected).toBe(false) + expect(localStorage.getItem('neode-auth')).toBeNull() + expect(mockedWs.disconnect).toHaveBeenCalled() + }) + + it('logout still clears state even if RPC fails', async () => { + mockedRpc.logout.mockRejectedValue(new Error('Network error')) + localStorage.setItem('neode-auth', 'true') + const store = useAppStore() + + await store.logout() + + expect(store.isAuthenticated).toBe(false) + expect(localStorage.getItem('neode-auth')).toBeNull() + }) + + it('checkSession returns true on valid session', async () => { + localStorage.setItem('neode-auth', 'true') + mockedRpc.call.mockResolvedValue('ping') + const store = useAppStore() + + const valid = await store.checkSession() + + expect(valid).toBe(true) + expect(store.isAuthenticated).toBe(true) + expect(store.data).not.toBeNull() + }) + + it('checkSession returns false when no auth in localStorage', async () => { + const store = useAppStore() + + const valid = await store.checkSession() + + expect(valid).toBe(false) + }) + + it('checkSession returns false and clears state on expired session', async () => { + localStorage.setItem('neode-auth', 'true') + mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized')) + const store = useAppStore() + + const valid = await store.checkSession() + + expect(valid).toBe(false) + expect(store.isAuthenticated).toBe(false) + expect(localStorage.getItem('neode-auth')).toBeNull() + expect(mockedWs.disconnect).toHaveBeenCalled() + }) + + it('connectWebSocket subscribes and connects', async () => { + mockedWs.connect.mockResolvedValue(undefined) + // First call: not connected (triggers connect), second call: connected (after connect) + mockedWs.isConnected.mockReturnValueOnce(false).mockReturnValue(true) + const store = useAppStore() + + await store.connectWebSocket() + + expect(mockedWs.subscribe).toHaveBeenCalledOnce() + expect(mockedWs.onConnectionStateChange).toHaveBeenCalledOnce() + expect(mockedWs.connect).toHaveBeenCalledOnce() + }) + + it('needsSessionValidation returns true when auth but not validated', () => { + localStorage.setItem('neode-auth', 'true') + const store = useAppStore() + + // isAuthenticated is true from localStorage, but sessionValidated is false + expect(store.needsSessionValidation()).toBe(true) + }) +})