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

102 lines
5 KiB
TypeScript

// 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);
});