test(admin): cover lib/api fetch wrapper (close Phase C coverage gap)
This commit is contained in:
104
apps/admin/src/lib/api.test.ts
Normal file
104
apps/admin/src/lib/api.test.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -598,3 +598,18 @@ Guaranteed behaviors:
|
|||||||
- Lifecycle (`src/screens/Stopwatch.test.tsx`): running locally after a successful start, when the
|
- 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
|
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.
|
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 <token>` 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user