// Locks on the contract itself. Drift: the committed `schema.ts` must equal what the generator // produces from `openapi.yaml` right now, so editing the spec without regenerating — or editing the // generated file by hand — cannot pass silently. Handshake: the version this build speaks must be // the version the spec declares. Vocabulary: the seam must narrow, not crash, and not guess. import openapiTS, { astToString } from 'openapi-typescript'; import { expect, test } from 'vitest'; import { readFile } from 'node:fs/promises'; import { contractVersion } from '../mock/events'; import { narrow } from './contract'; import { majorOf, supportedMajor } from './stream'; import { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary'; import type { components } from './schema'; type Schemas = components['schemas']; // Paths from the package root, and the spec handed over as a string, so the generator resolves // nothing itself. Measured: under happy-dom `import.meta.url` stays file://, but the generator // fetches through the DOM `fetch()`, which rebases the path onto `location.href` — a URL built // from import.meta.url goes out to http://localhost:3000/ and dies on ECONNREFUSED. const specPath = `${process.cwd()}/docs/api-contract/openapi.yaml`; const generatedPath = `${process.cwd()}/src/api/schema.ts`; // The "auto-generated" banner is written by the CLI and not by the programmatic API: compare // bodies, and assert the banner separately — without it the file stops declaring itself // generated and the next session edits it by hand. const generatedBanner = /^\/\*\*[\s\S]*?\*\/\n\n/; test('generated types have not drifted from the spec', async () => { const spec = await readFile(specPath, 'utf8'); const fresh = astToString(await openapiTS(spec)); const committed = await readFile(generatedPath, 'utf8'); expect(committed).toMatch(generatedBanner); expect(committed).toContain('auto-generated'); expect(committed.replace(generatedBanner, '')).toBe(fresh); }, 60_000); // The stream refuses a major it does not speak, which means the build has to know its own major. // A spec bumped to 1.x with this constant left at 0 would refuse every stream at run time; here it // fails at build time instead. test('the major version this build speaks is the one the spec declares', async () => { const spec = await readFile(specPath, 'utf8'); const declared = /^\s{2}version:\s*(\S+)\s*$/m.exec(spec)?.[1]; expect(declared, 'info.version not found in the spec').toBeDefined(); expect(majorOf(declared as string)).toBe(supportedMajor); }); // The fixture announces a version in its handshake, and nothing at run time can read the spec to // check it: the copy is locked here. It drifted a whole session unnoticed, because the client // compares the MAJOR — so a wrong minor breaks no test and tells no one. test('the version the fixture announces is the version the spec declares', async () => { const spec = await readFile(specPath, 'utf8'); expect(contractVersion).toBe(/^\s{2}version:\s*(\S+)\s*$/m.exec(spec)?.[1]); }); test('an unknown vocabulary value is narrowed, not trusted and not thrown', () => { expect(bookStatus.read('translating')).toBe('translating'); expect(bookStatus.read('status-from-a-future-contract')).toBeNull(); // A bank row may legally arrive with no kind at all, which is not the same as an unknown one — // both end as null, and both mean "show the row, invent nothing". expect(termKind.read('')).toBeNull(); }); test('every value of every vocabulary has a meaning, and so does the unknown one', () => { expect(bookStatus.values).toHaveLength(11); for (const value of bookStatus.values) expect(bookStatus.describe(value).label).not.toBe(''); expect(bookStatus.describe(null).label).not.toBe(''); expect(pausedReason.describe(null).label).not.toBe(''); }); // The defect this replaces, spelled out: an unknown severity used to fall into the QUIET branch, so // a step from a future contract would be silently demoted to "have a glance". test('an unknown severity takes the attention branch, not the quiet one', () => { expect(noteSeverity.describe(noteSeverity.read('glance')).tone).toBe('quiet'); expect(noteSeverity.describe(noteSeverity.read('screaming')).tone).toBe('note'); }); test('an unknown signing status does not get to claim the row is signed', () => { expect(termStatus.describe(termStatus.read('approved')).canon).toBe(true); expect(termStatus.describe(termStatus.read('countersigned')).canon).toBe(false); }); test('narrowing keeps every other field and replaces only the vocabularies', () => { const wire = { id: 'bk_1', title: 'A Book', source_lang: 'zh', target_lang: 'ru', chapter_count: 3, added_at: '2026-08-08T00:00:00Z', status: 'from_the_future', progress: { draft: { done: 1, total: 2 }, edit: { done: 0, total: 2 } }, note_count: 0, } as unknown as Schemas['Book']; const book = narrow.book(wire); expect(book.status).toBeNull(); expect(book.title).toBe('A Book'); expect(book.progress.draft.done).toBe(1); });