Files
HealthLog/src/__tests__/measurement-backup-completeness.test.ts
T
Marc-André Bombeck 99b8dfd566 test(guards): bind the account payload and the backup select to their pairs
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
2026-07-25 16:24:12 +02:00

165 lines
6.2 KiB
TypeScript

/**
* v1.32.36 — pair guard binding `prisma/schema.prisma`'s `Measurement` model to
* the disaster-recovery backup select.
*
* The failure it exists to catch: a column is added to the model in one release
* and the backup select is "the follow-up". Nothing fails — the backup keeps
* building, the restore keeps parsing (the payload schema is `.passthrough()`),
* and the column simply comes back NULL on every restored row. That is how the
* aggregation provenance was silently dropped from every disaster-recovery
* backup between the release that added it and the one that noticed.
*
* Both ends of this comparison are literals — a Prisma model block and a named
* select object — so the matcher is exact. No grep heuristics, no fuzzy name
* matching, nothing that can quietly over-accept. That soundness is the reason
* this guard is worth having as a gate while the broader "does any column have
* a reader" question is only worth a release-battery script.
*
* Scoped to `Measurement` on purpose. Extend it model by model as each becomes
* backup-relevant; converting every model at once buys exclusion-list churn
* rather than safety.
*
* Mutation check: comment out `sleepStage: true` in `MEASUREMENT_BACKUP_SELECT`
* and this goes red naming that column; add a scalar to the model without
* adding it to the select and it goes red naming the new one.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
const ROOT = process.cwd();
const SCHEMA = join(ROOT, "prisma", "schema.prisma");
const BACKUP_BUILDER = join(
ROOT,
"src",
"lib",
"export",
"full-backup-payload.ts",
);
/**
* Columns the backup is correct to omit, each with the reason.
* - `userId`: the backup is user-scoped. The owner is implicit in the query
* and re-derived from the restoring account; carrying it would let a
* payload claim a different owner.
*/
const EXCLUDED_COLUMNS: Record<string, string> = {
userId: "user-scoped backup; owner is re-derived from the restoring account",
};
function modelBlock(schema: string, model: string): string {
const start = schema.indexOf(`model ${model} {`);
expect(start, `model ${model} not found`).toBeGreaterThan(-1);
const end = schema.indexOf("\n}", start);
expect(end).toBeGreaterThan(start);
return schema.slice(start, end);
}
/** Names of every `model` / `type` block, so relation fields can be told apart. */
function declaredModels(schema: string): Set<string> {
const out = new Set<string>();
for (const m of schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)) {
out.add(m[1]);
}
return out;
}
/**
* Scalar (column-backed) fields of a model block: everything that is not a
* relation to another model. Enums and native scalars both map to columns and
* therefore both belong in a backup.
*/
function scalarFields(block: string, models: Set<string>): string[] {
const fields: string[] = [];
for (const rawLine of block.split("\n").slice(1)) {
const line = rawLine.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line);
if (!m) continue;
const [, name, type, isList] = m;
if (isList) continue;
if (models.has(type)) continue;
if (line.includes("@relation")) continue;
fields.push(name);
}
return fields;
}
/** Keys of the named `MEASUREMENT_BACKUP_SELECT` literal. */
function selectFields(source: string): string[] {
const start = source.indexOf("const MEASUREMENT_BACKUP_SELECT = {");
expect(start).toBeGreaterThan(-1);
const end = source.indexOf("\n}", start);
expect(end).toBeGreaterThan(start);
const body = source.slice(start, end);
return [...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*): true,$/gm)].map(
(m) => m[1],
);
}
/**
* Fields either serialization arm actually emits. The two arms share one
* restorable wire format, so this is a union and therefore a weaker claim than
* the select comparison: it catches a column that is selected and then written
* to neither payload, not a column carried by only one arm.
*/
function serialisedFields(source: string): Set<string> {
return new Set(
[...source.matchAll(/([a-zA-Z_$][\w$]*): measurement\./g)].map((m) => m[1]),
);
}
describe("measurement backup completeness", () => {
const schema = readFileSync(SCHEMA, "utf8");
const source = readFileSync(BACKUP_BUILDER, "utf8");
const models = declaredModels(schema);
const columns = scalarFields(modelBlock(schema, "Measurement"), models);
const selected = selectFields(source);
it("parses both ends into a plausible shape", () => {
expect(models.size).toBeGreaterThan(50);
expect(columns.length).toBeGreaterThan(15);
expect(columns).toContain("aggregationProvenance");
expect(columns).not.toContain("user");
expect(columns).not.toContain("ecgRecordings");
expect(selected.length).toBeGreaterThan(15);
});
it("the backup select covers every Measurement column", () => {
const missing = columns.filter(
(c) => !selected.includes(c) && !(c in EXCLUDED_COLUMNS),
);
if (missing.length > 0) {
const report = missing.map((c) => ` ❌ ${c}`).join("\n");
throw new Error(
`MEASUREMENT_BACKUP_SELECT omits ${missing.length} column(s) of the Measurement model:\n${report}\n\n` +
"A disaster-recovery restore brings these back NULL. Add each to the " +
"select and to both serialization arms, extend the backup validation " +
"schema and the restore create — or add it to EXCLUDED_COLUMNS with " +
"the reason the backup is correct to drop it.",
);
}
});
it("every selected column reaches a serialized payload", () => {
const emitted = serialisedFields(source);
const dropped = selected.filter((c) => !emitted.has(c));
expect(
dropped,
`selected but serialized by neither arm: ${dropped}`,
).toHaveLength(0);
});
it("the exclusion list carries no stale entry", () => {
for (const [column, reason] of Object.entries(EXCLUDED_COLUMNS)) {
expect(
columns,
`${column} is excluded but is no longer a Measurement column`,
).toContain(column);
expect(reason.length).toBeGreaterThan(20);
}
});
});