102 lines
5 KiB
TypeScript
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);
|
|
});
|