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) }) })