166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
|
|
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)
|
||
|
|
})
|
||
|
|
})
|