You've already forked agentic-coding-workflow
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { configure, request } from '@/api/client'
|
|
import { ApiException } from '@/api/types'
|
|
|
|
describe('api client', () => {
|
|
let unauthorizedCalled = 0
|
|
|
|
beforeEach(() => {
|
|
unauthorizedCalled = 0
|
|
configure({
|
|
tokenProvider: () => 'tok',
|
|
onUnauthorized: () => {
|
|
unauthorizedCalled++
|
|
},
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('attaches bearer token and parses JSON on 200', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ x: 1 }), { status: 200 }),
|
|
)
|
|
const out = await request<{ x: number }>('/api/test')
|
|
expect(out.x).toBe(1)
|
|
const init = fetchMock.mock.calls[0][1] as RequestInit
|
|
const headers = init.headers as Record<string, string>
|
|
expect(headers['Authorization']).toBe('Bearer tok')
|
|
expect(headers['X-Client-Request-ID']).toBeDefined()
|
|
})
|
|
|
|
it('returns undefined on 204', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 }))
|
|
const out = await request('/api/test', { method: 'DELETE' })
|
|
expect(out).toBeUndefined()
|
|
})
|
|
|
|
it('invokes onUnauthorized for 401 and throws', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ code: 'unauthorized', message: 'no' }), { status: 401 }),
|
|
)
|
|
await expect(request('/api/test')).rejects.toBeInstanceOf(ApiException)
|
|
expect(unauthorizedCalled).toBe(1)
|
|
})
|
|
|
|
it('throws ApiException with body for 4xx/5xx', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ code: 'invalid_input', message: 'bad' }), { status: 400 }),
|
|
)
|
|
try {
|
|
await request('/api/test')
|
|
throw new Error('should have thrown')
|
|
} catch (e) {
|
|
const ex = e as ApiException
|
|
expect(ex.status).toBe(400)
|
|
expect(ex.body.code).toBe('invalid_input')
|
|
}
|
|
})
|
|
|
|
it('schedules a default 30s timeout and forwards abort to fetch', async () => {
|
|
// Replace setTimeout with a stub that captures the scheduled callback,
|
|
// so we can assert the default-timeout scheduling and trigger it manually
|
|
// without waiting wall-clock 30 seconds.
|
|
const captured: Array<{ cb: () => void; ms: number }> = []
|
|
const realSetTimeout = globalThis.setTimeout
|
|
vi.stubGlobal('setTimeout', ((cb: () => void, ms?: number, ...args: unknown[]) => {
|
|
if (ms === 30_000) {
|
|
captured.push({ cb, ms })
|
|
return 0 as unknown as ReturnType<typeof setTimeout>
|
|
}
|
|
return realSetTimeout(cb, ms, ...args)
|
|
}) as typeof setTimeout)
|
|
|
|
try {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
(_input, init) =>
|
|
new Promise((_resolve, reject) => {
|
|
const sig = init?.signal as AbortSignal | undefined
|
|
sig?.addEventListener('abort', () => {
|
|
const err = new Error('aborted')
|
|
err.name = 'AbortError'
|
|
reject(err)
|
|
})
|
|
}),
|
|
)
|
|
|
|
let rejected: unknown = null
|
|
const promise = request('/api/test').catch((e) => {
|
|
rejected = e
|
|
})
|
|
|
|
// Allow the request to register its setTimeout + start the fetch.
|
|
await Promise.resolve()
|
|
|
|
expect(captured.length).toBe(1)
|
|
expect(captured[0].ms).toBe(30_000)
|
|
|
|
// Fire the captured timeout callback to simulate elapsed 30s.
|
|
captured[0].cb()
|
|
await promise
|
|
|
|
expect(rejected).not.toBeNull()
|
|
const init = fetchMock.mock.calls[0][1] as RequestInit
|
|
expect((init.signal as AbortSignal).aborted).toBe(true)
|
|
} finally {
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('forwards a caller-supplied signal abort to the fetch', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
(_input, init) =>
|
|
new Promise((_resolve, reject) => {
|
|
const sig = init?.signal as AbortSignal | undefined
|
|
if (sig) {
|
|
sig.addEventListener('abort', () => {
|
|
const err = new Error('aborted')
|
|
err.name = 'AbortError'
|
|
reject(err)
|
|
})
|
|
}
|
|
}),
|
|
)
|
|
|
|
const ctl = new AbortController()
|
|
const promise = request('/api/test', { signal: ctl.signal })
|
|
const settled = expect(promise).rejects.toThrow()
|
|
ctl.abort()
|
|
await settled
|
|
|
|
const init = fetchMock.mock.calls[0][1] as RequestInit
|
|
expect((init.signal as AbortSignal).aborted).toBe(true)
|
|
})
|
|
})
|