Added tests for web/src/api
This commit is contained in:
1 parent
60643546f6
commit
f9064511b0
2 files changed
+314
No files matched your search
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user