diff --git a/apps/admin/src/lib/api.test.ts b/apps/admin/src/lib/api.test.ts new file mode 100644 index 0000000..907c720 --- /dev/null +++ b/apps/admin/src/lib/api.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { apiFetch, signIn, ApiError } from './api'; +import { getToken, setToken } from './auth-storage'; + +describe('api client', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('attaches the bearer token to requests', async () => { + const mock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal('fetch', mock); + setToken('tok'); + + await apiFetch('/api/me'); + + expect(mock).toHaveBeenCalledTimes(1); + const [url, init] = mock.mock.calls[0]; + expect(url).toBe('http://localhost:3000/api/me'); + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer tok'); + }); + + it('omits the Authorization header when no token is stored', async () => { + const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', mock); + + await apiFetch('/api/me'); + + const [, init] = mock.mock.calls[0]; + expect(new Headers(init.headers).has('Authorization')).toBe(false); + }); + + it('throws ApiError on a non-2xx response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))); + + await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 }); + await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError); + }); + + it('returns undefined when the response body is empty', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 200 }))); + + await expect( + apiFetch('/api/admin/sessions/1/stop', { method: 'POST' }), + ).resolves.toBeUndefined(); + }); + + it('sets Content-Type to application/json when a body is present and none is set', async () => { + const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', mock); + + await apiFetch('/api/admin/sessions', { method: 'POST', body: JSON.stringify({ a: 1 }) }); + + const [, init] = mock.mock.calls[0]; + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json'); + }); + + it('does not override an explicitly provided Content-Type header', async () => { + const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', mock); + + await apiFetch('/x', { + method: 'POST', + body: 'raw', + headers: { 'Content-Type': 'text/plain' }, + }); + + const [, init] = mock.mock.calls[0]; + expect(new Headers(init.headers).get('Content-Type')).toBe('text/plain'); + }); + + it('signIn stores the token from the set-auth-token header', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(null, { status: 200, headers: { 'set-auth-token': 'xyz' } }), + ), + ); + + await signIn('a@b.c', 'pw'); + + expect(getToken()).toBe('xyz'); + }); + + it('signIn throws ApiError when credentials are rejected', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))); + + await expect(signIn('a@b.c', 'pw')).rejects.toBeInstanceOf(ApiError); + }); + + it('signIn throws when the set-auth-token header is missing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))); + + await expect(signIn('a@b.c', 'pw')).rejects.toMatchObject({ status: 500 }); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 2dd355a..b513ec8 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -598,3 +598,18 @@ Guaranteed behaviors: - Lifecycle (`src/screens/Stopwatch.test.tsx`): running locally after a successful start, when the active-sessions poll returns a list **without** the local session (only another worker's), the timer resets to idle and the `Deze sessie is door de beheerder gestopt.` notice shows. + +## Admin lib/api (fetch wrapper) + +Src covered: `apps/admin/src/lib/api.ts` — 100% stmts/branch/funcs/lines. (Filled in Phase C — +this base fetch helper fell between the D-domains, which covered the per-resource `api/*.ts`.) + +Guaranteed behaviors: + +- `apiFetch` attaches `Authorization: Bearer ` when a token is stored, and omits it when not. +- It sets `Content-Type: application/json` when a body is present and none was set, and never + overrides an explicitly provided `Content-Type`. +- It throws `ApiError` (carrying the HTTP status) on any non-2xx response, and returns `undefined` + for an empty 2xx body (otherwise the parsed JSON). +- `signIn` posts to `/api/auth/sign-in/email`, stores the token from the `set-auth-token` header, + throws `ApiError` on rejected credentials, and throws `ApiError(500)` when the header is missing.