feat(web): add apiFetch with typed error envelope
Wraps fetch, parses JSON, and throws ApiError{code,message,status}
on non-2xx. Degrades to {code:'unknown'} when the error body is not
the expected envelope shape.
This commit is contained in:
@@ -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<Response> = {}) {
|
||||
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<string, string>)['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<ApiError>({
|
||||
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('<html>500</html>', { status: 500, statusText: 'Internal Server Error' })
|
||||
));
|
||||
await expect(apiFetch('/api/ping')).rejects.toMatchObject<ApiError>({
|
||||
code: 'unknown',
|
||||
message: 'Internal Server Error',
|
||||
status: 500
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,22 @@ export type LoginResponse = {
|
||||
token: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user