stratoflights-web/tests/unit/client.test.ts
2026-08-14 00:01:49 +09:00

84 lines
2.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { api, ApiError, setUnauthorizedHandler } from '$api';
function mockFetch(status: number, body: unknown) {
return vi.fn(
async () =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
})
);
}
beforeEach(() => {
document.cookie = 'csrftoken=abc';
});
describe('api client', () => {
it('prepends the base URL and returns parsed JSON', async () => {
vi.stubGlobal('fetch', mockFetch(200, { username: 'demo' }));
const out = await api.get<{ username: string }>('/whoami/');
expect(out).toEqual({ username: 'demo' });
const url = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(String(url)).toContain('/api/whoami/');
});
it('throws ApiError carrying the backend detail on 4xx', async () => {
vi.stubGlobal('fetch', mockFetch(400, { detail: 'Invalid credentials.' }));
await expect(api.post('/login/', {})).rejects.toMatchObject({
name: 'ApiError',
status: 400,
detail: 'Invalid credentials.'
});
});
// The backend does not speak one error shape: DMR's response validator emits
// `detail` as a list of {msg}, Pydantic bodies arrive as a bare list, and a
// few endpoints use DRF-style field maps. Stringifying any of those yields
// "[object Object]" in the UI, so each shape needs flattening.
it('flattens a list-of-objects detail from DMR response validation', async () => {
vi.stubGlobal(
'fetch',
mockFetch(422, { detail: [{ msg: 'Returned status code 400 is not specified', type: 'value_error' }] })
);
await expect(api.post('/register/', {})).rejects.toMatchObject({
detail: 'Returned status code 400 is not specified'
});
});
it('flattens a bare list body of Pydantic errors', async () => {
vi.stubGlobal(
'fetch',
mockFetch(400, [
{ msg: 'Username must be at least 3 characters', loc: ['username'] },
{ msg: 'Invalid email format', loc: ['email'] }
])
);
await expect(api.post('/register/', {})).rejects.toMatchObject({
detail: 'Username must be at least 3 characters; Invalid email format'
});
});
it('flattens a DRF-style field error map', async () => {
vi.stubGlobal('fetch', mockFetch(400, { non_field_errors: ['Point already saved.'] }));
await expect(api.post('/points/', {})).rejects.toMatchObject({
detail: 'Point already saved.'
});
});
it('never surfaces [object Object] for an unrecognised shape', async () => {
vi.stubGlobal('fetch', mockFetch(400, { detail: { nested: { deep: true } } }));
await expect(api.post('/register/', {})).rejects.toMatchObject({
detail: 'Request failed (400)'
});
});
it('invokes the unauthorized handler on 401', async () => {
const handler = vi.fn();
setUnauthorizedHandler(handler);
vi.stubGlobal('fetch', mockFetch(401, { detail: 'Unauthorized' }));
await expect(api.get('/whoami/')).rejects.toBeInstanceOf(ApiError);
expect(handler).toHaveBeenCalledOnce();
});
});