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