mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Each of these asked the same question several times or did the expensive part before the cheap one: the threshold guard parsed every client file once per rule and twice per run, the i18n reverse check rebuilt its scan three times and compared every leaf with every literal key, the step-up caller guard stripped comments and searched every route path in every file per case, the plausibility guard ran its writer sweep three times, and the cycle verdict guard parsed every client file to find out whether it mentions a cycle. Each now works a result out once, and a file is parsed or searched only when a substring says it can match. A planted violation still fails the threshold, plausibility, cycle verdict and backup plan guards.
627 lines
23 KiB
TypeScript
627 lines
23 KiB
TypeScript
import { readdirSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
AUTH_MODELS_OUT_OF_SCOPE,
|
|
BACKED_UP_MODELS,
|
|
DERIVED_MODELS,
|
|
NOT_IN_BACKUP_MODELS,
|
|
RAW_SQL_TABLES,
|
|
backupVerdict,
|
|
} from "@/lib/export/backup-plan";
|
|
|
|
/**
|
|
* Every model in the schema has a written verdict about the backup.
|
|
*
|
|
* This is the first of three checks on that claim, and the weakest one on
|
|
* purpose — it asks only whether a decision was written down.
|
|
*
|
|
* 1. here — every user-scoped model has a verdict, and every verdict names a
|
|
* model the schema still has.
|
|
* 2. below — every model claimed two-ended has a payload reader and a restore
|
|
* branch in the declared files. Source text, so it sees code that was
|
|
* never written and not code that never runs.
|
|
* 3. `tests/integration/backup-round-trip.test.ts` — a seeded row of every
|
|
* two-ended model exported, the account emptied, the restore run, the row
|
|
* counted back. The only one of the three that can tell a restore branch
|
|
* that runs from one that merely exists.
|
|
*
|
|
* An earlier version of this comment said the second half did not exist and
|
|
* that `BACKED_UP` therefore meant "must be carried" rather than "is carried".
|
|
* Both halves exist now; the sentence is rewritten rather than deleted because
|
|
* the sequence is the point — the classification was written first and was
|
|
* mistaken for proof for several releases.
|
|
*
|
|
* The failure all three prevent: a model is added to `schema.prisma`, it is
|
|
* user-scoped from birth, and it is outside the backup by default. Nothing
|
|
* notices, and the gap is only discovered by someone restoring a backup and
|
|
* finding a part of their record missing — which is the worst possible moment
|
|
* and the worst possible messenger.
|
|
*/
|
|
|
|
const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma");
|
|
|
|
/** Models declared in the schema, minus `User` itself (the account row). */
|
|
function schemaModels(): string[] {
|
|
const source = readFileSync(SCHEMA_PATH, "utf8");
|
|
return [...source.matchAll(/^model\s+(\w+)\s*\{/gm)]
|
|
.map((m) => m[1])
|
|
.filter((name) => name !== "User");
|
|
}
|
|
|
|
/**
|
|
* Models with no `userId` and no relation to `User` are instance-scoped (queue
|
|
* tables, rate-limit buckets, invite codes the operator issues). They are not
|
|
* an account's data and the wipe plan already declares them as such.
|
|
*
|
|
* The relation check is on the TYPE, not on the field name. It used to require
|
|
* a field literally called `user`, which meant a model naming its account
|
|
* relations anything else — `grantor`/`grantee` on an account grant, say —
|
|
* read as instance-scoped and was excused from having a verdict at all. A
|
|
* guard that lets the newest table through is the shape of guard this file
|
|
* exists to prevent, so it reads the type.
|
|
*/
|
|
function isUserScoped(model: string): boolean {
|
|
const source = readFileSync(SCHEMA_PATH, "utf8");
|
|
const block = new RegExp(
|
|
`^model\\s+${model}\\s*\\{([\\s\\S]*?)^\\}`,
|
|
"m",
|
|
).exec(source);
|
|
if (!block) return false;
|
|
const body = block[1];
|
|
return /\buserId\b/.test(body) || /^\s*\w+\s+User(\[\]|\?)?\s/m.test(body);
|
|
}
|
|
|
|
describe("every schema model has a backup verdict", () => {
|
|
const models = schemaModels();
|
|
|
|
it("reads a schema with models in it (no vacuous pass)", () => {
|
|
expect(models.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
it("classifies every user-scoped model", () => {
|
|
const unclassified = models
|
|
.filter(isUserScoped)
|
|
.filter((m) => backupVerdict(m) === null);
|
|
|
|
if (unclassified.length > 0) {
|
|
throw new Error(
|
|
`${unclassified.length} user-scoped model(s) have no backup verdict:\n\n` +
|
|
unclassified.map((m) => ` ❌ ${m}`).join("\n") +
|
|
"\n\nAdd each to src/lib/export/backup-plan.ts: to BACKED_UP_MODELS if an " +
|
|
"account would lose something real without it, to DERIVED_MODELS with what " +
|
|
"rebuilds it, or to NOT_IN_BACKUP_MODELS with why it must not travel. " +
|
|
"A new model is user-scoped from birth and outside the backup by default, " +
|
|
"which is exactly the silence this test exists to break.",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("carries no verdict for a model the schema no longer has", () => {
|
|
const known = new Set(models);
|
|
const stale = [
|
|
...BACKED_UP_MODELS,
|
|
...Object.keys(DERIVED_MODELS),
|
|
...Object.keys(NOT_IN_BACKUP_MODELS),
|
|
...AUTH_MODELS_OUT_OF_SCOPE,
|
|
].filter((m) => !known.has(m));
|
|
|
|
expect(
|
|
stale,
|
|
`verdicts for models that no longer exist: ${stale.join(", ")}`,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("gives each model exactly one verdict", () => {
|
|
const seen = new Map<string, string[]>();
|
|
const add = (model: string, list: string) => {
|
|
seen.set(model, [...(seen.get(model) ?? []), list]);
|
|
};
|
|
for (const m of BACKED_UP_MODELS) add(m, "BACKED_UP");
|
|
for (const m of Object.keys(DERIVED_MODELS)) add(m, "DERIVED");
|
|
for (const m of Object.keys(NOT_IN_BACKUP_MODELS)) add(m, "NOT_IN_BACKUP");
|
|
for (const m of AUTH_MODELS_OUT_OF_SCOPE) add(m, "AUTH");
|
|
|
|
const doubled = [...seen.entries()].filter(([, lists]) => lists.length > 1);
|
|
expect(
|
|
doubled.map(([m, lists]) => `${m}: ${lists.join(" + ")}`),
|
|
"a model classified twice means two readers can each believe the other handles it",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("gives every excluded model a reason, not an empty string", () => {
|
|
const empty = [
|
|
...Object.entries(DERIVED_MODELS),
|
|
...Object.entries(NOT_IN_BACKUP_MODELS),
|
|
]
|
|
// 20 characters let "Same as WithingsConnection." through, which is a
|
|
// pointer rather than a reason: if the entry it points at changes, this
|
|
// one silently inherits something else. 60 forces the decision to be
|
|
// written where it is made.
|
|
.filter(([, reason]) => reason.trim().length < 60)
|
|
.map(([model]) => model);
|
|
expect(
|
|
empty,
|
|
"a one-word reason is how a decision stops being reviewable",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("excludes Guardian egress claims rather than exporting delivery metadata", () => {
|
|
expect(backupVerdict("NotificationEgressAuthorization")).toBe(
|
|
"NOT_IN_BACKUP",
|
|
);
|
|
expect(NOT_IN_BACKUP_MODELS.NotificationEgressAuthorization).toMatch(
|
|
/no notification content, credentials, or deduplication key/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The second half of the guard: a `BACKED_UP` model either travels BOTH ways or
|
|
* is named as debt.
|
|
*
|
|
* The first half above proves every model has an opinion. It would pass
|
|
* unchanged while a model classified as carried was read into the payload and
|
|
* never written back — which is not a hypothetical, it is what happened to the
|
|
* nutrient day totals, to the custom mood tags, and to the custom cycle
|
|
* symptoms, three releases running.
|
|
*
|
|
* The check reads the declared writer and restore files rather than grepping
|
|
* for a route, BECAUSE the route delegates. A review that grepped only
|
|
* `restore/route.ts` concluded the cycle data was never restored and had to be
|
|
* retracted; `restoreCycleData` and `restoreProfileData` both live elsewhere.
|
|
* Following the helpers is the entire difficulty of this question, so the file
|
|
* list is declared next to the verdicts and checked to exist.
|
|
*
|
|
* ── What reading source text cannot answer ──────────────────────────────────
|
|
*
|
|
* A branch that exists and never runs. Wrapping the nutrient restore in
|
|
* `if (false && …)` leaves every check in this file green, because the call is
|
|
* still written where the matcher looks. Same for a restore that writes into
|
|
* the wrong account, or a transaction that rolls back. The round-trip test
|
|
* named above is what answers those, and the reason it is worth its runtime.
|
|
*
|
|
* A model the matcher cannot tell apart from another is named in
|
|
* {@link STRUCTURALLY_UNATTRIBUTABLE} with what defeats it, and is checked here
|
|
* only for being covered by the round trip.
|
|
*/
|
|
|
|
import {
|
|
BACKUP_RESTORE_FILES,
|
|
BACKUP_WRITER_FILES,
|
|
COVERAGE_PENDING,
|
|
STRUCTURALLY_UNATTRIBUTABLE,
|
|
TWO_ENDED_MODELS,
|
|
} from "@/lib/export/backup-plan";
|
|
|
|
/**
|
|
* The behavioural half, checked from here only for its coverage.
|
|
*
|
|
* It seeds a row per two-ended model, exports through the real builder, empties
|
|
* the account and restores through the real route, so it is the only check that
|
|
* can tell a restore branch that RUNS from one that merely exists — this file
|
|
* stayed green with the nutrient restore wrapped in `if (false)`.
|
|
*/
|
|
const ROUND_TRIP_TEST = "tests/integration/backup-round-trip.test.ts";
|
|
|
|
const REPO_ROOT = resolve(__dirname, "../..");
|
|
|
|
/**
|
|
* Every read below is memoised: the two-ended checks ask each declared file
|
|
* about each model and each of its relations, and reading the schema and the
|
|
* files again for every question made those checks slow enough to time out on
|
|
* a loaded runner.
|
|
*/
|
|
const sourceCache = new Map<string, string>();
|
|
|
|
function read(relativePath: string): string {
|
|
let text = sourceCache.get(relativePath);
|
|
if (text === undefined) {
|
|
text = readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
|
|
sourceCache.set(relativePath, text);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
const flatCache = new Map<string, string>();
|
|
|
|
/** A declared file, whitespace-flattened so a call broken across lines matches. */
|
|
function flat(relativePath: string): string {
|
|
let text = flatCache.get(relativePath);
|
|
if (text === undefined) {
|
|
text = read(relativePath).replace(/\s+/g, " ");
|
|
flatCache.set(relativePath, text);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
let schemaModelBodies: Array<[string, string]> | null = null;
|
|
|
|
/** Every schema model's name and body, read once. */
|
|
function modelBodies(): Array<[string, string]> {
|
|
schemaModelBodies ??= [
|
|
...readFileSync(SCHEMA_PATH, "utf8").matchAll(
|
|
/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm,
|
|
),
|
|
].map(([, name, body]) => [name, body]);
|
|
return schemaModelBodies;
|
|
}
|
|
|
|
/** `Measurement` → `measurement`, the Prisma delegate name. */
|
|
function delegateName(model: string): string {
|
|
return model[0].toLowerCase() + model.slice(1);
|
|
}
|
|
|
|
/**
|
|
* Relation fields on OTHER models whose type is `model`, each with the model
|
|
* that declares it.
|
|
*
|
|
* A child rides its parent — `include: { schedules: true }` on the way out,
|
|
* `schedules: { create: [...] }` on the way back — so a delegate-only search
|
|
* would report every nested child as uncarried and the guard would be noise.
|
|
*
|
|
* The declaring parent comes back with the field name because a field name on
|
|
* its own is not evidence about a model. `symptomLinks` is declared twice —
|
|
* once on `IllnessDayLog` for `IllnessSymptomLink`, once on `CycleDayLog` for
|
|
* `CycleSymptomLink` — and a search for the bare name found the cycle write in
|
|
* `src/lib/cycle/backup.ts` and reported the ILLNESS link covered. Verified by
|
|
* deleting the illness `symptomLinks: { create: … }` branch from the restore
|
|
* route outright: the guard stayed green on all twelve checks, because the
|
|
* cycle file was answering a question about a different table.
|
|
*/
|
|
const parentsCache = new Map<
|
|
string,
|
|
Array<{ field: string; parent: string }>
|
|
>();
|
|
|
|
function relationParents(model: string): Array<{
|
|
field: string;
|
|
parent: string;
|
|
}> {
|
|
const cached = parentsCache.get(model);
|
|
if (cached) return cached;
|
|
const found: Array<{ field: string; parent: string }> = [];
|
|
for (const [parent, body] of modelBodies()) {
|
|
for (const line of body.split("\n")) {
|
|
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
|
|
if (match && match[2] === model) {
|
|
found.push({ field: match[1], parent });
|
|
}
|
|
}
|
|
}
|
|
parentsCache.set(model, found);
|
|
return found;
|
|
}
|
|
|
|
const READ_OPS = /(findMany|findUnique|findFirst|findUniqueOrThrow|groupBy)/;
|
|
const WRITE_OPS = /(create|createMany|upsert|update|updateMany)/;
|
|
const READ_RELATION_OPS = /[{[t]/;
|
|
const WRITE_RELATION_OPS = /\{\s*create\b/;
|
|
|
|
/** Does `source` call a matching operation on `model`'s own Prisma delegate? */
|
|
function callsDelegate(source: string, model: string, ops: RegExp): boolean {
|
|
const delegate = delegateName(model);
|
|
// The expression below names the delegate; a file without it cannot match.
|
|
if (!source.includes(delegate)) return false;
|
|
for (const match of source.matchAll(
|
|
new RegExp(`\\.${delegate}\\s*\\.\\s*(\\w+)`, "g"),
|
|
)) {
|
|
if (ops.test(match[1])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Every model a relation field of this name points at, across the schema. */
|
|
const targetsCache = new Map<string, Set<string>>();
|
|
|
|
function targetsOfRelationField(field: string): Set<string> {
|
|
const cached = targetsCache.get(field);
|
|
if (cached) return cached;
|
|
const targets = new Set<string>();
|
|
for (const [, body] of modelBodies()) {
|
|
for (const line of body.split("\n")) {
|
|
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
|
|
if (match && match[1] === field) targets.add(match[2]);
|
|
}
|
|
}
|
|
targetsCache.set(field, targets);
|
|
return targets;
|
|
}
|
|
|
|
/**
|
|
* Does any of `files` touch `model` through matching delegate and relation
|
|
* operations?
|
|
*
|
|
* A relation field name that points at exactly ONE model anywhere in the schema
|
|
* is evidence wherever it appears in the declared files — `schedules` can only
|
|
* mean `MedicationSchedule`.
|
|
*
|
|
* A name that points at SEVERAL models is evidence only in a file that also
|
|
* operates on the declaring parent's delegate, because otherwise the two
|
|
* writes are indistinguishable and either model answers for the other.
|
|
*/
|
|
function touches(
|
|
files: readonly string[],
|
|
model: string,
|
|
delegateOps: RegExp,
|
|
relationOps: RegExp,
|
|
): boolean {
|
|
return files.some((file) => {
|
|
const source = flat(file);
|
|
if (callsDelegate(source, model, delegateOps)) return true;
|
|
return relationParents(model).some(({ field, parent }) => {
|
|
if (!source.includes(field)) return false;
|
|
if (
|
|
!new RegExp(`\\b${field}\\s*:\\s*(?:${relationOps.source})`).test(
|
|
source,
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (targetsOfRelationField(field).size === 1) return true;
|
|
return callsDelegate(source, parent, delegateOps);
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("every backed-up model travels both ways, or is named as debt", () => {
|
|
it("does not count a nested relation read as a restore write", () => {
|
|
expect(
|
|
touches(
|
|
["src/lib/export/measurements-restore.ts"],
|
|
"User",
|
|
WRITE_OPS,
|
|
WRITE_RELATION_OPS,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not accept a relation name borrowed from another parent", () => {
|
|
// `src/lib/cycle/backup.ts` writes `symptomLinks: { create: … }` for
|
|
// `CycleDayLog`. The same field name belongs to `IllnessDayLog`, so the
|
|
// bare-name search read that line as proof the illness links are restored.
|
|
// The cycle file must answer for the cycle link and for nothing else.
|
|
expect(
|
|
touches(
|
|
["src/lib/cycle/backup.ts"],
|
|
"CycleSymptomLink",
|
|
WRITE_OPS,
|
|
WRITE_RELATION_OPS,
|
|
),
|
|
"the cycle file does write the cycle link — this half must stay true",
|
|
).toBe(true);
|
|
expect(
|
|
touches(
|
|
["src/lib/cycle/backup.ts"],
|
|
"IllnessSymptomLink",
|
|
WRITE_OPS,
|
|
WRITE_RELATION_OPS,
|
|
),
|
|
"the cycle file says nothing about illness links and must not be read as if it did",
|
|
).toBe(false);
|
|
});
|
|
|
|
it("declares writer and restore files that exist", () => {
|
|
for (const file of [...BACKUP_WRITER_FILES, ...BACKUP_RESTORE_FILES]) {
|
|
expect(() => read(file), `${file} is declared but missing`).not.toThrow();
|
|
}
|
|
// A grep of the restore ROUTE alone is the mistake this list prevents, so
|
|
// the list has to name more than the route.
|
|
expect(BACKUP_RESTORE_FILES.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("splits BACKED_UP into exactly two-ended and pending, with no overlap", () => {
|
|
const backedUp = new Set<string>(BACKED_UP_MODELS);
|
|
const twoEnded = new Set<string>(TWO_ENDED_MODELS);
|
|
const pending = new Set(Object.keys(COVERAGE_PENDING));
|
|
|
|
const both = [...twoEnded].filter((m) => pending.has(m));
|
|
expect(
|
|
both,
|
|
"a model cannot be both proven and pending — that is how a gap hides",
|
|
).toEqual([]);
|
|
|
|
const unaccounted = [...backedUp].filter(
|
|
(m) => !twoEnded.has(m) && !pending.has(m),
|
|
);
|
|
expect(
|
|
unaccounted,
|
|
"add each to TWO_ENDED_MODELS once its reader AND restore branch land, " +
|
|
"or to COVERAGE_PENDING with what an account loses meanwhile",
|
|
).toEqual([]);
|
|
|
|
const foreign = [...twoEnded, ...pending].filter((m) => !backedUp.has(m));
|
|
expect(
|
|
foreign,
|
|
"only a BACKED_UP model has two ends to have; these are not on that list",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("finds a payload reader for every model claimed two-ended", () => {
|
|
const missing = TWO_ENDED_MODELS.filter(
|
|
(model) =>
|
|
!(model in STRUCTURALLY_UNATTRIBUTABLE) &&
|
|
!touches(BACKUP_WRITER_FILES, model, READ_OPS, READ_RELATION_OPS),
|
|
);
|
|
expect(
|
|
missing,
|
|
"claimed carried, but no backup writer reads it — the restore branch has " +
|
|
"nothing feeding it and a restore silently rebuilds the account without it",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("finds a restore branch for every model claimed two-ended", () => {
|
|
const missing = TWO_ENDED_MODELS.filter(
|
|
(model) =>
|
|
!(model in STRUCTURALLY_UNATTRIBUTABLE) &&
|
|
!touches(BACKUP_RESTORE_FILES, model, WRITE_OPS, WRITE_RELATION_OPS),
|
|
);
|
|
expect(
|
|
missing,
|
|
"claimed carried, but nothing writes it back — the export is a file the " +
|
|
"restore reads past, which is the worst of both: the data is in the " +
|
|
"backup and not in the account",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("hands every unattributable model to the round trip instead", () => {
|
|
const names = Object.keys(STRUCTURALLY_UNATTRIBUTABLE);
|
|
const foreign = names.filter(
|
|
(m) => !(TWO_ENDED_MODELS as readonly string[]).includes(m),
|
|
);
|
|
expect(
|
|
foreign,
|
|
"only a model claimed two-ended has a claim for this check to be unable to attribute",
|
|
).toEqual([]);
|
|
|
|
const thin = Object.entries(STRUCTURALLY_UNATTRIBUTABLE)
|
|
.filter(([, reason]) => reason.trim().length < 60)
|
|
.map(([model]) => model);
|
|
expect(
|
|
thin,
|
|
"say what defeats the matcher, or the next reader cannot tell an " +
|
|
"unprovable model from an unproven one",
|
|
).toEqual([]);
|
|
|
|
// Naming a model here silences the two checks above, so it has to buy that
|
|
// silence with a seeded row in the round trip. Without this the record is
|
|
// a way to make any model's coverage question disappear.
|
|
const roundTrip = read(ROUND_TRIP_TEST);
|
|
const uncovered = names.filter(
|
|
(model) => !new RegExp(`\\b${model}\\s*:`).test(roundTrip),
|
|
);
|
|
expect(
|
|
uncovered,
|
|
`exempt from the structural check and absent from ${ROUND_TRIP_TEST} — ` +
|
|
"that is not a limitation being recorded, it is a model with nothing " +
|
|
"checking it at all",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("gives every pending model a reason that says what is lost", () => {
|
|
const thin = Object.entries(COVERAGE_PENDING)
|
|
.filter(([, reason]) => reason.trim().length < 60)
|
|
.map(([model]) => model);
|
|
expect(
|
|
thin,
|
|
"'not yet' is not a reason — say what an account loses, so the debt can " +
|
|
"be ordered by cost to a person rather than by ease of writing",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("proves the reminder engine models are on the covered side", () => {
|
|
// v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and
|
|
// the completion ledger landed carried from the release that introduced
|
|
// it. Pinned by name so a future edit cannot quietly move either back to
|
|
// COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip
|
|
// before the latest one, so "pending" would be a silent history loss, not
|
|
// a deferral.
|
|
for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) {
|
|
expect(BACKED_UP_MODELS).toContain(model);
|
|
expect(TWO_ENDED_MODELS).toContain(model);
|
|
expect(COVERAGE_PENDING).not.toHaveProperty(model);
|
|
}
|
|
});
|
|
|
|
it("proves the structured-profile models are on the covered side", () => {
|
|
// These account-owned rows are exported and restored explicitly rather
|
|
// than being treated as reconstructible data.
|
|
for (const model of [
|
|
"UserHealthProfile",
|
|
"HealthProfileFactRevision",
|
|
"CustomMetric",
|
|
"CustomMetricEntry",
|
|
]) {
|
|
expect(BACKED_UP_MODELS).toContain(model);
|
|
expect(TWO_ENDED_MODELS).toContain(model);
|
|
expect(COVERAGE_PENDING).not.toHaveProperty(model);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("tables outside the schema have a backup verdict too", () => {
|
|
const SRC = resolve(__dirname, "..");
|
|
|
|
function sourceFiles(dir: string): string[] {
|
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return entry.name === "generated" || entry.name === "__tests__"
|
|
? []
|
|
: sourceFiles(full);
|
|
}
|
|
return /\.tsx?$/.test(entry.name) ? [full] : [];
|
|
});
|
|
}
|
|
|
|
const CREATE_TABLE = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi;
|
|
|
|
function tablesIn(text: string): string[] {
|
|
return [...text.matchAll(CREATE_TABLE)].map((m) => m[1]);
|
|
}
|
|
|
|
function rawTables(): string[] {
|
|
const names = new Set<string>();
|
|
for (const file of sourceFiles(SRC)) {
|
|
for (const name of tablesIn(readFileSync(file, "utf8"))) {
|
|
names.add(name);
|
|
}
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
const mappedTables = new Set(
|
|
[...readFileSync(SCHEMA_PATH, "utf8").matchAll(/@@map\("(\w+)"\)/g)].map(
|
|
(m) => m[1],
|
|
),
|
|
);
|
|
|
|
it("finds a raw table when one is written (no vacuous pass)", () => {
|
|
// The source holds none since v1.40, so the matcher is proven against the
|
|
// exact DDL the medication category helper used to run at runtime.
|
|
expect(
|
|
tablesIn(`
|
|
CREATE TABLE IF NOT EXISTS medication_categories (
|
|
medication_id TEXT PRIMARY KEY
|
|
);
|
|
create table if not exists "quoted_name" (id TEXT);
|
|
`),
|
|
).toEqual(["medication_categories", "quoted_name"]);
|
|
});
|
|
|
|
it("keeps no verdict for a raw table the source no longer creates", () => {
|
|
const present = new Set(rawTables());
|
|
expect(Object.keys(RAW_SQL_TABLES).filter((t) => !present.has(t))).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it("judges the adopted medication category table as a schema model", () => {
|
|
expect(mappedTables.has("medication_categories")).toBe(true);
|
|
expect(BACKED_UP_MODELS).toContain("MedicationCategoryAssignment");
|
|
expect(TWO_ENDED_MODELS).toContain("MedicationCategoryAssignment");
|
|
});
|
|
|
|
it("gives every raw table that is not a schema model a verdict", () => {
|
|
const unjudged = rawTables().filter(
|
|
(name) => !mappedTables.has(name) && !(name in RAW_SQL_TABLES),
|
|
);
|
|
expect(unjudged).toEqual([]);
|
|
});
|
|
|
|
it("carries the medication category through both ends", () => {
|
|
const writer = readFileSync(
|
|
join(SRC, "lib/export/medications-backup.ts"),
|
|
"utf8",
|
|
);
|
|
const restore = readFileSync(
|
|
join(SRC, "lib/export/medications-restore.ts"),
|
|
"utf8",
|
|
);
|
|
expect(writer).toMatch(/getMedicationCategories\(/);
|
|
expect(restore).toMatch(/setMedicationCategory\(/);
|
|
});
|
|
});
|