diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts new file mode 100644 index 00000000..891bbc2a --- /dev/null +++ b/web/src/lib/api/client.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { apiFetch, type ApiError } from './client'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stubFetch(status: number, body: unknown, init: Partial = {}) { + const res = new Response( + body === null ? null : JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' }, ...init } + ); + const spy = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', spy); + return spy; +} + +describe('apiFetch', () => { + test('resolves with parsed JSON on 200', async () => { + stubFetch(200, { hello: 'world' }); + const result = await apiFetch('/api/ping'); + expect(result).toEqual({ hello: 'world' }); + }); + + test('sends Content-Type application/json by default', async () => { + const spy = stubFetch(200, {}); + await apiFetch('/api/ping'); + const init = spy.mock.calls[0][1] as RequestInit; + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + }); + + test('resolves to null on 204', async () => { + stubFetch(204, null); + const result = await apiFetch('/api/ping', { method: 'DELETE' }); + expect(result).toBeNull(); + }); + + test('throws ApiError from error envelope on 4xx', async () => { + stubFetch(404, { error: { code: 'not_found', message: 'track not found' } }); + await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({ + code: 'not_found', + message: 'track not found', + status: 404 + }); + }); + + test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('500', { status: 500, statusText: 'Internal Server Error' }) + )); + await expect(apiFetch('/api/ping')).rejects.toMatchObject({ + code: 'unknown', + message: 'Internal Server Error', + status: 500 + }); + }); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 5c8c09a8..c7707d60 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -14,3 +14,22 @@ export type LoginResponse = { token: string; user: User; }; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + ...init + }); + const body = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) { + const envelope = body && (body as { error?: { code?: string; message?: string } }).error; + const err: ApiError = { + code: envelope?.code ?? 'unknown', + message: envelope?.message ?? res.statusText, + status: res.status + }; + throw err; + } + return body; +}