textmachine/frontend/src/api/api.test.ts

221 lines
9.1 KiB
TypeScript

// The data layer against the mock network, the way the screens use it. Not a test of MSW: a test of
// the rules the contract puts on the CLIENT, each of which is invisible until it is broken —
// following a cursor to the end, dropping a stale read, sending the CSRF header, refusing a major
// version, and not guessing at a value it does not know.
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest';
import { ApiError } from './client';
import { advance, rewind } from '../mock/live';
import { majorOf, readFrame, supportedMajor } from './stream';
import { terms } from '../mock/bank';
import { handlersFor, problemTitles } from '../mock/handlers';
import {
freshest,
readBank,
readBook,
readChapters,
readLibrary,
readUsage,
startRun,
stopRun,
submitBankDecisions,
} from './queries';
import type { ChapterList } from './contract';
import type { components } from './schema';
type Schemas = components['schemas'];
// `onUnhandledRequest: 'error'` is deliberate: by default an unmatched request goes to the REAL
// network (measured: ENOTFOUND), which reports a typo in a handler path as a connection failure
// instead of naming it.
const server = setupServer();
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterAll(() => {
server.close();
});
beforeEach(() => {
// The fixture's live state is module state: a run left half-played would leak into the next test.
rewind();
server.use(...handlersFor('showcase'));
});
afterEach(() => {
server.resetHandlers();
});
test('a read returns the shape of the contract, narrowed', async () => {
const library = await readLibrary();
expect(library.revision).toBe(1841);
expect(library.books[0]?.source_lang).toBe('zh');
expect(library.books[0]?.status).toBe('translating');
// The hard case on purpose: an end-to-end counter would read zero here.
expect(library.books[0]?.progress.edit.done).toBe(0);
// Eleven statuses, every one of them on one screen.
expect(new Set(library.books.map((book) => book.status)).size).toBe(11);
});
test('the whole collection is read, not just its first page', async () => {
// The scale world pages for real (500 rows a page over 2284 chapters), so a client that read the
// head and stopped would return 500 here and look perfectly healthy.
server.use(...handlersFor('scale'));
const chapters = await readChapters('bk_scale');
expect(chapters.chapters).toHaveLength(2284);
expect(chapters.next_cursor).toBeNull();
});
// A list assembled out of several pages is only as fresh as its OLDEST page: the contract warns
// that the revision moves while a collection is being paged (`NextCursor`). Stamped with the last
// page, such a list claims a freshness its head does not have — and the read guard, which is the
// only thing standing between a frame and an older answer, then lets it through.
test('a torn list is stamped with its oldest page, not its newest', async () => {
const pages: Schemas['ChapterList'][] = [
{ revision: 1800, next_cursor: 'p1', chapters: [] },
{ revision: 2000, next_cursor: null, chapters: [] },
];
let asked = 0;
server.use(http.get('*/v0/books/:bookId/chapters', () => HttpResponse.json(pages[asked++])));
const list = await readChapters('bk_guzhenren');
expect(list.revision).toBe(1800);
// Which is what the number is FOR: a frame at 1900 has already been applied to the cache, and
// this list — whose head was read before it — must not overwrite what the frame wrote.
const patched: ChapterList = { revision: 1900, next_cursor: null, chapters: [] };
expect(freshest(patched, list)).toBe(patched);
});
test('bank counts are of the whole bank, not summed across pages', async () => {
server.use(...handlersFor('scale'));
const bank = await readBank('bk_scale');
expect(bank.terms).toHaveLength(1200);
expect(bank.total).toBe(1200);
expect(bank.signed).toBeLessThan(bank.total);
});
// The guard the contract puts on the client, tested where it now lives. It used to live in a
// private store of the last read — and that store never saw the STREAM, which patches the same
// cache entry. A read prepared before a frame and delivered after it was accepted, and the live
// progress rolled backwards on the next refetch on focus. Comparing against what the application
// actually holds is what closes it.
test('a read older than what is already held is dropped', () => {
const held = { revision: 1900, chapters: ['live'] };
const late = { revision: 1841, chapters: ['stale'] };
expect(freshest(held, late)).toBe(held);
expect(freshest(held, { revision: 1901, chapters: ['fresh'] })).toMatchObject({ revision: 1901 });
// Equal passes: one transaction is one revision but several answers.
expect(freshest(held, { revision: 1900, chapters: ['sibling'] })).toMatchObject({
chapters: ['sibling'],
});
});
test('a first answer is never dropped, however low its revision', () => {
// Per resource by construction: each query compares only against its own previous value, so a
// notes read at a lower revision than a chapters read cannot empty the notes.
expect(freshest(undefined, { revision: 1 })).toMatchObject({ revision: 1 });
});
test('the fixture never answers a read older than a frame it has emitted', async () => {
rewind();
const before = await readBook('bk_guzhenren');
advance(0);
advance(1);
const after = await readBook('bk_guzhenren');
// A server that froze its read revision while the stream advanced would make the guard above
// untestable AND would violate the contract; the fixture used to do exactly that.
expect(after.revision).toBeGreaterThan(before.revision);
expect(after.book.progress.draft.done).toBeGreaterThan(before.book.progress.draft.done);
rewind();
});
test('the CSRF header goes on unsafe requests and only on those', async () => {
const seen = new Map<string, string | null>();
server.use(
http.get('*/v0/books', ({ request }) => {
seen.set('GET', request.headers.get('X-TM-Client'));
return HttpResponse.json<Schemas['Library']>({ revision: 1, next_cursor: null, books: [] });
}),
http.post('*/v0/runs/:runId/stop', ({ request }) => {
seen.set('POST', request.headers.get('X-TM-Client'));
return HttpResponse.json({ error: 'stop' }, { status: 500 });
}),
);
await readLibrary();
await stopRun('run_41').catch(() => undefined);
// Presence is what protects; the value carries no token semantics and nothing may read one into
// it. A read must NOT carry it — it is not a general-purpose header.
expect(seen.get('POST')).not.toBeNull();
expect(seen.get('GET')).toBeNull();
});
test('an action answers with the run it produced', async () => {
const run = await startRun('bk_guzhenren', { verify_bank: true, ceiling_chapters: 60 });
expect(run.ceiling_chapters).toBe(60);
expect((await stopRun('run_41')).status).toBe('stopped');
});
test('a ceiling outside the bounds is refused, and the refusal is a product phrase', async () => {
await expect(
startRun('bk_guzhenren', { verify_bank: true, ceiling_chapters: 5000 }),
).rejects.toMatchObject({ status: 409 });
const failure = await startRun('bk_guzhenren', {
verify_bank: true,
ceiling_chapters: 5000,
}).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ApiError);
expect((failure as ApiError).problem?.title).toBe(problemTitles.ceilingTooHigh);
});
test('partial bank decisions answer with what is LEFT, not with "saved"', async () => {
// The promoted spelling is the fixture's own for `t_4`. The mock ignores `dst`, and a word made up
// here would read as if the test were choosing the product wording.
const promoted = terms.find((row) => row.id === 't_4')?.dst ?? '';
const result = await submitBankDecisions('bk_guzhenren', [
{ term_id: 't_4', action: 'promote', dst: promoted },
{ term_id: 't_15', action: 'decline' },
]);
expect(result.complete).toBe(false);
expect(result.pending_decisions).toBeGreaterThan(0);
});
test('a refusal becomes an ApiError carrying the product phrase, never engine text', async () => {
server.use(...handlersFor('error'));
const failure = await readLibrary().catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ApiError);
expect((failure as ApiError).status).toBe(503);
expect((failure as ApiError).problem?.title).toBe(problemTitles.unavailable);
});
test('an empty world is empty, not an error', async () => {
server.use(...handlersFor('empty'));
expect((await readLibrary()).books).toEqual([]);
expect((await readUsage()).state).toBe('ok');
});
test('a frame of an unknown name is ignored, and a broken one does not kill the stream', () => {
expect(
readFrame(
'progress',
'{"progress":{"draft":{"done":1,"total":2},"edit":{"done":0,"total":2}}}',
),
).toMatchObject({ event: 'progress' });
expect(readFrame('a_frame_from_a_future_contract', '{}')).toBeNull();
expect(readFrame('progress', 'not json at all')).toBeNull();
});
test('the major version is what the handshake compares', () => {
expect(majorOf('0.2.0')).toBe(supportedMajor);
expect(majorOf('1.0.0')).not.toBe(supportedMajor);
});