// The mock network. MSW rather than a stubbed module on purpose: the application then does real // `fetch` and a real `EventSource`, so the waiting, error and empty branches are the code that // runs in production and not a second implementation of it. // // Measured before this was built (probe, 08.08): MSW's service worker does intercept `EventSource` // in Chromium and delivers a streamed body frame by frame, `lastEventId` included. import { HttpResponse, delay, http } from 'msw'; import type { Scenario } from '../api'; import type { components } from '../api/schema'; import { runEvents } from './events'; import { intakeHandler } from './intake'; import { bookId, worldOf, type World } from './worlds'; type Schemas = components['schemas']; // Both helpers are typed on the way IN and plain `Response` on the way out. The body type is what // matters — a made-up field fails the typecheck — while a resolver that returns a union of two // differently-typed MSW responses does not infer. const json = (body: T, init?: ResponseInit): Response => HttpResponse.json(body, init); /** * The titles the fake platform sends in `problem+json`. Named rather than written inline, because a * test asserts that the client hands the phrase on untouched — and an assertion that repeats the * literal locks its own copy instead of the fixture's. */ export const problemTitles = { unavailable: 'Сервис временно недоступен', bookNotFound: 'Книга не найдена', runNotFound: 'Прогон не найден', noClientHeader: 'Запрос без заголовка клиента', ceilingTooHigh: 'Потолок больше доступного остатка', }; const problem = (status: number, title: string): Response => json( { type: 'about:blank', title, status }, { status, headers: { 'Content-Type': 'application/problem+json' } }, ); // Cursors are opaque to the client by contract, so their shape here is free — an offset is enough, // and anything the client tried to read out of it would be a bug this fixture cannot cause. function page(url: URL, rows: Row[], fallbackSize: number) { const limit = Number(url.searchParams.get('limit') ?? fallbackSize); const from = Number(url.searchParams.get('cursor')?.replace('p', '') ?? 0); const slice = rows.slice(from, from + limit); const next = from + limit; return { rows: slice, next_cursor: next < rows.length ? `p${String(next)}` : null }; } export function handlersFor(scenario: Scenario) { const world = worldOf(scenario); // The waiting branch has to be a real waiting branch. A response that never comes is what the // screen sees while a slow platform is thinking, and this is the only way to hold it still long // enough to photograph. if (scenario === 'loading') { return [ http.get('*/v0/*', async () => { await delay('infinite'); return HttpResponse.error(); }), ]; } if (scenario === 'error') { return [http.get('*/v0/*', () => problem(503, problemTitles.unavailable))]; } // ONE read fails while the rest work. Its own route because a branch that cannot be opened by URL // is checked by nothing — and this one used to draw a failed chapter list as a book with no // chapters, which no test could have seen. if (scenario === 'partial') { return [ http.get('*/v0/books/:bookId/chapters', () => problem(503, problemTitles.unavailable)), ...reads(world), ...actions(world), runEvents(scenario, world), ]; } return [ // The intake stands FIRST: MSW takes the first handler that matches, and `POST /books` has to // win over nothing else — the reads below answer `GET` only. A world that does not accept books // does not mount it at all, which is exactly how the platform answers 404 on a deployment // without an intake (contract 0.2.3, PD-174). ...(world.intake === true ? [intakeHandler] : []), ...reads(world), ...actions(world), runEvents(scenario, world), ]; } function reads(world: World) { // Read PER REQUEST, not captured once: the run advances the world, and a handler that froze the // revision at construction time would answer a read with a number older than a frame it had // already emitted — a server the contract forbids. const revision = () => world.revision; return [ http.get('*/v0/usage', () => json(world.usage)), http.get('*/v0/books/:bookId/chapters', ({ params, request }) => { const { rows, next_cursor } = page( new URL(request.url), world.chaptersOf(String(params.bookId)), world.pageSize, ); return json({ revision: revision(), next_cursor, chapters: rows }); }), http.get('*/v0/books/:bookId/chapters/:chapterId/units', ({ params, request }) => { const { rows, next_cursor } = page( new URL(request.url), world.unitsOf(String(params.bookId), String(params.chapterId)), world.pageSize, ); return json({ revision: revision(), next_cursor, units: rows }); }), http.get('*/v0/books/:bookId/notes', ({ params, request }) => { const { rows, next_cursor } = page( new URL(request.url), world.notesOf(String(params.bookId)), world.pageSize, ); return json({ revision: revision(), next_cursor, notes: rows }); }), http.get('*/v0/books/:bookId/bank', ({ params, request }) => { const found = world.bankOf(String(params.bookId)); const { rows, next_cursor } = page(new URL(request.url), found.terms, world.pageSize); return json({ revision: revision(), next_cursor, // Counts are of the whole bank, not of the page — the contract says so, and a client that // summed pages would multiply them. total: found.total, signed: found.signed, terms: rows, }); }), http.get('*/v0/books/:bookId/run-options', () => json({ ceiling: world.ceiling }), ), http.get('*/v0/books/:bookId', ({ params }) => { const found = world.books.find((row) => row.id === String(params.bookId)); if (!found) return problem(404, problemTitles.bookNotFound); return json({ revision: revision(), book: found, run: world.runOf(found.id), }); }), http.get('*/v0/books', ({ request }) => { const { rows, next_cursor } = page(new URL(request.url), world.books, world.pageSize); return json({ revision: revision(), next_cursor, books: rows }); }), ]; } // Unsafe methods are refused without `X-TM-Client`: the client half of the CSRF rule, enforced by // the fixture so that forgetting the header fails here rather than in production. function actions(world: World) { const missingHeader = (request: Request) => request.headers.get('X-TM-Client') === null; const runOf = (id: string) => world.runOf(id) ?? null; return [ http.post('*/v0/books/:bookId/runs', async ({ params, request }) => { if (missingHeader(request)) return problem(403, problemTitles.noClientHeader); const body = (await request.json()) as Schemas['RunRequest']; const ceiling = world.ceiling; // The world may have its own answer — bounds that moved between the read and this call, which // is what the contract's 409 is for. Without one, the bounds it answered the read with decide. const fits = world.fits?.(body.ceiling_chapters) ?? (body.ceiling_chapters >= ceiling.min_chapters && body.ceiling_chapters <= ceiling.max_chapters); if (!fits) return problem(409, problemTitles.ceilingTooHigh); const started = world.startRun(String(params.bookId), body); if (!started) return problem(404, problemTitles.bookNotFound); return json(started, { status: 202 }); }), http.post('*/v0/runs/:runId/stop', ({ request }) => { if (missingHeader(request)) return problem(403, problemTitles.noClientHeader); const stopped = runOf(bookId); if (!stopped) return problem(404, problemTitles.runNotFound); return json({ ...stopped, status: 'stopped' }, { status: 202 }); }), http.post('*/v0/books/:bookId/bank/decisions', async ({ request }) => { if (missingHeader(request)) return problem(403, problemTitles.noClientHeader); const body = (await request.json()) as Schemas['BankDecisionsRequest']; const found = world.bankOf(bookId); // The stop clears only on a COMPLETE set, so the answer is what is left, not "saved". const left = Math.max(0, found.total - found.signed - body.decisions.length); return json({ revision: world.revision, pending_decisions: left, complete: left === 0, }); }), ]; }