test(admin): cover lib/api fetch wrapper (close Phase C coverage gap)

This commit is contained in:
Bas van Rossem
2026-07-23 15:39:25 +02:00
parent a30441b2a2
commit a53a253f91
2 changed files with 119 additions and 0 deletions

View 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 });
});
});