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).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(); }); });