Files
hapi/web/src/api/client.test.ts
T
2026-08-03 15:13:36 +08:00

141 lines
5.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiClient, ApiError } from './client'
describe('ApiClient error mapping', () => {
let originalFetch: typeof globalThis.fetch
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
originalFetch = globalThis.fetch
fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
})
it('prefers the stable `code` field over the human-readable `error` message in ApiError.code', async () => {
// Match the shape /sessions/:id/reopen actually returns on a 503.
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: 'No machine online', code: 'no_machine_online' }),
{ status: 503, statusText: 'Service Unavailable' }
)
)
const api = new ApiClient('test-token')
try {
await api.reopenSession('session-X')
expect.unreachable('expected reopenSession to throw')
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
const apiError = error as ApiError
expect(apiError.status).toBe(503)
// The stable taxonomy must survive into ApiError.code so callers can
// branch on `no_machine_online` rather than parsing the message text.
expect(apiError.code).toBe('no_machine_online')
expect(apiError.body).toContain('no_machine_online')
}
})
it('falls back to `parsed.error` when `code` is absent (legacy route shape)', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: 'something broke' }),
{ status: 500, statusText: 'Internal Server Error' }
)
)
const api = new ApiClient('test-token')
try {
await api.reopenSession('session-Y')
expect.unreachable('expected reopenSession to throw')
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
expect((error as ApiError).code).toBe('something broke')
}
})
it('passes the 422 missing-metadata body through unchanged so the UI can show the missing fields', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
error: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id',
missing: ['cursorSessionId']
}),
{ status: 422, statusText: 'Unprocessable Entity' }
)
)
const api = new ApiClient('test-token')
try {
await api.reopenSession('session-Z')
expect.unreachable('expected reopenSession to throw')
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
const apiError = error as ApiError
expect(apiError.status).toBe(422)
expect(apiError.body).toContain('cursorSessionId')
}
})
it('loads the Cursor chat store status for the selected session', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ onDisk: false, store: null }), { status: 200 })
)
const api = new ApiClient('test-token')
await expect(api.getCursorChatStoreStatus('session cursor')).resolves.toEqual({
onDisk: false,
store: null
})
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/sessions/session%20cursor/cursor-chat-store')
})
it('loads the authoritative queued state for encoded session IDs', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({
queuedLocalIds: ['local-2'],
invokedLocalMessages: [{ localId: 'local-3', invokedAt: 1_000 }]
}), { status: 200 })
)
const api = new ApiClient('test-token')
await expect(api.getQueuedState('session /?#', ['local-1', 'local-2'])).resolves.toEqual({
queuedLocalIds: ['local-2'],
invokedLocalMessages: [{ localId: 'local-3', invokedAt: 1_000 }]
})
const [url, init] = fetchMock.mock.calls[0] ?? []
expect(url).toBe('/api/sessions/session%20%2F%3F%23/messages/queued-state')
expect(init).toMatchObject({
method: 'POST',
body: JSON.stringify({ localIds: ['local-1', 'local-2'] })
})
expect(new Headers(init?.headers).get('content-type')).toBe('application/json')
})
it('lets fetch set the multipart boundary for transcription uploads', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ text: 'hello' }), { status: 200 }))
const api = new ApiClient('test-token')
const file = new File(['audio'], 'speech.webm', { type: 'audio/webm' })
await api.transcribeVoice({ file, provider: 'openai', mode: 'standard' })
const [, init] = fetchMock.mock.calls[0] ?? []
expect(init?.body).toBeInstanceOf(FormData)
expect(new Headers(init?.headers).has('content-type')).toBe(false)
})
it('preserves an unavailable voice backend response', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ backend: null, backends: [] }), { status: 200 })
)
const api = new ApiClient('test-token')
await expect(api.fetchVoiceBackend()).resolves.toEqual({ backend: null, backends: [] })
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/voice/backend')
})
})