Added tests for web/src/api

This commit is contained in:
bwilliams committed 2026-10-16 16:37:58 +01:00
1 parent 60643546f6
commit f9064511b0
2 files changed
+314

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { testOpportunity } from '@/lib/testOpportunity';
import type * as clientModule from './client';
/** A request the fake fetch was given. */
interface SentRequest {
method: string;
url: string;
headers: Headers;
}
let sent: SentRequest[];
/** Answers each request in turn; a test sets the responses it needs. */
let respond: (request: SentRequest) => Response;
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** client.ts afresh: it keeps the CSRF token between calls. */
async function loadClient(): Promise<typeof clientModule> {
vi.resetModules();
return import('./client');
}
beforeEach(() => {
vi.stubEnv('VITE_API_BASE_URL', '/api');
sent = [];
vi.stubGlobal(
'fetch',
vi.fn((url: string, init: RequestInit = {}) => {
const request = {
method: init.method ?? 'GET',
url,
headers: new Headers(init.headers),
};
sent.push(request);
return Promise.resolve(respond(request));
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe('listOpportunities', () => {
test('fetches every page, keeping the first page’s batch', async () => {
const { api } = await loadClient();
respond = (request) => {
const offset = Number(new URL(request.url, 'http://test').searchParams.get('offset'));
return json({
opportunities: [testOpportunity(`ocds-${offset}`)],
offset,
limit: 100,
has_more: offset < 200,
batch: `batch-${offset}`,
});
};
const loaded = await api.listOpportunities();
expect(sent.map((request) => request.url)).toEqual([
'/api/opportunities?offset=0&limit=100',
'/api/opportunities?offset=100&limit=100',
'/api/opportunities?offset=200&limit=100',
]);
expect(loaded.opportunities.map((o) => o.uuid)).toEqual(['ocds-0', 'ocds-100', 'ocds-200']);
expect(loaded.batch).toBe('batch-0');
expect(sent[0]?.headers.get('Accept')).toBe('application/json');
});
});
describe('listChangedOpportunities', () => {
test('asks for the changes after a batch', async () => {
const { api } = await loadClient();
respond = () =>
json({
opportunities: [testOpportunity('ocds-1')],
offset: 0,
limit: 1,
has_more: false,
batch: 'newer',
});
const changes = await api.listChangedOpportunities('a b');
expect(sent[0]?.url).toBe('/api/opportunities?changed_after=a%20b');
expect(changes.batch).toBe('newer');
expect(changes.opportunities.map((o) => o.uuid)).toEqual(['ocds-1']);
});
test('an unknown batch is an ApiError with status 410', async () => {
const client = await loadClient();
respond = () =>
json({ type: 'about:blank', status: 410, title: 'Gone', detail: 'unknown batch' }, 410);
const err: unknown = await client.api.listChangedOpportunities('old').catch((e: unknown) => e);
expect(err).toBeInstanceOf(client.ApiError);
expect(err).toMatchObject({ status: 410, message: 'unknown batch' });
});
});
describe('setMark', () => {
const marks = { ...testOpportunity('ocds-1').marks, won: true };
const csrf = (token: string): Response => json({ token, header: 'X-Csrf-Token' });
test('sends the CSRF token, fetched once and kept', async () => {
const { api } = await loadClient();
respond = (request) => (request.url === '/api/csrf' ? csrf('t1') : json(marks));
expect(await api.setMark('ocds/1', 'won', true)).toEqual(marks);
await api.setMark('ocds/1', 'won', false);
expect(sent.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /api/csrf',
'PUT /api/opportunities/ocds%2F1/marks/won',
'DELETE /api/opportunities/ocds%2F1/marks/won',
]);
expect(sent[1]?.headers.get('X-Csrf-Token')).toBe('t1');
expect(sent[2]?.headers.get('X-Csrf-Token')).toBe('t1');
});
test('on 403, fetches a new token and tries once more', async () => {
const { api } = await loadClient();
let tokens = 0;
respond = (request) => {
if (request.url === '/api/csrf') {
tokens += 1;
return csrf(`t${tokens}`);
}
return request.headers.get('X-Csrf-Token') === 't2' ? json(marks) : json({}, 403);
};
expect(await api.setMark('ocds-1', 'won', true)).toEqual(marks);
expect(tokens).toBe(2);
});
test('a second 403 is an ApiError', async () => {
const client = await loadClient();
respond = (request) => (request.url === '/api/csrf' ? csrf('t') : json({}, 403));
const err: unknown = await client.api.setMark('ocds-1', 'won', true).catch((e: unknown) => e);
expect(err).toBeInstanceOf(client.ApiError);
expect(err).toMatchObject({ status: 403 });
expect(sent.filter((request) => request.method === 'PUT')).toHaveLength(2);
});
test('a conflict is reported with the problem’s detail', async () => {
const { api } = await loadClient();
respond = (request) =>
request.url === '/api/csrf'
? csrf('t')
: json({ status: 409, detail: 'an opportunity cannot be both won and lost' }, 409);
await expect(api.setMark('ocds-1', 'won', true)).rejects.toThrow(
'an opportunity cannot be both won and lost',
);
});
});
test('an error without a problem body names the path and status', async () => {
const { api } = await loadClient();
respond = () => new Response('not json', { status: 502 });
await expect(api.listClassifications()).rejects.toThrow('/classifications returned 502');
});
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { ServerEvent } from '@/types/Events';
import { connectEvents } from './events';
/** A WebSocket the test drives: it opens, sends and closes when told to. */
class FakeSocket {
static sockets: FakeSocket[] = [];
readonly url: string;
isClosed = false;
private readonly listeners = new Map<string, ((event: unknown) => void)[]>();
constructor(url: string) {
this.url = url;
FakeSocket.sockets.push(this);
}
addEventListener(type: string, listener: (event: unknown) => void): void {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
close(): void {
this.isClosed = true;
}
emit(type: string, event: unknown = {}): void {
for (const listener of this.listeners.get(type) ?? []) listener(event);
}
receive(message: unknown): void {
this.emit('message', { data: typeof message === 'string' ? message : JSON.stringify(message) });
}
}
/** The socket connectEvents made last. */
const latest = (): FakeSocket => {
const socket = FakeSocket.sockets.at(-1);
if (!socket) throw new Error('no socket was made');
return socket;
};
let events: ServerEvent[];
let reconnects: number;
let disconnect: () => void;
function connect(protocol = 'http:'): void {
vi.stubGlobal('window', { location: { protocol, host: 'example.test:8080' } });
disconnect = connectEvents({
onEvent: (event) => events.push(event),
onReconnect: () => {
reconnects += 1;
},
});
}
beforeEach(() => {
vi.useFakeTimers();
FakeSocket.sockets = [];
events = [];
reconnects = 0;
vi.stubGlobal('WebSocket', FakeSocket);
});
afterEach(() => {
disconnect();
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('connects to /ws on the page’s host, over wss for https', () => {
connect();
expect(latest().url).toBe('ws://example.test:8080/ws');
disconnect();
connect('https:');
expect(latest().url).toBe('wss://example.test:8080/ws');
});
test('passes on the events it knows and ignores everything else', () => {
connect();
const socket = latest();
socket.emit('open');
const marks = {
uuid: 'ocds-1',
hidden: false,
skipped: true,
interested: false,
bid_submitted: false,
won: false,
lost: false,
};
socket.receive({ type: 'welcome', payload: { clientId: 'X' } });
socket.receive({ type: 'event', eventType: 'SomethingNew', payload: {} });
socket.receive('not json');
socket.receive('null');
socket.emit('message', { data: new ArrayBuffer(1) });
socket.receive({ type: 'event', eventType: 'OpportunityMarksUpdated', payload: marks });
expect(events).toEqual([{ eventType: 'OpportunityMarksUpdated', payload: marks }]);
});
test('reconnects after a growing delay, up to 30 seconds, and reports it', () => {
connect();
latest().emit('open');
expect(reconnects).toBe(0);
const delays: number[] = [];
for (let i = 0; i < 7; i++) {
const count = FakeSocket.sockets.length;
latest().emit('close');
let waited = 0;
while (FakeSocket.sockets.length === count) {
vi.advanceTimersByTime(1000);
waited += 1000;
}
delays.push(waited);
}
expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000]);
latest().emit('open');
expect(reconnects).toBe(1);
// Once connected again, the delay starts from a second again.
const count = FakeSocket.sockets.length;
latest().emit('close');
vi.advanceTimersByTime(1000);
expect(FakeSocket.sockets).toHaveLength(count + 1);
});
test('disconnecting closes the socket for good', () => {
connect();
const socket = latest();
socket.emit('open');
disconnect();
socket.emit('close');
vi.advanceTimersByTime(60_000);
expect(socket.isClosed).toBe(true);
expect(FakeSocket.sockets).toHaveLength(1);
});