208 lines
8.8 KiB
TypeScript
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,
|
|
});
|
|
}),
|
|
];
|
|
}
|