Files
redefined-designs/backend/tests/integration/schemaMirror.integration.test.ts
T
bermudalambandClaude Opus 5 36e6057c6e fix(tests): close the route guard's factory hole and two regex warnings (#307)
Three of the items on the cleanup issue, and the first is the one that mattered.

routesAreWrapped.test.ts could not see a handler built by a factory. `router.post('/x', rotationRoute('left'))` carries no async token of its own, so the guard read those two lines, found nothing to object to, and passed — which is not the same as finding them wrapped. That is how the rotation routes added in #301 went through a test that exists precisely because this convention had already been half-forgotten once, when thirty handlers were added unwrapped after the wrapper existed. It now follows a call to a function declared in the same file and reads its body the same way it reads a registration, so an unwrapped handler inside a factory is an offender. Proved rather than assumed: unwrapping rotationRoute's handler makes the suite fail naming admin.ts, where before it passed.

Only same-file functions are followed, deliberately. app.ts registers express.json(), cookieParser() and uploadsRouter(), none of which is a handler factory and none of which can be resolved from the file being read — treating an unresolvable name as an offender would trade one hole for a permanently red test, so there is a case asserting those are left alone.

The brace and paren walking is now one function rather than two. Adding the factory reader as a near-copy of registrationAt is what a cleanup commit should not do, and the duplicate carried its own cognitive-complexity and loop-counter warnings with it; parameterising the delimiter pair removes both the copy and the warnings it added.

The schema mirror's table regex used `\s*` where kysely-codegen emits exactly two spaces and one after the colon, and `[A-Za-z0-9_]` where `\w` says the same thing. SonarQube flagged both, and the looser form bought nothing and backtracked for it.

An empty status list would have compiled to `in ()`, which is a Postgres syntax error, where the `= ANY($n::text[])` it replaced in #308 was valid and matched nothing. It is unreachable through parseItemFilters, which refuses a list that names nothing — but the obvious guard is wrong in the opposite direction, because dropping the clause entirely would make an empty status filter match every status rather than none, so the empty case is spelled out as false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:26:22 -05:00

130 lines
5.1 KiB
TypeScript

import { readFileSync } from 'fs';
import path from 'path';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
afterAll(async () => {
await pool.end();
await closeDb();
});
const SCHEMA = readFileSync(
path.join(__dirname, '..', '..', 'src', 'db-kysely', 'schema.ts'),
'utf8'
);
/**
* Every column the mirror declares, keyed by the database's own table name.
*
* The `DB` interface maps a table name to the interface that declares that
* table's columns, so the two have to be read together. A flat set of every
* column name appearing anywhere in the file is not the same assertion and is
* far weaker than it looks: seventeen column names are declared on more than
* one table and `created_at` is on fourteen of the eighteen, so a migration
* adding one of those to a table that lacks it would pass without the mirror
* knowing anything about it.
*
* Parsed as text rather than imported, because these are TypeScript types and
* are erased at run time — there is nothing to import and inspect.
*/
function mirroredColumns(): Map<string, Set<string>> {
const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? '';
const byTable = new Map<string, Set<string>>();
// Literal spaces rather than `\s*`, and `\w` rather than `[A-Za-z0-9_]`.
// kysely-codegen indents two spaces and puts exactly one after the colon, so
// the looser form bought nothing and backtracked for it — SonarQube flagged
// both (#307).
for (const [, table, iface] of block.matchAll(/^ {2}([a-z0-9_]+): (\w+);/gm)) {
const declaration =
new RegExp(`export interface ${iface!} \\{([^}]*)\\}`).exec(SCHEMA)?.[1] ?? '';
byTable.set(
table!,
new Set([...declaration.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!))
);
}
return byTable;
}
/** Every table name the generated mirror declares. */
function mirroredTables(): string[] {
return [...mirroredColumns().keys()].sort();
}
async function liveTables(): Promise<string[]> {
const { rows } = await pool.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
AND table_name <> 'pgmigrations'
ORDER BY table_name`
);
return rows.map((r) => r.table_name);
}
/**
* The guard for #217.
*
* `src/db-kysely/schema.ts` is generated by `npm run db:types` and is a
* read-only mirror of the real schema, which `backend/migrations` owns. Nothing
* makes anyone regenerate after writing a migration, and that is not
* hypothetical: the mirror sat missing `item_drafts` and `upload_links` from
* the moment #222 landed until #217, because it had been copied into src/ by
* hand and nobody had reason to look at it.
*
* A stale mirror is worse than no mirror. Row types are inferred from it, so a
* converted query would type-check against a schema the database does not have
* and fail at run time with a column that does not exist — the exact class of
* drift the adoption was meant to close.
*
* The generator changed in #305 and this test did not, because the drift it
* guards is a property of generating a mirror at all rather than of any
* library.
*/
describe('the generated schema mirror', () => {
it('declares every table the migrations create', async () => {
const live = await liveTables();
const mirrored = mirroredTables();
const missing = live.filter((name) => !mirrored.includes(name));
expect(missing).toEqual([]);
});
it('declares no table the database does not have', async () => {
const live = await liveTables();
const mirrored = mirroredTables();
const extra = mirrored.filter((name) => !live.includes(name));
expect(extra).toEqual([]);
});
// pgmigrations is node-pg-migrate's bookkeeping, excluded by
// --exclude-pattern in the db:types script. A regeneration without that flag
// would quietly put it back.
it('excludes node-pg-migrate bookkeeping', () => {
expect(mirroredTables()).not.toContain('pgmigrations');
});
// Columns, not just tables: a migration that adds a column to a table the
// mirror already knows about is the likelier drift, and the one a table-level
// check would wave through.
//
// One exact match per table, where the Drizzle version needed two matches and
// no table at all, and called itself "deliberately loose" for it.
// kysely-codegen emits the database's own name as a bare interface key and
// the DB interface says which interface belongs to which table, so the pair
// is the whole of the naming rule and there is nothing to re-implement.
it('declares every column of every table it mirrors', async () => {
const { rows } = await pool.query<{ table_name: string; column_name: string }>(
`SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name <> 'pgmigrations'
ORDER BY table_name, column_name`
);
const columns = mirroredColumns();
const missing = rows
.filter((row) => !columns.get(row.table_name)?.has(row.column_name))
.map((row) => `${row.table_name}.${row.column_name}`);
expect(missing).toEqual([]);
});
});