diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..053f64d --- /dev/null +++ b/web/src/api/client.test.ts @@ -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 { + 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'); +}); diff --git a/web/src/api/events.test.ts b/web/src/api/events.test.ts new file mode 100644 index 0000000..1500582 --- /dev/null +++ b/web/src/api/events.test.ts @@ -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 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); +});