mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
1bba65634c
The server suite seeds and reads its database through some 5,500 raw better-sqlite3 statements on the sync handle. They restate the schema by hand, skip the repositories the app goes through and run on SQLite only, so nothing in the suite could ever exercise the dialect layer on another engine. tests/helpers/factories/ adds typed factories per domain (users, trips and days, places, assignments, bookings, budget, packing, to-dos, files, journeys, tours, collections, notifications, settings, plugins, tokens, atlas, vacay, photos, collab) and generic readers (findRow, findRows, countRows, updateRows, deleteRows) that go through MikroORM in a request context of their own, against the app's ORM or a createTestOrm handle. They refuse a write to a read-only foreign-key twin, which MikroORM would otherwise drop without a word. The README there is the conversion guide. The categories integration suite and the tags and calendar-feed e2e suites are converted in full as worked examples; the tags suite also moves from hand-rolled tables to the migrated snapshot. lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left to a per-file baseline: growth and stale entries fail, files whose subject is raw SQL are exempt with a reason, and a single statement can be allowed with a test-sql-allow comment. CI runs it next to lint:boundaries.
206 lines
8.8 KiB
TypeScript
206 lines
8.8 KiB
TypeScript
/**
|
|
* scripts/test-sql-ratchet.mjs is the CI gate that keeps raw better-sqlite3
|
|
* statements in the server tests from growing back while the suite moves onto
|
|
* the ORM factories (tests/helpers/factories). A gate nobody tests passes
|
|
* forever once it breaks, so this runs the real script as a child process
|
|
* against throwaway server roots (--dir), proves it fails on growth and on
|
|
* stale entries, honours the exemption list and the allow marker, and refuses
|
|
* a broken setup instead of passing silently. The last case runs it 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/test-sql-ratchet.mjs');
|
|
const SERVER_ROOT = path.join(__dirname, '../../..');
|
|
|
|
interface ExecError {
|
|
status: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
interface Baseline {
|
|
exempt: Record<string, string>;
|
|
counts: Record<string, number>;
|
|
}
|
|
|
|
// Spelled in pieces so this file holds no raw call of its own for the ratchet to count.
|
|
const CALL = ['.', 'prepare('].join('');
|
|
const stmt = (n: number) => `db${CALL}'SELECT 1').get();\n`.repeat(n);
|
|
|
|
const roots: string[] = [];
|
|
|
|
function serverRoot(files: Record<string, string>, baseline: unknown = { exempt: {}, counts: {} }): string {
|
|
// null writes no baseline file at all.
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'trek-test-sql-'));
|
|
roots.push(dir);
|
|
mkdirSync(path.join(dir, 'tests'), { 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/test-sql-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 ExecError;
|
|
return { status, out: `${stdout}${stderr}` };
|
|
}
|
|
}
|
|
|
|
function readBaseline(dir: string): Baseline {
|
|
return JSON.parse(readFileSync(path.join(dir, 'scripts/test-sql-baseline.json'), 'utf8')) as Baseline;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('test-sql-ratchet.mjs', () => {
|
|
it('TSQL-001: passes when no test file holds a raw statement', () => {
|
|
const dir = serverRoot({ 'tests/a.test.ts': 'await makeUser(orm);\n' });
|
|
const { status, out } = run(dir);
|
|
expect(status).toBe(0);
|
|
expect(out).toContain('test-sql: 0 raw statement(s) in 0 baselined file(s), 0 file(s) exempt');
|
|
});
|
|
|
|
it('TSQL-002: fails a file without a baseline entry that holds one', () => {
|
|
const { status, out } = run(serverRoot({ 'tests/unit/new.test.ts': stmt(1) }));
|
|
expect(status).toBe(1);
|
|
expect(out).toContain(
|
|
'FAIL tests/unit/new.test.ts: 1 raw statement(s), a file without a baseline entry may hold none.',
|
|
);
|
|
});
|
|
|
|
it('TSQL-003: fails a baselined file that grows past its entry, passes at it', () => {
|
|
const baseline = { exempt: {}, counts: { 'tests/a.test.ts': 2 } };
|
|
const grown = run(serverRoot({ 'tests/a.test.ts': stmt(3) }, baseline));
|
|
expect(grown.status).toBe(1);
|
|
expect(grown.out).toContain('FAIL tests/a.test.ts: 3 raw statement(s), its baseline is 2.');
|
|
expect(run(serverRoot({ 'tests/a.test.ts': stmt(2) }, baseline)).status).toBe(0);
|
|
});
|
|
|
|
it('TSQL-004: counts every call on a line, in helpers too, and walks .ts and .mjs alike', () => {
|
|
const dir = serverRoot({
|
|
'tests/helpers/seed.ts': `db${CALL}'a').run(); db${CALL}'b').run();\n`,
|
|
'tests/tools/x.mjs': stmt(1),
|
|
'tests/notes.md': stmt(5),
|
|
});
|
|
const { status, out } = run(dir);
|
|
expect(status).toBe(1);
|
|
expect(out).toContain('tests/helpers/seed.ts: 2 raw');
|
|
expect(out).toContain('tests/tools/x.mjs: 1 raw');
|
|
expect(out).not.toContain('notes.md');
|
|
});
|
|
|
|
it('TSQL-005: an allow marker with a reason on the line or the line above takes the statement out of the count', () => {
|
|
const text =
|
|
`// test-sql-allow: PRAGMA introspection has no ORM equivalent\n${stmt(1)}` +
|
|
`db${CALL}'PRAGMA table_info(x)').all(); // test-sql-allow: same reason\n`;
|
|
expect(run(serverRoot({ 'tests/a.test.ts': text })).status).toBe(0);
|
|
});
|
|
|
|
it('TSQL-006: a marker without a reason, or two lines up, does not count', () => {
|
|
const bare = run(serverRoot({ 'tests/a.test.ts': `// test-sql-allow:\n${stmt(1)}` }));
|
|
expect(bare.status).toBe(1);
|
|
const far = run(serverRoot({ 'tests/b.test.ts': `// test-sql-allow: reason\n\n${stmt(1)}` }));
|
|
expect(far.status).toBe(1);
|
|
});
|
|
|
|
it('TSQL-007: an exempt file is not counted, and an exemption for a gone file fails', () => {
|
|
const exempt = { 'tests/unit/db/dialect.test.ts': 'pins the SQL each dialect function renders' };
|
|
const ok = run(serverRoot({ 'tests/unit/db/dialect.test.ts': stmt(40) }, { exempt, counts: {} }));
|
|
expect(ok.status).toBe(0);
|
|
expect(ok.out).toContain('1 file(s) exempt');
|
|
|
|
const gone = run(serverRoot({ 'tests/a.test.ts': '' }, { exempt, counts: {} }));
|
|
expect(gone.status).toBe(1);
|
|
expect(gone.out).toContain('FAIL tests/unit/db/dialect.test.ts is exempt in scripts/test-sql-baseline.json, but the file is gone');
|
|
});
|
|
|
|
it('TSQL-008: fails an entry above its file, or for a file with none left, until --update lowers it', () => {
|
|
const shrunk = run(serverRoot({ 'tests/a.test.ts': stmt(1) }, { exempt: {}, counts: { 'tests/a.test.ts': 3 } }));
|
|
expect(shrunk.status).toBe(1);
|
|
expect(shrunk.out).toContain('FAIL tests/a.test.ts is held at 3 in scripts/test-sql-baseline.json, but it has 1 now.');
|
|
expect(shrunk.out).toContain('npm run lint:test-sql -- --update');
|
|
|
|
const converted = run(serverRoot({ 'tests/a.test.ts': '' }, { exempt: {}, counts: { 'tests/a.test.ts': 3 } }));
|
|
expect(converted.status).toBe(1);
|
|
expect(converted.out).toContain('but it has none left (or the file is gone)');
|
|
});
|
|
|
|
it('TSQL-009: --update only lowers and drops counts, never adds one, never touches the exemptions', () => {
|
|
const exempt = { 'tests/raw.test.ts': 'raw by nature' };
|
|
const dir = serverRoot(
|
|
{
|
|
'tests/shrunk.test.ts': stmt(2),
|
|
'tests/converted.test.ts': '',
|
|
'tests/grown.test.ts': stmt(9),
|
|
'tests/new.test.ts': stmt(1),
|
|
'tests/raw.test.ts': stmt(4),
|
|
},
|
|
{
|
|
exempt,
|
|
counts: { 'tests/shrunk.test.ts': 5, 'tests/converted.test.ts': 2, 'tests/grown.test.ts': 6, 'tests/gone.test.ts': 1 },
|
|
},
|
|
);
|
|
const { status } = run(dir, '--update');
|
|
expect(readBaseline(dir)).toEqual({
|
|
exempt,
|
|
counts: { 'tests/grown.test.ts': 6, 'tests/shrunk.test.ts': 2 },
|
|
});
|
|
// The grown file and the new one still fail after the update.
|
|
expect(status).toBe(1);
|
|
});
|
|
|
|
it('TSQL-010: fails closed on a missing, unparsable or malformed baseline', () => {
|
|
const missing = serverRoot({ 'tests/a.test.ts': '' }, null);
|
|
expect(run(missing).status).toBe(1);
|
|
expect(run(missing).out).toContain('test-sql-baseline.json cannot be read');
|
|
|
|
expect(run(serverRoot({}, '{ not json')).status).toBe(1);
|
|
expect(run(serverRoot({}, '[]')).status).toBe(1);
|
|
expect(run(serverRoot({}, { counts: {} })).status).toBe(1);
|
|
// A count that is not a whole number above zero, an exemption without a
|
|
// reason, or a file both exempt and counted is a hand edit gone wrong.
|
|
expect(run(serverRoot({}, { exempt: {}, counts: { 'tests/a.test.ts': 0 } })).status).toBe(1);
|
|
expect(run(serverRoot({}, { exempt: {}, counts: { 'tests/a.test.ts': '2' } })).status).toBe(1);
|
|
expect(run(serverRoot({}, { exempt: { 'tests/a.test.ts': ' ' }, counts: {} })).out).toContain('needs a reason');
|
|
expect(
|
|
run(serverRoot({}, { exempt: { 'tests/a.test.ts': 'why' }, counts: { 'tests/a.test.ts': 1 } })).out,
|
|
).toContain('both exempt and counted');
|
|
});
|
|
|
|
it('TSQL-011: fails closed when tests/ is missing', () => {
|
|
const dir = serverRoot({});
|
|
rmSync(path.join(dir, 'tests'), { recursive: true });
|
|
const { status, out } = run(dir);
|
|
expect(status).toBe(1);
|
|
expect(out).toContain('tests/ does not exist');
|
|
});
|
|
|
|
it('TSQL-012: the committed tree matches the committed baseline', () => {
|
|
const { status, out } = run(SERVER_ROOT);
|
|
expect(out).not.toContain('FAIL');
|
|
expect(status).toBe(0);
|
|
});
|
|
});
|