mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #1074 from MBombeck/fix/v1397-meta
Every guard must be able to fail; every backed-up column must round-trip
This commit is contained in:
23 files changed
+1418
-39
No files matched your search
@@ -35293,6 +35293,11 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
absorbedIntoId:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -36145,6 +36150,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
externalSourceVersion:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
metadata:
|
||||
$ref: "#/components/schemas/__schema0"
|
||||
createdAt:
|
||||
@@ -36398,6 +36407,13 @@ components:
|
||||
source:
|
||||
type: string
|
||||
minLength: 1
|
||||
externalSourceVersion:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- day
|
||||
- nutrient
|
||||
|
||||
@@ -98,12 +98,14 @@ describe("coach_usage — the two counters move together", () => {
|
||||
|
||||
it("names operator_tokens in every clause that names total_tokens", () => {
|
||||
const offences: string[] = [];
|
||||
let examined = 0;
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (!source.includes("coach_usage")) continue;
|
||||
for (const literal of sqlLiterals(source)) {
|
||||
for (const clause of columnClauses(literal)) {
|
||||
if (!clause.text.includes("total_tokens")) continue;
|
||||
examined += 1;
|
||||
if (clause.text.includes("operator_tokens")) continue;
|
||||
offences.push(
|
||||
`${relative(REPO_ROOT, file)} — ${clause.label}: ${clause.text
|
||||
@@ -118,6 +120,8 @@ describe("coach_usage — the two counters move together", () => {
|
||||
offences,
|
||||
`Every raw coach_usage clause that touches total_tokens must touch operator_tokens too:\n${offences.join("\n")}`,
|
||||
).toEqual([]);
|
||||
// No clauses examined would agree with the rule above on nothing.
|
||||
expect(examined).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("keeps the raw-SQL writer set to the files that reason about the ledger", () => {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Every structural guard asserts that it found something.
|
||||
*
|
||||
* A guard that sweeps the tree and compares the result against an allowlist,
|
||||
* or against "none of these may exist", passes on an empty sweep. That is not
|
||||
* hypothetical here: the Bearer-scope guard stayed green for weeks because its
|
||||
* matcher demanded `apiToken.findUnique(` on one line and the one file that
|
||||
* mattered wrote it across two. The sweep matched nothing, nothing disagreed
|
||||
* with the allowlist, and the guard reported a clean tree.
|
||||
*
|
||||
* So this reads every guard and fails unless it states a floor — a count it
|
||||
* must reach — or is named below with the reason it does not need one.
|
||||
*
|
||||
* ## Scope
|
||||
*
|
||||
* Every test file under `src/` named `*-guard.test.ts(x)` or
|
||||
* `*-guards.test.ts(x)`, plus every test file that walks a directory
|
||||
* (`readdirSync`, `globSync`, `walkSourceFiles`), whatever it is called: the
|
||||
* inventories and coverage checks are the same machine under another name.
|
||||
*
|
||||
* ## What counts as a floor
|
||||
*
|
||||
* The shapes `analyseGuardSource` recognises: `requireFloor`,
|
||||
* `scanSourceMatches`, `toBeGreaterThan(n ≥ 0)`, `toBeGreaterThanOrEqual(n ≥
|
||||
* 1)`, `.not.toHaveLength(0)`, `toHaveLength(n ≥ 1)`, an exact
|
||||
* `expect(x.length).toBe(n ≥ 1)`, and an exact `toEqual([...])` against a
|
||||
* non-empty literal set. A floor on the walk alone (`walkSourceFiles`'s own
|
||||
* `floor`) does not count: it proves the tree was read, not that the matcher
|
||||
* found anything in it, and the Bearer guard had one.
|
||||
*
|
||||
* ## Its limit
|
||||
*
|
||||
* It answers per file. A guard with three matchers and a floor under one of
|
||||
* them passes. Reviewers still own the question of which set the floor sits
|
||||
* under.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyseGuardSource } from "./helpers/guard-floor";
|
||||
import {
|
||||
requireFloor,
|
||||
scanSourceMatches,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* In scope, without a floor, and why that is right. Paths relative to `src/`.
|
||||
* An entry that gains a floor, or whose file disappears, fails the run, so the
|
||||
* list cannot outlive its reasons.
|
||||
*/
|
||||
const NO_FLOOR_NEEDED: Readonly<Record<string, string>> = {
|
||||
// Fixed file lists. Each file is read by name, so a missing one throws,
|
||||
// and each must positively match the shape it is supposed to keep.
|
||||
"__tests__/backup-readers-stream-guard.test.ts":
|
||||
"named readers, each must positively reference the streaming reader",
|
||||
"__tests__/proxy-relative-redirect-guard.test.ts":
|
||||
"named routes, each must positively call relativeRedirect(",
|
||||
"__tests__/rotation-walk-keyset-guard.test.ts":
|
||||
"named walks, each must positively match the keyset cursor",
|
||||
"components/charts/__tests__/touch-action-guard.test.ts":
|
||||
"named chart wrappers, each must positively declare touch-pan-y",
|
||||
"__tests__/moodlog-removal-guard.test.ts":
|
||||
"absence checks over named paths, columns and keys; no matcher to drift",
|
||||
|
||||
// Unit tests of runtime code whose name ends in "guard". They read no
|
||||
// source and sweep nothing.
|
||||
"app/__tests__/error-chunk-reload-guard.test.ts":
|
||||
"unit test of the chunk-reload guard function",
|
||||
"app/api/insights/chat/__tests__/route-fence-guard.test.ts":
|
||||
"route behaviour test of the chat fence",
|
||||
"app/api/insights/chat/__tests__/route-guard.test.ts":
|
||||
"route behaviour test of the outbound screen",
|
||||
"lib/ai/__tests__/consent-guard.test.ts": "unit test of the consent gate",
|
||||
"lib/ai/coach/__tests__/outbound-guard.test.ts":
|
||||
"unit test of the outbound screen",
|
||||
"lib/analytics/score/__tests__/config-delta-guard.test.ts":
|
||||
"unit test of the score delta boundary",
|
||||
"lib/medications/__tests__/route-guards.test.ts":
|
||||
"unit test of the ownership helper",
|
||||
|
||||
// List a temporary directory the test itself filled.
|
||||
"lib/import/__tests__/apple-health-staging.test.ts":
|
||||
"lists its own temp staging directory",
|
||||
"lib/multipart/__tests__/stream-to-disk.test.ts":
|
||||
"lists its own temp directory",
|
||||
};
|
||||
|
||||
const NAMED_GUARD = /-guards?\.test\.tsx?$/;
|
||||
|
||||
interface Scoped {
|
||||
rel: string;
|
||||
named: boolean;
|
||||
floors: string[];
|
||||
}
|
||||
|
||||
function guardsInScope(): Scoped[] {
|
||||
const scoped: Scoped[] = [];
|
||||
const tests = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((rel) => !rel.startsWith("generated/"))
|
||||
.filter((rel) => /\.test\.tsx?$/.test(rel));
|
||||
for (const rel of requireFloor("test files under src/", tests, 1500)) {
|
||||
const named = NAMED_GUARD.test(rel);
|
||||
const report = analyseGuardSource(
|
||||
rel,
|
||||
readFileSync(join(SRC, rel), "utf8"),
|
||||
);
|
||||
if (!named && !report.walksDirectories) continue;
|
||||
scoped.push({ rel, named, floors: report.floors });
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
/** Floors that say something about matches, not just about the walk. */
|
||||
function matchFloors(floors: readonly string[]): string[] {
|
||||
return floors.filter((f) => f !== "walkSourceFiles");
|
||||
}
|
||||
|
||||
describe("every structural guard states a floor", () => {
|
||||
const scoped = guardsInScope();
|
||||
|
||||
it("finds the guards (this check has a floor too)", () => {
|
||||
expect(scoped.filter((g) => g.named).length).toBeGreaterThanOrEqual(100);
|
||||
expect(scoped.filter((g) => !g.named).length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("each one asserts a floor on what it found, or says why not", () => {
|
||||
const missing = scoped
|
||||
.filter((g) => matchFloors(g.floors).length === 0)
|
||||
.filter((g) => !(g.rel in NO_FLOOR_NEEDED))
|
||||
.map((g) => g.rel);
|
||||
expect(
|
||||
missing,
|
||||
"These guards can pass on a sweep that found nothing. Add a floor " +
|
||||
"(requireFloor, scanSourceMatches, or expect(count).toBeGreaterThan…) " +
|
||||
"pinned at the real count, or name the file in NO_FLOOR_NEEDED with " +
|
||||
"the reason it is not a scanner.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("names no file in the exemption list that no longer needs it", () => {
|
||||
const byRel = new Map(scoped.map((g) => [g.rel, g]));
|
||||
const stale = Object.keys(NO_FLOOR_NEEDED).filter((rel) => {
|
||||
const g = byRel.get(rel);
|
||||
return !g || matchFloors(g.floors).length > 0;
|
||||
});
|
||||
expect(
|
||||
stale,
|
||||
"Exempt files that are gone, out of scope, or now carry a floor",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the floor reader counts what it should and nothing else", () => {
|
||||
const floorsOf = (source: string) =>
|
||||
matchFloors(analyseGuardSource("probe.test.ts", source).floors);
|
||||
|
||||
it.each([
|
||||
["requireFloor", `requireFloor("x", found, 3);`],
|
||||
["toBeGreaterThan(0)", `expect(found.length).toBeGreaterThan(0);`],
|
||||
["toBeGreaterThanOrEqual(n)", `expect(n).toBeGreaterThanOrEqual(12);`],
|
||||
["a named floor", `expect(n).toBeGreaterThanOrEqual(FLOOR);`],
|
||||
["not.toHaveLength(0)", `expect(found).not.toHaveLength(0);`],
|
||||
["exact length", `expect(found.length).toBe(7);`],
|
||||
["exact non-empty set", `expect(found).toEqual(["lib/a.ts"]);`],
|
||||
[
|
||||
"scanSourceMatches",
|
||||
`scanSourceMatches(root, /x/g, { fileFloor: 10, matchFloor: 2 });`,
|
||||
],
|
||||
])("counts %s", (_label, source) => {
|
||||
expect(floorsOf(source)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a floor in a comment", `// expect(n).toBeGreaterThan(0);`],
|
||||
["a floor in a string", `const s = "expect(n).toBeGreaterThan(0)";`],
|
||||
["a negated floor", `expect(n).not.toBeGreaterThan(0);`],
|
||||
["GreaterThanOrEqual(0)", `expect(n).toBeGreaterThanOrEqual(0);`],
|
||||
["a zero requireFloor", `requireFloor("x", found, 0);`],
|
||||
["a zero matchFloor", `scanSourceMatches(r, /x/g, { matchFloor: 0 });`],
|
||||
["an empty-set equality", `expect(offenders).toEqual([]);`],
|
||||
["a spread-only set", `expect(found).toEqual([...EXPECTED]);`],
|
||||
["toHaveLength(0)", `expect(found).toHaveLength(0);`],
|
||||
["a walk floor alone", `walkSourceFiles(SRC, { floor: 3000 });`],
|
||||
[
|
||||
"a floor inside it.skip",
|
||||
`it.skip("x", () => { expect(n).toBeGreaterThan(0); });`,
|
||||
],
|
||||
])("does not count %s", (_label, source) => {
|
||||
expect(floorsOf(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("still notices the walk, so a walker stays in scope", () => {
|
||||
const report = analyseGuardSource(
|
||||
"probe.test.ts",
|
||||
`const files = readdirSync(dir);`,
|
||||
);
|
||||
expect(report.walksDirectories).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the floor helpers refuse an empty answer", () => {
|
||||
it("requireFloor throws below the floor and refuses a zero floor", () => {
|
||||
expect(() => requireFloor("probe", [], 1)).toThrow(/below the stated/);
|
||||
expect(() => requireFloor("probe", [1, 2], 0)).toThrow(/positive/);
|
||||
expect(requireFloor("probe", [1, 2], 2)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("scanSourceMatches floors files and matches separately", () => {
|
||||
const helpers = join(SRC, "__tests__", "helpers");
|
||||
const hits = scanSourceMatches(helpers, /export function \w+/g, {
|
||||
fileFloor: 5,
|
||||
matchFloor: 5,
|
||||
});
|
||||
expect(hits.some((h) => h.file === "source-files.ts")).toBe(true);
|
||||
expect(() =>
|
||||
scanSourceMatches(helpers, /no such text anywhere\u0000/g, {
|
||||
fileFloor: 5,
|
||||
matchFloor: 1,
|
||||
}),
|
||||
).toThrow(/below the stated floor/);
|
||||
expect(() =>
|
||||
scanSourceMatches(helpers, /export/, { fileFloor: 5, matchFloor: 1 }),
|
||||
).toThrow(/g flag/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Reads a structural guard's source and answers one question: does it assert
|
||||
* that it found something?
|
||||
*
|
||||
* A guard that sweeps the tree and compares what it found against an
|
||||
* allowlist, or against "none of these may exist", passes on an empty sweep.
|
||||
* So every scanner has to say, somewhere, that its sweep was not empty. The
|
||||
* shapes that count as saying it are listed on `FLOOR_SHAPES` below; anything
|
||||
* else is not a floor.
|
||||
*
|
||||
* This parses the file with the TypeScript compiler rather than grepping it,
|
||||
* so a floor that only appears in a comment, inside a string, under `.not`, or
|
||||
* inside an `it.skip` does not count.
|
||||
*
|
||||
* Its honest limit: it answers per file, not per matcher. A guard with three
|
||||
* matchers and a floor on one of them passes here. The floor proves the file
|
||||
* read something; it does not prove every rule in the file did.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
|
||||
export type FloorShape =
|
||||
| "walkSourceFiles"
|
||||
| "scanSourceMatches"
|
||||
| "requireFloor"
|
||||
| "toBeGreaterThan"
|
||||
| "toBeGreaterThanOrEqual"
|
||||
| "not.toHaveLength(0)"
|
||||
| "toHaveLength(n>0)"
|
||||
| "length.toBe(n>0)"
|
||||
| "toEqual([non-empty])";
|
||||
|
||||
/** Calls that make a file a directory scanner, and so put it in scope. */
|
||||
const WALKERS = new Set(["readdirSync", "globSync", "walkSourceFiles"]);
|
||||
|
||||
function numericValue(node: ts.Expression | undefined): number | null {
|
||||
if (!node) return null;
|
||||
if (ts.isNumericLiteral(node)) return Number(node.text);
|
||||
if (
|
||||
ts.isPrefixUnaryExpression(node) &&
|
||||
node.operator === ts.SyntaxKind.MinusToken &&
|
||||
ts.isNumericLiteral(node.operand)
|
||||
) {
|
||||
return -Number(node.operand.text);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function calleeName(call: ts.CallExpression): string | null {
|
||||
const callee = call.expression;
|
||||
if (ts.isIdentifier(callee)) return callee.text;
|
||||
if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `x.not.toBeGreaterThan(…)` — the matcher sits under a `.not`. */
|
||||
function isNegated(call: ts.CallExpression): boolean {
|
||||
const callee = call.expression;
|
||||
return (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isPropertyAccessExpression(callee.expression) &&
|
||||
callee.expression.name.text === "not"
|
||||
);
|
||||
}
|
||||
|
||||
/** `expect(<anything>.length, …).toBe(…)` — the subject is a length. */
|
||||
function expectSubjectIsLength(call: ts.CallExpression): boolean {
|
||||
const callee = call.expression;
|
||||
if (!ts.isPropertyAccessExpression(callee)) return false;
|
||||
const expectCall = callee.expression;
|
||||
if (
|
||||
!ts.isCallExpression(expectCall) ||
|
||||
!ts.isIdentifier(expectCall.expression) ||
|
||||
expectCall.expression.text !== "expect"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const subject = expectCall.arguments[0];
|
||||
return (
|
||||
subject !== undefined &&
|
||||
ts.isPropertyAccessExpression(subject) &&
|
||||
subject.name.text === "length"
|
||||
);
|
||||
}
|
||||
|
||||
/** Inside `it.skip(…)`, `describe.todo(…)`, `test.skip(…)` and friends. */
|
||||
function isInSkippedBlock(node: ts.Node): boolean {
|
||||
for (let cur = node.parent; cur; cur = cur.parent) {
|
||||
if (
|
||||
ts.isCallExpression(cur) &&
|
||||
ts.isPropertyAccessExpression(cur.expression) &&
|
||||
["skip", "todo"].includes(cur.expression.name.text) &&
|
||||
ts.isIdentifier(cur.expression.expression) &&
|
||||
["it", "test", "describe"].includes(cur.expression.expression.text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The value of property `key` on an object-literal argument, if any. */
|
||||
function objectProperty(
|
||||
arg: ts.Expression | undefined,
|
||||
key: string,
|
||||
): ts.Expression | "shorthand" | undefined {
|
||||
if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined;
|
||||
for (const prop of arg.properties) {
|
||||
if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === key) {
|
||||
return "shorthand";
|
||||
}
|
||||
if (
|
||||
ts.isPropertyAssignment(prop) &&
|
||||
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
|
||||
prop.name.text === key
|
||||
) {
|
||||
return prop.initializer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A floor value counts unless it is a literal below `min`. */
|
||||
function floorValueCounts(
|
||||
value: ts.Expression | "shorthand" | undefined,
|
||||
min: number,
|
||||
): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (value === "shorthand") return true;
|
||||
const n = numericValue(value);
|
||||
return n === null || n >= min;
|
||||
}
|
||||
|
||||
function floorShapeOf(call: ts.CallExpression): FloorShape | null {
|
||||
const name = calleeName(call);
|
||||
const [first, second, third] = call.arguments;
|
||||
switch (name) {
|
||||
case "walkSourceFiles":
|
||||
return floorValueCounts(objectProperty(second, "floor"), 1)
|
||||
? "walkSourceFiles"
|
||||
: null;
|
||||
case "scanSourceMatches":
|
||||
return floorValueCounts(objectProperty(third, "matchFloor"), 1)
|
||||
? "scanSourceMatches"
|
||||
: null;
|
||||
case "requireFloor":
|
||||
return floorValueCounts(third, 1) ? "requireFloor" : null;
|
||||
case "toBeGreaterThan":
|
||||
if (isNegated(call)) return null;
|
||||
return floorValueCounts(first, 0) ? "toBeGreaterThan" : null;
|
||||
case "toBeGreaterThanOrEqual":
|
||||
if (isNegated(call)) return null;
|
||||
return floorValueCounts(first, 1) ? "toBeGreaterThanOrEqual" : null;
|
||||
case "toHaveLength": {
|
||||
const n = numericValue(first);
|
||||
if (isNegated(call)) return n === 0 ? "not.toHaveLength(0)" : null;
|
||||
return n !== null && n >= 1 ? "toHaveLength(n>0)" : null;
|
||||
}
|
||||
case "toBe":
|
||||
case "toEqual":
|
||||
case "toStrictEqual": {
|
||||
if (isNegated(call)) return null;
|
||||
// `expect(found).toEqual(["lib/one.ts"])` — the exact, non-empty set.
|
||||
if (
|
||||
name !== "toBe" &&
|
||||
first !== undefined &&
|
||||
ts.isArrayLiteralExpression(first) &&
|
||||
first.elements.some((el) => !ts.isSpreadElement(el))
|
||||
) {
|
||||
return "toEqual([non-empty])";
|
||||
}
|
||||
// `expect(found.length).toBe(12)` — an exact non-zero count.
|
||||
const n = numericValue(first);
|
||||
if (n === null || n < 1) return null;
|
||||
return expectSubjectIsLength(call) ? "length.toBe(n>0)" : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuardFloorReport {
|
||||
/** Every floor-asserting call reachable outside a skipped block. */
|
||||
floors: FloorShape[];
|
||||
/** True when the file calls a directory walker. */
|
||||
walksDirectories: boolean;
|
||||
}
|
||||
|
||||
export function analyseGuardSource(
|
||||
fileName: string,
|
||||
source: string,
|
||||
): GuardFloorReport {
|
||||
const sf = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const floors: FloorShape[] = [];
|
||||
let walksDirectories = false;
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node);
|
||||
if (name && WALKERS.has(name)) walksDirectories = true;
|
||||
const shape = floorShapeOf(node);
|
||||
if (shape && !isInSkippedBlock(node)) floors.push(shape);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
|
||||
return { floors, walksDirectories };
|
||||
}
|
||||
@@ -50,8 +50,8 @@
|
||||
* worktrees, each a full second copy of the tree that would double every
|
||||
* match. Every caller roots at `src/`, `e2e/` or a subtree of one.
|
||||
*/
|
||||
import { readdirSync } from "node:fs";
|
||||
import { sep } from "node:path";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
export function walkSourceFiles(
|
||||
root: string,
|
||||
@@ -140,3 +140,92 @@ export function stripComments(source: string): string {
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a sweep found at least `floor` things, and returns them.
|
||||
*
|
||||
* `walkSourceFiles` puts a floor under the walk. This puts one under what a
|
||||
* guard does with it: the narrowed file set, the call sites a matcher picked
|
||||
* out, the routes an inventory extracted. A matcher that drifts out of step
|
||||
* with the code — a renamed helper, a call split across two lines, a regex
|
||||
* that stopped compiling the way it was meant to — returns an empty list, and
|
||||
* an empty list agrees with every allowlist and every "none of these may
|
||||
* exist" rule. That is how the Bearer-scope guard stayed green for weeks while
|
||||
* matching nothing.
|
||||
*
|
||||
* Pin `floor` at the real count when the set is a closed inventory, and below
|
||||
* it with headroom only when the count legitimately moves. A floor of zero is
|
||||
* refused: it is the bug this exists to prevent, spelled as a parameter.
|
||||
*/
|
||||
export function requireFloor<T>(
|
||||
label: string,
|
||||
found: readonly T[],
|
||||
floor: number,
|
||||
): readonly T[] {
|
||||
if (!Number.isInteger(floor) || floor < 1) {
|
||||
throw new Error(
|
||||
`requireFloor(${label}): floor must be a positive integer, got ${floor}.`,
|
||||
);
|
||||
}
|
||||
if (found.length < floor) {
|
||||
throw new Error(
|
||||
`${label}: found ${found.length}, below the stated floor of ${floor}. ` +
|
||||
`A matcher that finds this little has drifted from the code it ` +
|
||||
`reads; fix the matcher before lowering the floor.`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export interface SourceMatch {
|
||||
/** Path relative to the scanned root, posix-separated. */
|
||||
file: string;
|
||||
/** 1-based line of the match start. */
|
||||
line: number;
|
||||
/** The matched text. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `root`, run `pattern` over every kept file, and return each match —
|
||||
* with a floor on the files read AND a floor on the matches found.
|
||||
*
|
||||
* `pattern` must carry the `g` flag; it is the only way to report more than
|
||||
* one hit per file, and a non-global regex silently finding one hit per file
|
||||
* is the kind of quiet narrowing this helper is for.
|
||||
*/
|
||||
export function scanSourceMatches(
|
||||
root: string,
|
||||
pattern: RegExp,
|
||||
options: {
|
||||
fileFloor: number;
|
||||
matchFloor: number;
|
||||
extensions?: readonly string[];
|
||||
include?: (rel: string) => boolean;
|
||||
stripComments?: boolean;
|
||||
},
|
||||
): SourceMatch[] {
|
||||
if (!pattern.global) {
|
||||
throw new Error(`scanSourceMatches: pattern ${pattern} needs the g flag.`);
|
||||
}
|
||||
const files = walkSourceFiles(root, {
|
||||
floor: options.fileFloor,
|
||||
extensions: options.extensions,
|
||||
}).filter(options.include ?? (() => true));
|
||||
requireFloor(`scanSourceMatches(${root}) files`, files, options.fileFloor);
|
||||
|
||||
const matches: SourceMatch[] = [];
|
||||
for (const file of files) {
|
||||
const raw = readFileSync(join(root, file), "utf8");
|
||||
const source = options.stripComments ? stripComments(raw) : raw;
|
||||
for (const m of source.matchAll(pattern)) {
|
||||
const line = source.slice(0, m.index).split("\n").length;
|
||||
matches.push({ file, line, text: m[0] });
|
||||
}
|
||||
}
|
||||
return requireFloor(
|
||||
`scanSourceMatches(${root}, ${pattern})`,
|
||||
matches,
|
||||
options.matchFloor,
|
||||
) as SourceMatch[];
|
||||
}
|
||||
@@ -64,6 +64,7 @@ describe("columns that point into measurements are indexed", () => {
|
||||
const refs = measurementReferences();
|
||||
|
||||
it("finds the references (a matcher that finds none proves nothing)", () => {
|
||||
expect(refs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(refs).toEqual(
|
||||
expect.arrayContaining([
|
||||
"ecg_recordings.measurement_id",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
import {
|
||||
scopeSourceFromMetricKey,
|
||||
scopeSourceMetricLabelKey,
|
||||
@@ -38,8 +39,10 @@ function sourceFilesBelow(directory: string): string[] {
|
||||
}
|
||||
|
||||
function genericMetricCardInventory(): Set<string> {
|
||||
const insightRouteSources = sourceFilesBelow(
|
||||
join(PROJECT_ROOT, "src/app/insights"),
|
||||
const insightRouteSources = requireFloor(
|
||||
"insight route sources",
|
||||
sourceFilesBelow(join(PROJECT_ROOT, "src/app/insights")),
|
||||
60,
|
||||
).map((path) => readFileSync(path, "utf8"));
|
||||
const recoverySource = readFileSync(
|
||||
join(PROJECT_ROOT, "src/components/insights/recovery/recovery-section.tsx"),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
|
||||
/**
|
||||
* Settings + Admin read as one surface. These pin the parts of that claim
|
||||
@@ -179,8 +180,12 @@ describe("the Settings and Admin shells route their heading through PageHeader",
|
||||
describe("the abolished body gutter stays abolished", () => {
|
||||
it("has no `pl-7` left under Settings or Admin", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const dir of ["settings", "admin"]) {
|
||||
for (const file of walk(join(ROOT, "src", "components", dir))) {
|
||||
for (const [dir, floor] of [
|
||||
["settings", 130],
|
||||
["admin", 30],
|
||||
] as const) {
|
||||
const files = walk(join(ROOT, "src", "components", dir));
|
||||
for (const file of requireFloor(`${dir} components`, files, floor)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const [i, line] of source.split("\n").entries()) {
|
||||
// Comments may name the class they retired; class strings may not.
|
||||
|
||||
@@ -23,6 +23,8 @@ import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = join(__dirname, "../../../..");
|
||||
const SETTINGS_DIR = join(ROOT, "src/components/settings");
|
||||
|
||||
@@ -59,7 +61,8 @@ describe("Settings card headers route through SettingsCardHeader", () => {
|
||||
it("has no hand-rolled <h2 text-lg> outside the primitive + allowlist", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of walk(SETTINGS_DIR)) {
|
||||
const files = requireFloor("settings cards", walk(SETTINGS_DIR), 80);
|
||||
for (const file of files) {
|
||||
const base = file.slice(file.lastIndexOf("/") + 1);
|
||||
if (base === PRIMITIVE) continue;
|
||||
if (ALLOWLIST.has(base)) continue;
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import {
|
||||
@@ -113,14 +114,10 @@ describe("structural guard — no second pill dialect", () => {
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const MESSAGES_DIR = join(ROOT, "messages");
|
||||
const EN_PATH = join(MESSAGES_DIR, "en.json");
|
||||
@@ -20,7 +22,9 @@ function discoverLocales(): Array<{ locale: string; path: string }> {
|
||||
}));
|
||||
}
|
||||
|
||||
const ALL_LOCALES = discoverLocales();
|
||||
// Every parity check below iterates this list, so an empty discovery would
|
||||
// run none of them and pass. Seven bundles ship today.
|
||||
const ALL_LOCALES = requireFloor("message bundles", discoverLocales(), 7);
|
||||
const NON_EN_LOCALES = ALL_LOCALES.filter((l) => l.locale !== "en");
|
||||
|
||||
// JSON.parse silently keeps the last value when an object has duplicate keys,
|
||||
|
||||
@@ -16,6 +16,8 @@ import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = resolve(__dirname, "../../../..");
|
||||
|
||||
/**
|
||||
@@ -23,10 +25,14 @@ const ROOT = resolve(__dirname, "../../../..");
|
||||
* so every module there is guarded — read from the directory, so a step added
|
||||
* later is covered without editing this list.
|
||||
*/
|
||||
const TURN_PIPELINE_FILES = readdirSync(resolve(ROOT, "lib/ai/coach/turn"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.sort()
|
||||
.map((name) => `lib/ai/coach/turn/${name}`);
|
||||
const TURN_PIPELINE_FILES = requireFloor(
|
||||
"coach turn pipeline modules",
|
||||
readdirSync(resolve(ROOT, "lib/ai/coach/turn"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.sort()
|
||||
.map((name) => `lib/ai/coach/turn/${name}`),
|
||||
12,
|
||||
);
|
||||
|
||||
const GUARDED_FILES = [
|
||||
"app/api/insights/chat/route.ts",
|
||||
|
||||
@@ -64,6 +64,12 @@ export interface CycleBackupSection {
|
||||
isPredicted?: boolean;
|
||||
syncVersion?: number;
|
||||
deletedAt?: string | null;
|
||||
/**
|
||||
* Disaster recovery only: the start that folded this one in. A bare id with
|
||||
* no foreign key, meaningful only because the same file carries the ids of
|
||||
* the starts it names.
|
||||
*/
|
||||
absorbedIntoId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
@@ -214,6 +220,7 @@ export async function buildCycleBackupSection(
|
||||
isPredicted: c.isPredicted,
|
||||
syncVersion: c.syncVersion,
|
||||
deletedAt: c.deletedAt?.toISOString() ?? null,
|
||||
absorbedIntoId: c.absorbedIntoId,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
}
|
||||
@@ -432,6 +439,12 @@ export async function restoreCycleData(
|
||||
// re-attach to their owning span.
|
||||
const cycleIdByStart = new Map<string, string>();
|
||||
const restoredCycleIds = new Set<string>();
|
||||
// A folded start names the start that absorbed it. Kept only when that start
|
||||
// is in the same file under the same id; a pointer at nothing would stop the
|
||||
// folded row from ever coming back, which is what the column is for.
|
||||
const carriedCycleIds = new Set(
|
||||
payload.cycles.flatMap((c) => (c.id ? [c.id] : [])),
|
||||
);
|
||||
for (const c of payload.cycles) {
|
||||
const created = await tx.menstrualCycle.create({
|
||||
data: {
|
||||
@@ -447,6 +460,10 @@ export async function restoreCycleData(
|
||||
isPredicted: c.isPredicted ?? false,
|
||||
syncVersion: c.syncVersion ?? 0,
|
||||
deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
|
||||
absorbedIntoId:
|
||||
c.absorbedIntoId && carriedCycleIds.has(c.absorbedIntoId)
|
||||
? c.absorbedIntoId
|
||||
: null,
|
||||
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
|
||||
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
|
||||
},
|
||||
|
||||
@@ -361,6 +361,8 @@ function makePrisma() {
|
||||
amount: 1500,
|
||||
unit: "ml",
|
||||
source: "MANUAL",
|
||||
externalSourceVersion: null,
|
||||
createdAt: new Date("2026-07-19T20:15:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -705,6 +707,8 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
|
||||
amount: 1500,
|
||||
unit: "ml",
|
||||
source: "MANUAL",
|
||||
externalSourceVersion: null,
|
||||
createdAt: "2026-07-19T20:15:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -887,6 +887,9 @@ export async function buildFullBackupPayload(
|
||||
// `source` is part of the composite PK, so it has to ride along or a
|
||||
// restore cannot tell a manual water entry from a synced day total. The
|
||||
// table carries no tombstone, so both purposes read the same shape.
|
||||
// `externalSourceVersion` is the source's own version marker, stored so
|
||||
// the sync can replay it back unchanged; `createdAt` is when the day
|
||||
// first arrived. Neither can be rebuilt after a restore.
|
||||
prisma.nutrientIntakeDay.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
@@ -895,6 +898,8 @@ export async function buildFullBackupPayload(
|
||||
amount: true,
|
||||
unit: true,
|
||||
source: true,
|
||||
externalSourceVersion: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [{ day: "desc" }, { nutrient: "asc" }],
|
||||
}),
|
||||
@@ -1293,6 +1298,8 @@ export async function buildFullBackupPayload(
|
||||
amount: n.amount,
|
||||
unit: n.unit,
|
||||
source: n.source,
|
||||
externalSourceVersion: n.externalSourceVersion,
|
||||
createdAt: n.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface WorkoutBackupEntry {
|
||||
externalId: string | null;
|
||||
/** Canonical DR only; portable workout summaries keep their existing shape. */
|
||||
metadata?: Prisma.JsonValue;
|
||||
/** Canonical DR only, beside `metadata`: the source's own version marker. */
|
||||
externalSourceVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentBackupEntry {
|
||||
@@ -517,6 +519,7 @@ export async function buildRecordsBackupSection(
|
||||
createdAt: w.createdAt.toISOString(),
|
||||
updatedAt: w.updatedAt.toISOString(),
|
||||
...(w.metadata == null ? {} : { metadata: w.metadata }),
|
||||
externalSourceVersion: w.externalSourceVersion,
|
||||
}
|
||||
: {}),
|
||||
sportType: w.sportType,
|
||||
|
||||
@@ -2139,6 +2139,7 @@ export async function restoreBackup(
|
||||
pauseDurationSec: workout.pauseDurationSec ?? null,
|
||||
source: workout.source as never,
|
||||
externalId: workout.externalId ?? null,
|
||||
externalSourceVersion: workout.externalSourceVersion ?? null,
|
||||
...(workout.metadata == null
|
||||
? {}
|
||||
: { metadata: toJson(workout.metadata) }),
|
||||
@@ -2161,6 +2162,8 @@ export async function restoreBackup(
|
||||
amount: n.amount,
|
||||
unit: n.unit,
|
||||
source: n.source,
|
||||
externalSourceVersion: n.externalSourceVersion ?? null,
|
||||
...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { openingShape } from "@/lib/ai/prompts/shared-contracts";
|
||||
import {
|
||||
@@ -110,14 +111,18 @@ describe("tone harness — prompt surfaces", () => {
|
||||
"native-prompts.ts",
|
||||
"status-batch.ts",
|
||||
]);
|
||||
const modules = readdirSync(dir).filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".d.ts") &&
|
||||
!NON_ASSESSMENT.has(f) &&
|
||||
/export function get\w*SystemPrompt/.test(
|
||||
readFileSync(join(dir, f), "utf8"),
|
||||
),
|
||||
const modules = requireFloor(
|
||||
"assessment prompt modules",
|
||||
readdirSync(dir).filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".d.ts") &&
|
||||
!NON_ASSESSMENT.has(f) &&
|
||||
/export function get\w*SystemPrompt/.test(
|
||||
readFileSync(join(dir, f), "utf8"),
|
||||
),
|
||||
),
|
||||
11,
|
||||
);
|
||||
const registered = new Set(
|
||||
ASSESSMENT_SURFACES.map((s) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
import { JOB_FACT_ALLOWLIST } from "@/lib/jobs/job-outcome";
|
||||
|
||||
const JOBS_ROOT = join(process.cwd(), "src/lib/jobs");
|
||||
@@ -109,14 +110,18 @@ function successFactKeys(file: string): string[] {
|
||||
|
||||
describe("durable production job-outcome fact inventory", () => {
|
||||
it("allows every explicit production success-fact key", () => {
|
||||
const unknown = productionJobFiles()
|
||||
.flatMap((file) =>
|
||||
successFactKeys(file).map((key) => ({
|
||||
file: file.replace(`${process.cwd()}/`, ""),
|
||||
key,
|
||||
})),
|
||||
)
|
||||
.filter(({ key }) => !JOB_FACT_ALLOWLIST.has(key));
|
||||
const facts = requireFloor(
|
||||
"jobDone fact keys",
|
||||
requireFloor("production job files", productionJobFiles(), 120).flatMap(
|
||||
(file) =>
|
||||
successFactKeys(file).map((key) => ({
|
||||
file: file.replace(`${process.cwd()}/`, ""),
|
||||
key,
|
||||
})),
|
||||
),
|
||||
350,
|
||||
);
|
||||
const unknown = facts.filter(({ key }) => !JOB_FACT_ALLOWLIST.has(key));
|
||||
|
||||
expect(unknown).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,13 @@ function sortedKeys<T extends string>(keys: readonly T[]): T[] {
|
||||
}
|
||||
|
||||
describe("side-effect taxonomy drift guard", () => {
|
||||
it("compares populated keysets, not two empty ones", () => {
|
||||
// Every check below is an equality, and two empty sets are equal. A
|
||||
// mocked or mis-resolved enum would pass all of them on nothing.
|
||||
expect(Object.values(MedicationSideEffectCategory).length).toBe(5);
|
||||
expect(Object.values(MedicationSideEffectEntry).length).toBe(21);
|
||||
});
|
||||
|
||||
describe("category keysets", () => {
|
||||
it("validator categories equal Prisma enum categories", () => {
|
||||
const prisma = Object.values(MedicationSideEffectCategory);
|
||||
|
||||
@@ -559,6 +559,7 @@ const cycleSpanSchema = z
|
||||
tz: z.string().nullable().optional(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
absorbedIntoId: z.string().min(1).nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
@@ -691,6 +692,9 @@ const nutrientDaySchema = z
|
||||
// Closed set in app code rather than a DB enum; kept as a string here so a
|
||||
// future source does not make an old file unparseable.
|
||||
source: z.string().min(1),
|
||||
// Absent from files written before v1.39.7.
|
||||
externalSourceVersion: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -1641,6 +1645,7 @@ const workoutBackupSchema = z
|
||||
pauseDurationSec: z.number().int().nullable().optional(),
|
||||
source: z.enum(MeasurementSource),
|
||||
externalId: z.string().nullable().optional(),
|
||||
externalSourceVersion: z.string().nullable().optional(),
|
||||
metadata: z.json().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Column-level bookkeeping for the backup round trip.
|
||||
*
|
||||
* The round-trip test proves that a row of every two-ended model comes back.
|
||||
* It does not prove the row comes back WHOLE: a restore that writes the row
|
||||
* with a column defaulted returns the right count and loses the value. That is
|
||||
* how the medication category went missing, and how the aggregation
|
||||
* provenance did before it.
|
||||
*
|
||||
* So this reads every column of every two-ended model from the client's own
|
||||
* data model (the generated DMMF projection Prisma ships inside the client),
|
||||
* fills the ones the fixture left empty with a value that cannot be mistaken
|
||||
* for a default, and compares each column before the export with the same
|
||||
* column after the restore. A column added to a model tomorrow is in that list
|
||||
* automatically, so it is compared automatically: it either comes back, or it
|
||||
* is named in the exclusions with the reason it should not.
|
||||
*
|
||||
* Attributes the runtime data model does not carry (optional, list, default,
|
||||
* id, foreign key) are read from `schema.prisma`, and the two field lists are
|
||||
* required to agree, so neither source can quietly drift from the other.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { readDeclaredSchema } from "@/__tests__/helpers/prisma-schema-names";
|
||||
|
||||
const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma");
|
||||
|
||||
export interface AuditColumn {
|
||||
model: string;
|
||||
name: string;
|
||||
kind: "scalar" | "enum";
|
||||
type: string;
|
||||
isList: boolean;
|
||||
isOptional: boolean;
|
||||
isId: boolean;
|
||||
isUpdatedAt: boolean;
|
||||
/** Named in an `@relation(fields: [...])` on this model. */
|
||||
isForeignKey: boolean;
|
||||
/** The `@default(...)` argument as written, or null. */
|
||||
defaultText: string | null;
|
||||
}
|
||||
|
||||
export interface AuditModel {
|
||||
name: string;
|
||||
delegate: string;
|
||||
columns: AuditColumn[];
|
||||
/** Fields of the primary key, `@id` or `@@id([...])`. */
|
||||
primaryKey: string[];
|
||||
/** To-one relations: field name -> target model. */
|
||||
parents: Record<string, string>;
|
||||
}
|
||||
|
||||
interface RuntimeField {
|
||||
name: string;
|
||||
kind: "scalar" | "enum" | "object";
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface RuntimeDataModel {
|
||||
models: Record<string, { fields: RuntimeField[] }>;
|
||||
}
|
||||
|
||||
function runtimeDataModel(prisma: PrismaClient): RuntimeDataModel {
|
||||
const rdm = (prisma as unknown as { _runtimeDataModel?: RuntimeDataModel })
|
||||
._runtimeDataModel;
|
||||
if (!rdm?.models) {
|
||||
throw new Error(
|
||||
"the Prisma client no longer exposes its data model; the column audit " +
|
||||
"cannot list columns and must not pass on an empty list",
|
||||
);
|
||||
}
|
||||
return rdm;
|
||||
}
|
||||
|
||||
interface SchemaFieldAttrs {
|
||||
isList: boolean;
|
||||
isOptional: boolean;
|
||||
isId: boolean;
|
||||
isUpdatedAt: boolean;
|
||||
defaultText: string | null;
|
||||
}
|
||||
|
||||
/** Field attributes and block attributes of one `model` block. */
|
||||
function schemaModelAttrs(source: string, model: string) {
|
||||
const start = source.search(new RegExp(`^model ${model} \\{`, "m"));
|
||||
if (start === -1) throw new Error(`model ${model} not in schema.prisma`);
|
||||
const end = source.indexOf("\n}", start);
|
||||
const lines = source
|
||||
.slice(start, end)
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.filter((line) => !/^\s*\/\//.test(line));
|
||||
|
||||
const fields = new Map<string, SchemaFieldAttrs>();
|
||||
const foreignKeys = new Set<string>();
|
||||
let compoundId: string[] | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const block = /^\s*@@id\(\[([^\]]+)\]/.exec(line);
|
||||
if (block) {
|
||||
compoundId = block[1].split(",").map((s) => s.trim());
|
||||
continue;
|
||||
}
|
||||
const m = /^\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(.*)$/.exec(line);
|
||||
if (!m) continue;
|
||||
const [, name, , list, optional, rest] = m;
|
||||
const rel = /@relation\([^)]*fields:\s*\[([^\]]+)\]/.exec(rest);
|
||||
if (rel) {
|
||||
for (const fk of rel[1].split(",")) foreignKeys.add(fk.trim());
|
||||
}
|
||||
const def = /@default\(((?:[^()]|\([^()]*\))*)\)/.exec(rest);
|
||||
fields.set(name, {
|
||||
isList: Boolean(list),
|
||||
isOptional: Boolean(optional),
|
||||
isId: /(^|\s)@id\b/.test(rest),
|
||||
isUpdatedAt: /@updatedAt\b/.test(rest),
|
||||
defaultText: def ? def[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return { fields, foreignKeys, compoundId };
|
||||
}
|
||||
|
||||
export function readAuditModels(
|
||||
prisma: PrismaClient,
|
||||
models: readonly string[],
|
||||
): AuditModel[] {
|
||||
const rdm = runtimeDataModel(prisma);
|
||||
const source = readFileSync(SCHEMA_PATH, "utf8");
|
||||
|
||||
return models.map((name) => {
|
||||
const runtime = rdm.models[name];
|
||||
if (!runtime) throw new Error(`${name} is not in the client data model`);
|
||||
const attrs = schemaModelAttrs(source, name);
|
||||
|
||||
const columns: AuditColumn[] = [];
|
||||
const parents: Record<string, string> = {};
|
||||
for (const field of runtime.fields) {
|
||||
const a = attrs.fields.get(field.name);
|
||||
if (!a) {
|
||||
throw new Error(
|
||||
`${name}.${field.name} is in the client data model but not in ` +
|
||||
`schema.prisma; regenerate the client`,
|
||||
);
|
||||
}
|
||||
if (field.kind === "object") {
|
||||
if (!a.isList) parents[field.name] = field.type;
|
||||
continue;
|
||||
}
|
||||
columns.push({
|
||||
model: name,
|
||||
name: field.name,
|
||||
kind: field.kind,
|
||||
type: field.type,
|
||||
isList: a.isList,
|
||||
isOptional: a.isOptional,
|
||||
isId: a.isId,
|
||||
isUpdatedAt: a.isUpdatedAt,
|
||||
isForeignKey: attrs.foreignKeys.has(field.name),
|
||||
defaultText: a.defaultText,
|
||||
});
|
||||
}
|
||||
|
||||
const primaryKey =
|
||||
attrs.compoundId ?? columns.filter((c) => c.isId).map((c) => c.name);
|
||||
if (primaryKey.length === 0) throw new Error(`${name} has no primary key`);
|
||||
|
||||
return {
|
||||
name,
|
||||
delegate: name.charAt(0).toLowerCase() + name.slice(1),
|
||||
columns,
|
||||
primaryKey,
|
||||
parents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `where` that selects the rows one account owns: its own `userId` when
|
||||
* the model has one, otherwise through the nearest to-one parent that does.
|
||||
*/
|
||||
export function ownerWhere(
|
||||
models: ReadonlyMap<string, AuditModel>,
|
||||
prisma: PrismaClient,
|
||||
model: string,
|
||||
userId: string,
|
||||
depth = 0,
|
||||
): Record<string, unknown> {
|
||||
const rdm = runtimeDataModel(prisma);
|
||||
const fields = rdm.models[model].fields;
|
||||
if (fields.some((f) => f.name === "userId" && f.kind === "scalar")) {
|
||||
return { userId };
|
||||
}
|
||||
if (depth > 3) throw new Error(`no owner path for ${model}`);
|
||||
const parents =
|
||||
models.get(model)?.parents ??
|
||||
Object.fromEntries(
|
||||
fields.filter((f) => f.kind === "object").map((f) => [f.name, f.type]),
|
||||
);
|
||||
for (const [field, target] of Object.entries(parents)) {
|
||||
try {
|
||||
return {
|
||||
[field]: ownerWhere(models, prisma, target, userId, depth + 1),
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`no owner path for ${model}`);
|
||||
}
|
||||
|
||||
/** A comparable, order-independent rendering of one value. */
|
||||
export function normalise(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (value instanceof Date) return `date:${value.toISOString()}`;
|
||||
if (value instanceof Uint8Array) {
|
||||
return `bytes:${Buffer.from(value).toString("base64")}`;
|
||||
}
|
||||
if (typeof value === "bigint") return `bigint:${value}`;
|
||||
if (typeof value === "object" && "toFixed" in (value as object)) {
|
||||
return `decimal:${String(value)}`;
|
||||
}
|
||||
return JSON.stringify(value, (_k, v) =>
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? Object.fromEntries(
|
||||
Object.entries(v as object).sort(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
: v,
|
||||
);
|
||||
}
|
||||
|
||||
/** Is this value the column's declared default (or its empty state)? */
|
||||
export function isDefaultValue(column: AuditColumn, value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (column.isList && Array.isArray(value) && value.length === 0) return true;
|
||||
const d = column.defaultText;
|
||||
if (d === null) return false;
|
||||
if (/^(now|uuid|cuid|autoincrement|dbgenerated)\(/.test(d)) return false;
|
||||
const literal = d.replace(/^"(.*)"$/, "$1");
|
||||
if (column.type === "Json") {
|
||||
try {
|
||||
return normalise(value) === normalise(JSON.parse(literal));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (column.isList && Array.isArray(value)) {
|
||||
const items = d
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().replace(/^"(.*)"$/, "$1"))
|
||||
.filter((item) => item.length > 0);
|
||||
return normalise(value.map(String)) === normalise(items);
|
||||
}
|
||||
return String(value) === literal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value of the column's type that is not its default, for a column the
|
||||
* fixture left empty. `overrides` supplies one where the type alone does not
|
||||
* produce something the export and the restore accept.
|
||||
*/
|
||||
export function syntheticValue(
|
||||
column: AuditColumn,
|
||||
enumValues: ReadonlyMap<string, string[]>,
|
||||
overrides: Readonly<Record<string, unknown>>,
|
||||
): unknown {
|
||||
const key = `${column.model}.${column.name}`;
|
||||
if (key in overrides) {
|
||||
const v = overrides[key];
|
||||
return typeof v === "function" ? (v as () => unknown)() : v;
|
||||
}
|
||||
const tag = `rt-${column.model}-${column.name}`;
|
||||
const sealed = /Encrypted$/.test(column.name);
|
||||
let one: unknown;
|
||||
if (column.kind === "enum") {
|
||||
const values = enumValues.get(column.type) ?? [];
|
||||
const literal = column.defaultText;
|
||||
one = values.find((v) => v !== literal) ?? values[0];
|
||||
} else {
|
||||
switch (column.type) {
|
||||
case "String":
|
||||
one = sealed ? encrypt(tag) : tag;
|
||||
break;
|
||||
case "Int":
|
||||
one = column.defaultText === "3" ? 4 : 3;
|
||||
break;
|
||||
case "BigInt":
|
||||
one = BigInt(5);
|
||||
break;
|
||||
case "Float":
|
||||
case "Decimal":
|
||||
one = column.defaultText === "2.5" ? 3.5 : 2.5;
|
||||
break;
|
||||
case "Boolean":
|
||||
one = column.defaultText !== "true";
|
||||
break;
|
||||
case "DateTime":
|
||||
one = new Date("2026-06-30T12:34:56.789Z");
|
||||
break;
|
||||
case "Json":
|
||||
one = { roundTrip: tag };
|
||||
break;
|
||||
case "Bytes":
|
||||
// The codec most sealed byte columns share; a column on another one
|
||||
// names its own in the overrides.
|
||||
one = encryptToBytes(tag);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`no synthetic value for ${key} (${column.type})`);
|
||||
}
|
||||
}
|
||||
return column.isList ? [one] : one;
|
||||
}
|
||||
|
||||
export function enumValuesFromSchema(): Map<string, string[]> {
|
||||
return new Map(
|
||||
readDeclaredSchema(SCHEMA_PATH).enums.map((e) => [e.name, e.values]),
|
||||
);
|
||||
}
|
||||
@@ -43,13 +43,15 @@
|
||||
* is a thing a restore could get wrong while returning the right number of
|
||||
* rows, which is the only test worth writing here.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
@@ -69,11 +71,25 @@ import {
|
||||
} from "@/lib/medication-category";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { parseOnboardingSteps } from "@/lib/onboarding/needs";
|
||||
import { POST } from "./restore-job-driver";
|
||||
import {
|
||||
type AuditColumn,
|
||||
type AuditModel,
|
||||
enumValuesFromSchema,
|
||||
isDefaultValue,
|
||||
normalise,
|
||||
ownerWhere,
|
||||
readAuditModels,
|
||||
syntheticValue,
|
||||
} from "./backup-column-audit";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -3212,3 +3228,416 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Columns the restore is right not to bring back verbatim, by `Model.column`.
|
||||
* Every other column of every two-ended model must come back with the value
|
||||
* it left with. An entry here whose column no longer exists fails the run.
|
||||
*/
|
||||
const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
||||
"MeasurementReminder.lastNotifiedAt":
|
||||
"operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour",
|
||||
// When a join row was written. Nothing reads these: the links are read for
|
||||
// which tag, symptom or context applies, never for when the row appeared,
|
||||
// and the parent entry carries its own timestamps, which do travel.
|
||||
"MoodContext.createdAt":
|
||||
"insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel",
|
||||
"MoodEntryTagLink.createdAt":
|
||||
"insert stamp of a tag link; no reader, the mood entry's own timestamps travel",
|
||||
"CycleSymptomLink.createdAt":
|
||||
"insert stamp of a symptom link; no reader, the day log's own timestamps travel",
|
||||
"IllnessSymptomLink.createdAt":
|
||||
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
|
||||
};
|
||||
|
||||
/**
|
||||
* Columns every reader parses through one function before using, compared
|
||||
* through that same function: the restore writes the parsed form.
|
||||
*/
|
||||
const READ_THROUGH: Readonly<Record<string, (value: unknown) => unknown>> = {
|
||||
"OnboardingRecord.stepsJson": parseOnboardingSteps,
|
||||
};
|
||||
|
||||
/**
|
||||
* Values for columns the fixture leaves empty where the type alone does not
|
||||
* produce one the export and the restore accept.
|
||||
*/
|
||||
const FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
||||
"MedicationSchedule.rrule": "FREQ=WEEKLY;BYDAY=MO,TH",
|
||||
"InboundDocument.sourceInstance": "https://paperless.example.test",
|
||||
// The digest of the fixture's own document bytes, so a restore that checks
|
||||
// it against the content finds them consistent.
|
||||
"InboundDocument.contentSha256": createHash("sha256")
|
||||
.update("round-trip document bytes")
|
||||
.digest("hex"),
|
||||
"Biomarker.contextEncrypted": () =>
|
||||
encryptContextToBytes("fasted, morning draw"),
|
||||
"MoodEntry.tags": JSON.stringify(["round-trip-tag"]),
|
||||
"CorrelationPattern.qValue": 0.04,
|
||||
"CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32),
|
||||
};
|
||||
|
||||
/**
|
||||
* Rows for the columns a CHECK constraint ties to a shape the base fixture
|
||||
* does not have: a stage only on a sleep reading, a context only on a glucose
|
||||
* reading, a rolling interval only on a schedule without a calendar rule.
|
||||
*/
|
||||
async function seedColumnShapes(prisma: PrismaClient): Promise<void> {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 5.4,
|
||||
unit: "mmol/L",
|
||||
measuredAt: AT("2026-07-03T06:45:00.000Z"),
|
||||
source: "MANUAL",
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
});
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 95,
|
||||
unit: "min",
|
||||
measuredAt: AT("2026-07-03T02:00:00.000Z"),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "round-trip-deep-sleep",
|
||||
sleepStage: "DEEP",
|
||||
},
|
||||
});
|
||||
// A start logged out of order and folded into the one before it: soft
|
||||
// deleted, and pointing at the start that absorbed it so that removing that
|
||||
// start can bring this one back.
|
||||
const absorbing = await prisma.menstrualCycle.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID, startDate: "2026-06-01" },
|
||||
});
|
||||
await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
startDate: "2026-06-03",
|
||||
deletedAt: AT("2026-06-04T07:00:00.000Z"),
|
||||
absorbedIntoId: absorbing.id,
|
||||
},
|
||||
});
|
||||
const medication = await prisma.medication.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
await prisma.medicationSchedule.create({
|
||||
data: {
|
||||
medicationId: medication.id,
|
||||
windowStart: "20:00",
|
||||
windowEnd: "21:00",
|
||||
label: "Every third evening",
|
||||
rollingIntervalDays: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type Delegate = {
|
||||
findMany(args: {
|
||||
where: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>[]>;
|
||||
update(args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
|
||||
function delegateOf(prisma: PrismaClient, model: AuditModel): Delegate {
|
||||
return (prisma as unknown as Record<string, Delegate>)[model.delegate];
|
||||
}
|
||||
|
||||
function primaryKeyWhere(
|
||||
model: AuditModel,
|
||||
row: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (model.primaryKey.length === 1) {
|
||||
return { [model.primaryKey[0]]: row[model.primaryKey[0]] };
|
||||
}
|
||||
return {
|
||||
[model.primaryKey.join("_")]: Object.fromEntries(
|
||||
model.primaryKey.map((k) => [k, row[k]]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** How a column is compared, or why it is not. */
|
||||
function comparison(
|
||||
column: AuditColumn,
|
||||
): "value" | "presence" | { excluded: string } {
|
||||
const key = `${column.model}.${column.name}`;
|
||||
if (key in COLUMN_EXCLUSIONS) return { excluded: COLUMN_EXCLUSIONS[key] };
|
||||
if (column.isId) {
|
||||
return { excluded: "primary key; the restore may mint a fresh one" };
|
||||
}
|
||||
if (column.name === "userId") {
|
||||
return { excluded: "owner; re-derived from the restoring account" };
|
||||
}
|
||||
if (column.isUpdatedAt) {
|
||||
return { excluded: "@updatedAt; Prisma stamps it on the restore's write" };
|
||||
}
|
||||
// A declared foreign key may be re-pointed at a row the restore minted, so
|
||||
// it is held to being set where it was set, not to the same id.
|
||||
if (column.isForeignKey) return "presence";
|
||||
return "value";
|
||||
}
|
||||
|
||||
/** Plaintext behind a sealed value, or the value itself when it is not one. */
|
||||
function opened(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return `text:${decrypt(value)}`;
|
||||
} catch {
|
||||
return `text:${value}`;
|
||||
}
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
for (const open of BYTE_OPENERS) {
|
||||
try {
|
||||
return `text:${open(value)}`;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalise(value);
|
||||
}
|
||||
|
||||
/** Every codec a sealed byte column is written with. */
|
||||
const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
|
||||
decryptFromBytes,
|
||||
decryptNoteFromBytes,
|
||||
decryptContextFromBytes,
|
||||
(value) => decryptBytes(Buffer.from(value)).toString("base64"),
|
||||
];
|
||||
|
||||
/**
|
||||
* A legacy plaintext column beside its sealed twin (`note` / `noteEncrypted`)
|
||||
* is one value in two places: the restore seals what it finds in either, and
|
||||
* clears the plaintext. So the pair is compared as the value a reader sees.
|
||||
*/
|
||||
function readerValue(
|
||||
row: Record<string, unknown>,
|
||||
column: AuditColumn,
|
||||
): string {
|
||||
const parse = READ_THROUGH[`${column.model}.${column.name}`];
|
||||
if (parse) return normalise(parse(row[column.name]));
|
||||
const twin = `${column.name}Encrypted`;
|
||||
if (twin in row) {
|
||||
const sealed = opened(row[twin]);
|
||||
return sealed !== "null" ? sealed : opened(row[column.name]);
|
||||
}
|
||||
return opened(row[column.name]);
|
||||
}
|
||||
|
||||
function columnValues(
|
||||
rows: Record<string, unknown>[],
|
||||
column: AuditColumn,
|
||||
how: "value" | "presence",
|
||||
): string[] {
|
||||
return rows
|
||||
.map((row) =>
|
||||
how === "presence"
|
||||
? row[column.name] === null || row[column.name] === undefined
|
||||
? "null"
|
||||
: "set"
|
||||
: readerValue(row, column),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function snapshot(
|
||||
prisma: PrismaClient,
|
||||
models: AuditModel[],
|
||||
wheres: Map<string, Record<string, unknown>>,
|
||||
): Promise<Map<string, Record<string, unknown>[]>> {
|
||||
const out = new Map<string, Record<string, unknown>[]>();
|
||||
for (const model of models) {
|
||||
out.set(
|
||||
model.name,
|
||||
await delegateOf(prisma, model).findMany({
|
||||
where: wheres.get(model.name)!,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns whose value differs between two snapshots, by `Model.column`. Pure,
|
||||
* so the test can show it catches a column it was never told about.
|
||||
*/
|
||||
function lostColumns(
|
||||
models: AuditModel[],
|
||||
before: Map<string, Record<string, unknown>[]>,
|
||||
after: Map<string, Record<string, unknown>[]>,
|
||||
): { lost: string[]; compared: number } {
|
||||
const lost: string[] = [];
|
||||
let compared = 0;
|
||||
for (const model of models) {
|
||||
for (const column of model.columns) {
|
||||
const how = comparison(column);
|
||||
if (typeof how === "object") continue;
|
||||
const plain = column.name.replace(/Encrypted$/, "");
|
||||
if (
|
||||
plain !== column.name &&
|
||||
model.columns.some((c) => c.name === plain)
|
||||
) {
|
||||
continue; // compared through its plaintext twin, see readerValue
|
||||
}
|
||||
compared += 1;
|
||||
const was = columnValues(before.get(model.name) ?? [], column, how);
|
||||
const is = columnValues(after.get(model.name) ?? [], column, how);
|
||||
if (normalise(was) !== normalise(is)) {
|
||||
lost.push(`${model.name}.${column.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lost: lost.sort(), compared };
|
||||
}
|
||||
|
||||
describe("every column of every two-ended model survives a real restore", () => {
|
||||
it("brings each column back with the value it left with", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
// A catalogue row the fixture adds, which survives both the account delete
|
||||
// and the per-test truncation; left behind by the first test in this file.
|
||||
await prisma.illnessSymptom.deleteMany({
|
||||
where: { key: "round_trip_cough" },
|
||||
});
|
||||
await seedEveryTwoEndedModel(prisma);
|
||||
await seedColumnShapes(prisma);
|
||||
|
||||
const models = readAuditModels(prisma, TWO_ENDED_MODELS);
|
||||
const byName = new Map(models.map((m) => [m.name, m]));
|
||||
const wheres = new Map(
|
||||
models.map((m) => [m.name, ownerWhere(byName, prisma, m.name, OWNER_ID)]),
|
||||
);
|
||||
const enumValues = enumValuesFromSchema();
|
||||
|
||||
// Fill what the fixture left empty, so an empty column cannot come back
|
||||
// empty and read as carried. One column at a time on one row, so a value
|
||||
// the database refuses names its column instead of failing the batch.
|
||||
const unfillable: string[] = [];
|
||||
const seededRows = await snapshot(prisma, models, wheres);
|
||||
for (const model of models) {
|
||||
const rows = seededRows.get(model.name)!;
|
||||
expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0);
|
||||
for (const column of model.columns) {
|
||||
if (comparison(column) !== "value") continue;
|
||||
// Filling it would turn the fixture's row into a tombstone, and which
|
||||
// models carry tombstones is a row decision, not a column one.
|
||||
if (column.name === "deletedAt") continue;
|
||||
if (rows.some((row) => !isDefaultValue(column, row[column.name]))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await delegateOf(prisma, model).update({
|
||||
where: primaryKeyWhere(model, rows[0]),
|
||||
data: {
|
||||
[column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
unfillable.push(
|
||||
`${model.name}.${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(unfillable, "columns the fill could not set").toEqual([]);
|
||||
|
||||
const before = await snapshot(prisma, models, wheres);
|
||||
|
||||
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
|
||||
let json = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{ purpose: "disaster-recovery", exportedAt },
|
||||
);
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "COLUMN_ROUND_TRIP",
|
||||
data: await legacyStreamedBlobFrom(async (write) => {
|
||||
await write(json);
|
||||
}),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
|
||||
const after = await snapshot(prisma, models, wheres);
|
||||
const { lost, compared } = lostColumns(models, before, after);
|
||||
|
||||
// A comparison over no columns agrees with everything. 632 at the time of
|
||||
// writing; the headroom is for a column dropped by a migration, not for a
|
||||
// data model that stopped being read.
|
||||
expect(compared).toBeGreaterThanOrEqual(620);
|
||||
expect(
|
||||
lost,
|
||||
"these columns left with a value and came back without it. Carry each " +
|
||||
"through the export and the restore, or name it in COLUMN_EXCLUSIONS " +
|
||||
"with the reason the restore is right to drop it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("notices a column nobody told it about", () => {
|
||||
const model: AuditModel = {
|
||||
name: "Probe",
|
||||
delegate: "probe",
|
||||
primaryKey: ["id"],
|
||||
parents: {},
|
||||
columns: [
|
||||
{
|
||||
model: "Probe",
|
||||
name: "addedTomorrow",
|
||||
kind: "scalar",
|
||||
type: "String",
|
||||
isList: false,
|
||||
isOptional: true,
|
||||
isId: false,
|
||||
isUpdatedAt: false,
|
||||
isForeignKey: false,
|
||||
defaultText: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const before = new Map([["Probe", [{ id: "a", addedTomorrow: "kept" }]]]);
|
||||
const after = new Map([["Probe", [{ id: "b", addedTomorrow: null }]]]);
|
||||
expect(lostColumns([model], before, after)).toEqual({
|
||||
lost: ["Probe.addedTomorrow"],
|
||||
compared: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("names no exclusion for a column that no longer exists", () => {
|
||||
const models = readAuditModels(getPrismaClient(), TWO_ENDED_MODELS);
|
||||
const known = new Set(
|
||||
models.flatMap((m) => m.columns.map((c) => `${m.name}.${c.name}`)),
|
||||
);
|
||||
const stale = [
|
||||
...Object.keys(COLUMN_EXCLUSIONS),
|
||||
...Object.keys(READ_THROUGH),
|
||||
...Object.keys(FILL_OVERRIDES),
|
||||
].filter((key) => !known.has(key));
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user