// 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(); server.use( http.get('*/v0/books', ({ request }) => { seen.set('GET', request.headers.get('X-TM-Client')); return HttpResponse.json({ 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); });