221 lines
9.1 KiB
TypeScript
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);
|
|
});
|