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