Files
TREK/server/tests/unit/scripts/query-api-ratchet.test.ts
T
Maurice 4a3c97e500 docs(server): name the query API per statement shape and hold QueryBuilder calls to a baseline
server/CLAUDE.md now says which API new repository code uses: Kysely for join, aggregate and set-based statements, the entity methods for single-table CRUD, no new QueryBuilder calls. The TrekRepository docstring no longer calls Kysely an escape hatch. lint:query-api counts this.qb()/createQueryBuilder() per repository file against a baseline that may only shrink and runs in the server CI job.

The same file documents the JSON column and timestamp rules from the two changes before.
2026-10-10 20:15:56 +02:00

107 lines
5.0 KiB
TypeScript

/**
* scripts/query-api-ratchet.mjs holds the MikroORM QueryBuilder calls in the
* repositories to their baseline (server/CLAUDE.md, "Which query API"). It
* runs the real script against throwaway server roots (--dir) and once on this
* repository, so the committed baseline is checked by the unit suite too.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
const SCRIPT = path.join(__dirname, '../../../scripts/query-api-ratchet.mjs');
const SERVER_ROOT = path.join(__dirname, '../../..');
const REPOS = 'src/db/repositories';
// Spelled in pieces so this file reads as what it is, a fixture.
const QB = ['this.', 'qb(', "'r')"].join('');
const calls = (n: number) => `await ${QB}.select('*').execute();\n`.repeat(n);
const roots: string[] = [];
function serverRoot(files: Record<string, string>, baseline: unknown = { counts: {} }): string {
const dir = mkdtempSync(path.join(tmpdir(), 'trek-query-api-'));
roots.push(dir);
mkdirSync(path.join(dir, REPOS), { recursive: true });
mkdirSync(path.join(dir, 'scripts'), { recursive: true });
for (const [file, text] of Object.entries(files)) {
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
writeFileSync(path.join(dir, file), text);
}
if (baseline !== null) {
writeFileSync(path.join(dir, 'scripts/query-api-baseline.json'), typeof baseline === 'string' ? baseline : JSON.stringify(baseline));
}
return dir;
}
function run(dir: string, ...args: string[]): { status: number | null; out: string } {
try {
const stdout = execFileSync(process.execPath, [SCRIPT, `--dir=${dir}`, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
return { status: 0, out: stdout };
} catch (err) {
const { status, stdout, stderr } = err as { status: number | null; stdout: string; stderr: string };
return { status, out: `${stdout}${stderr}` };
}
}
afterEach(() => {
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe('query-api-ratchet.mjs', () => {
it('QAPI-001: passes when no repository builds through the QueryBuilder', () => {
const { status, out } = run(serverRoot({ [`${REPOS}/A.repository.ts`]: 'await this.findOne({ id });\n' }));
expect(status).toBe(0);
expect(out).toContain('query-api: 0 QueryBuilder call(s) in 0 file(s)');
});
it('QAPI-002: fails a new file holding one, and counts createQueryBuilder too', () => {
const { status, out } = run(serverRoot({ [`${REPOS}/New.repository.ts`]: `${calls(1)}em.createQueryBuilder(X);\n` }));
expect(status).toBe(1);
expect(out).toContain(`FAIL ${REPOS}/New.repository.ts: 2 QueryBuilder call(s), a file without a baseline entry may hold none.`);
});
it('QAPI-003: holds a baselined file to its entry', () => {
const baseline = { counts: { [`${REPOS}/A.repository.ts`]: 2 } };
const grown = run(serverRoot({ [`${REPOS}/A.repository.ts`]: calls(3) }, baseline));
expect(grown.status).toBe(1);
expect(grown.out).toContain('its baseline is 2.');
expect(run(serverRoot({ [`${REPOS}/A.repository.ts`]: calls(2) }, baseline)).status).toBe(0);
});
it('QAPI-004: the base class that defines qb is not counted', () => {
expect(run(serverRoot({ [`${REPOS}/_shared/trek-repository.ts`]: 'return super.qb(alias);\n' })).status).toBe(0);
});
it('QAPI-005: an entry above its file fails until --update lowers it, and --update never raises or adds one', () => {
const dir = serverRoot(
{ [`${REPOS}/A.repository.ts`]: calls(1), [`${REPOS}/B.repository.ts`]: calls(4) },
{ counts: { [`${REPOS}/A.repository.ts`]: 3, [`${REPOS}/Gone.repository.ts`]: 1, [`${REPOS}/B.repository.ts`]: 2 } },
);
const stale = run(dir);
expect(stale.status).toBe(1);
expect(stale.out).toContain(`FAIL ${REPOS}/A.repository.ts is held at 3`);
expect(stale.out).toContain(`FAIL ${REPOS}/Gone.repository.ts is held at 1`);
run(dir, '--update');
const lowered = JSON.parse(readFileSync(path.join(dir, 'scripts/query-api-baseline.json'), 'utf8')) as { counts: Record<string, number> };
expect(lowered.counts).toEqual({ [`${REPOS}/A.repository.ts`]: 1, [`${REPOS}/B.repository.ts`]: 2 });
});
it('QAPI-006: a missing or malformed baseline, or no repositories folder, stops the run', () => {
expect(run(serverRoot({}, null)).out).toContain('cannot be read');
expect(run(serverRoot({}, '{"counts":{"x":0}}')).out).toContain('expected a whole number above 0');
expect(run(serverRoot({}, '[]')).out).toContain('must be an object with a "counts" object');
const empty = mkdtempSync(path.join(tmpdir(), 'trek-query-api-'));
roots.push(empty);
expect(run(empty).out).toContain('does not exist');
});
it('QAPI-007: this repository holds its committed baseline', () => {
const { status, out } = run(SERVER_ROOT);
expect(out).toContain('query-api:');
expect(status).toBe(0);
});
});