import { afterEach, describe, expect, test, vi } from 'vitest'; import { api, apiFetch } 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 }); }); }); describe('api.get/post/del', () => { test('api.get returns typed JSON on 200', async () => { stubFetch(200, { id: 'abc', name: 'A' }); type T = { id: string; name: string }; const result = await api.get('/api/artists/abc'); expect(result).toEqual({ id: 'abc', name: 'A' }); }); test('api.post serializes body and sets method', async () => { const spy = stubFetch(200, { ok: true }); await api.post('/api/auth/login', { username: 'u', password: 'p' }); const init = spy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' })); }); test('api.del sends DELETE and resolves to null on 204', async () => { const spy = stubFetch(204, null); const result = await api.del('/api/sessions/xyz'); expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); expect(result).toBeNull(); }); }); describe('apiFetch 401 interceptor', () => { test('401 response triggers auth.logout({silent:true}) and still throws', async () => { const logoutSpy = vi.fn(); vi.doMock('$lib/auth/store.svelte', () => ({ logout: logoutSpy, login: vi.fn(), bootstrap: vi.fn(), user: { value: null } })); // Re-import to pick up the mock. const { apiFetch: apiFetchFresh } = await import('./client'); stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } }); await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({ code: 'unauthorized', status: 401 }); expect(logoutSpy).toHaveBeenCalledWith({ silent: true }); vi.doUnmock('$lib/auth/store.svelte'); }); });