textmachine/frontend/src/mock/handlers.ts

208 lines
8.8 KiB
TypeScript

// 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 = <T extends object>(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<Schemas['Problem']>(
{ 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<Row>(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<Schemas['ChapterList']>({ 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<Schemas['UnitList']>({ 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<Schemas['NoteList']>({ 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<Schemas['Bank']>({
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<Schemas['RunOptions']>({ 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<Schemas['BookDetail']>({
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<Schemas['Library']>({ 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<Schemas['Run']>(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<Schemas['Run']>({ ...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<Schemas['BankDecisionsResult']>({
revision: world.revision,
pending_decisions: left,
complete: left === 0,
});
}),
];
}