mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
141 lines
5.6 KiB
TypeScript
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')
|
|
})
|
|
})
|