diff --git a/loop/plan.md b/loop/plan.md index 6b093956..045a413c 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -18,7 +18,7 @@ - [x] **TEST-01** — Install Vitest and configure frontend test runner. Add `vitest`, `@vue/test-utils`, `jsdom` to `neode-ui/package.json` devDependencies. Create `neode-ui/vitest.config.ts` with Vue plugin and path aliases matching `neode-ui/vite.config.ts`. Add `"test": "vitest run"` and `"test:watch": "vitest"` scripts. **Acceptance**: `cd neode-ui && npm test` runs with exit 0 (zero tests is fine). -- [ ] **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. +- [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. diff --git a/neode-ui/src/api/__tests__/rpc-client.test.ts b/neode-ui/src/api/__tests__/rpc-client.test.ts new file mode 100644 index 00000000..707b4f32 --- /dev/null +++ b/neode-ui/src/api/__tests__/rpc-client.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// We need to test the RPCClient class, so import it by re-creating the module +// Import the actual class and instance +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// Import after stubbing fetch +const { rpcClient } = await import('../rpc-client') + +function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText, + json: () => Promise.resolve(body), + headers: new Headers(), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => jsonResponse(body, status, statusText), + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + text: () => Promise.resolve(JSON.stringify(body)), + bytes: () => Promise.resolve(new Uint8Array()), + } +} + +describe('RPCClient', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('makes a successful RPC call and returns the result', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } })) + + const result = await rpcClient.call<{ did: string }>({ + method: 'node.did', + params: {}, + }) + + expect(result).toEqual({ did: 'did:key:z123' }) + expect(mockFetch).toHaveBeenCalledOnce() + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('/rpc/v1') + expect(init.method).toBe('POST') + expect(init.credentials).toBe('include') + expect(JSON.parse(init.body)).toEqual({ method: 'node.did', params: {} }) + }) + + it('includes credentials for session cookies', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' })) + + await rpcClient.call({ method: 'test', params: {} }) + + const [, init] = mockFetch.mock.calls[0] + expect(init.credentials).toBe('include') + }) + + it('retries on 502 Bad Gateway and eventually succeeds', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway')) + .mockResolvedValueOnce(jsonResponse({ result: 'ok' })) + + const result = await rpcClient.call({ method: 'test' }) + + expect(result).toBe('ok') + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('retries on 503 Service Unavailable and eventually succeeds', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(null, 503, 'Service Unavailable')) + .mockResolvedValueOnce(jsonResponse({ result: 'recovered' })) + + const result = await rpcClient.call({ method: 'test' }) + + expect(result).toBe('recovered') + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('throws after max retries on persistent 502', async () => { + mockFetch + .mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway')) + + await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway') + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('throws immediately on non-retryable HTTP errors (e.g. 401)', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized')) + + await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 401: Unauthorized') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('throws on RPC-level error in response body', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: { code: -32600, message: 'Invalid method' } }), + ) + + await expect(rpcClient.call({ method: 'bad.method' })).rejects.toThrow('Invalid method') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('throws timeout error when request times out', async () => { + const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + mockFetch.mockRejectedValue(abortError) + + await expect( + rpcClient.call({ method: 'slow', timeout: 100 }), + ).rejects.toThrow('Request timeout') + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('retries on network/fetch errors and eventually succeeds', async () => { + mockFetch + .mockRejectedValueOnce(new Error('fetch failed')) + .mockResolvedValueOnce(jsonResponse({ result: 'back online' })) + + const result = await rpcClient.call({ method: 'test' }) + + expect(result).toBe('back online') + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('throws on non-retryable errors immediately', async () => { + mockFetch.mockRejectedValueOnce(new Error('some random error')) + + await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('some random error') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('handles unknown (non-Error) thrown values', async () => { + mockFetch.mockRejectedValueOnce('string error') + + await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Unknown error occurred') + }) + + it('uses default params when none provided', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' })) + + await rpcClient.call({ method: 'test' }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.params).toEqual({}) + }) + + it('sends an abort signal for timeout', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' })) + + await rpcClient.call({ method: 'test', timeout: 5000 }) + + const [, init] = mockFetch.mock.calls[0] + expect(init.signal).toBeInstanceOf(AbortSignal) + }) +})