mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #702 from MBombeck/work/backup-plan-reader
Prove a backed-up model travels, instead of proving a name exists
This commit is contained in:
3 files changed
+696
-29
No files matched your search
@@ -14,15 +14,26 @@ import {
|
||||
/**
|
||||
* Every model in the schema has a written verdict about the backup.
|
||||
*
|
||||
* This is the first half of the backup guard: classification. The second half
|
||||
* — that every `BACKED_UP` model actually has a payload reader AND a restore
|
||||
* branch — DOES NOT EXIST YET. It is the harder half and the one that matters,
|
||||
* because a model classified as carried and then never restored is the defect
|
||||
* rather than the fix, and this test would pass either way.
|
||||
* 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.
|
||||
*
|
||||
* Until it lands, `BACKED_UP` means "must be carried", not "is carried".
|
||||
* 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.
|
||||
*
|
||||
* The failure this prevents: a model is added to `schema.prisma`, it is
|
||||
* 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
|
||||
@@ -153,15 +164,38 @@ describe("every schema model has a backup verdict", () => {
|
||||
* 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, "../..");
|
||||
|
||||
function read(relativePath: string): string {
|
||||
@@ -174,22 +208,39 @@ function delegateName(model: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation fields on OTHER models whose type is `model`.
|
||||
* 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.
|
||||
*/
|
||||
function relationFieldNames(model: string): string[] {
|
||||
function relationParents(model: string): Array<{
|
||||
field: string;
|
||||
parent: string;
|
||||
}> {
|
||||
const source = readFileSync(SCHEMA_PATH, "utf8");
|
||||
const names = new Set<string>();
|
||||
for (const [, body] of source.matchAll(/^model\s+\w+\s*\{([\s\S]*?)^\}/gm)) {
|
||||
const found: Array<{ field: string; parent: string }> = [];
|
||||
for (const [, parent, body] of source.matchAll(
|
||||
/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm,
|
||||
)) {
|
||||
for (const line of body.split("\n")) {
|
||||
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
|
||||
if (match && match[2] === model) names.add(match[1]);
|
||||
if (match && match[2] === model) {
|
||||
found.push({ field: match[1], parent });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
return found;
|
||||
}
|
||||
|
||||
const READ_OPS = /(findMany|findUnique|findFirst|findUniqueOrThrow|groupBy)/;
|
||||
@@ -197,28 +248,63 @@ const WRITE_OPS = /(create|createMany|upsert|update|updateMany)/;
|
||||
const READ_RELATION_OPS = /[{[t]/;
|
||||
const WRITE_RELATION_OPS = /\{\s*create\b/;
|
||||
|
||||
/** Does any of `files` touch `model` through matching delegate and relation operations? */
|
||||
/** 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);
|
||||
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. */
|
||||
function targetsOfRelationField(field: string): Set<string> {
|
||||
const source = readFileSync(SCHEMA_PATH, "utf8");
|
||||
const targets = new Set<string>();
|
||||
for (const [, body] of source.matchAll(/^model\s+\w+\s*\{([\s\S]*?)^\}/gm)) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
const delegate = delegateName(model);
|
||||
const relations = relationFieldNames(model);
|
||||
return files.some((file) => {
|
||||
// Whitespace-flattened so a call broken across lines still matches.
|
||||
const source = read(file).replace(/\s+/g, " ");
|
||||
for (const match of source.matchAll(
|
||||
new RegExp(`\\.${delegate}\\s*\\.\\s*(\\w+)`, "g"),
|
||||
)) {
|
||||
if (delegateOps.test(match[1])) return true;
|
||||
}
|
||||
return relations.some((relation) =>
|
||||
new RegExp(`\\b${relation}\\s*:\\s*(?:${relationOps.source})`).test(
|
||||
source,
|
||||
),
|
||||
);
|
||||
if (callsDelegate(source, model, delegateOps)) return true;
|
||||
return relationParents(model).some(({ field, parent }) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,6 +320,31 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
).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();
|
||||
@@ -245,7 +356,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
|
||||
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(TWO_ENDED_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));
|
||||
@@ -273,6 +384,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
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(
|
||||
@@ -285,6 +397,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
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(
|
||||
@@ -295,6 +408,40 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
).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)
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
* defect this file was written to prevent, so it is recorded
|
||||
* rather than quietly corrected — and it is why the guard now
|
||||
* reads the source rather than trusting a list.
|
||||
*
|
||||
* Reading the source is still not the same as watching the data
|
||||
* move: a restore branch wrapped in `if (false)` reads exactly
|
||||
* like one that works. So the claim is settled end to end in
|
||||
* `tests/integration/backup-round-trip.test.ts`, which seeds a
|
||||
* row of every model named here, exports it, deletes the
|
||||
* account and counts the row back out of the real restore.
|
||||
* `DERIVED` — recomputable from `BACKED_UP` rows. Leaving it out keeps the
|
||||
* file smaller and cannot lose anything; the reason names what
|
||||
* recomputes it.
|
||||
@@ -161,6 +168,11 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
// delegates" mistake the list exists to prevent, made while writing the list.
|
||||
"src/lib/export/paged-measurements.ts",
|
||||
"src/lib/export/records-backup.ts",
|
||||
// The illness day-log's symptom include is a shared constant living here,
|
||||
// not a literal in the records writer. Same "the caller delegates" shape as
|
||||
// the measurement pager above, found the same way: by a check that stopped
|
||||
// accepting one model's relation write as proof about another's.
|
||||
"src/lib/illness/dto.ts",
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/export/health-score-backup.ts",
|
||||
@@ -187,8 +199,12 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
* the whole point of the split — a verdict of `BACKED_UP` is an intention, and
|
||||
* an intention is what the nutrient day totals had while a restore was throwing
|
||||
* them away and reporting success.
|
||||
*
|
||||
* Frozen as a literal tuple so {@link TwoEndedModel} can key the round-trip
|
||||
* test's seed registry. Adding a name here without seeding a row for it is then
|
||||
* a compile error rather than a silently unproven claim.
|
||||
*/
|
||||
export const TWO_ENDED_MODELS: readonly string[] = [
|
||||
export const TWO_ENDED_MODELS = [
|
||||
"Measurement",
|
||||
"IntradayCumulativeProfile",
|
||||
"Medication",
|
||||
@@ -218,7 +234,30 @@ export const TWO_ENDED_MODELS: readonly string[] = [
|
||||
"Workout",
|
||||
"NutrientIntakeDay",
|
||||
"InboundDocument",
|
||||
];
|
||||
] as const;
|
||||
|
||||
/** One model claimed to travel both ways. */
|
||||
export type TwoEndedModel = (typeof TWO_ENDED_MODELS)[number];
|
||||
|
||||
/**
|
||||
* Two-ended models whose coverage the structural check cannot ATTRIBUTE, with
|
||||
* the reason each defeats it.
|
||||
*
|
||||
* Not an exemption from being carried — every model here is carried, and the
|
||||
* round-trip test proves it by seeding a row, exporting, emptying the account
|
||||
* and reading the row back out of the restore. It is an exemption from being
|
||||
* proven by reading source text, and it exists so the limit is written down in
|
||||
* one place instead of showing up as a green check that means nothing.
|
||||
*
|
||||
* A model earns a line here only when a same-file grep genuinely cannot tell
|
||||
* two tables apart. The guard beside this file checks that each one is covered
|
||||
* by the round trip, so naming a model here moves the burden of proof rather
|
||||
* than dropping it.
|
||||
*/
|
||||
export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
|
||||
IllnessSymptomLink:
|
||||
"Read out through `dayLogSymptomInclude`, a shared include constant in `src/lib/illness/dto.ts` that names `symptomLinks` with no query beside it. `symptomLinks` is also `CycleDayLog`'s field for `CycleSymptomLink`, so the name alone cannot say which table a line is about, and the parent that would disambiguate it — `IllnessDayLog` — is reached transitively through the episode's `dayLogs` include in another file. Deleting the illness branch from the restore route left the old check green on the strength of the cycle write; the round trip is what notices now.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Backed-up models that do NOT travel yet, each with what an account loses
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Every model the plan claims travels both ways, proven by making it travel.
|
||||
*
|
||||
* `src/__tests__/backup-plan-classification.test.ts` reads source text. That
|
||||
* catches a reader or a restore branch that was never written, and it is cheap
|
||||
* enough to run on every `pnpm test` — but it cannot tell a branch that RUNS
|
||||
* from one that merely exists. Verified rather than assumed: wrapping the
|
||||
* nutrient restore in `if (false && …)` left all twelve of its checks green.
|
||||
* Correct, green, and inert in production is the failure mode this repository
|
||||
* has already shipped once, so the claim needs an end-to-end answer as well.
|
||||
*
|
||||
* This is that answer, and it is deliberately the LONG way round:
|
||||
*
|
||||
* 1. Seed one row of every model in `TWO_ENDED_MODELS` for one account.
|
||||
* 2. Export through the real `buildFullBackupPayload`, disaster-recovery
|
||||
* purpose — the same builder both export routes and the weekly worker use.
|
||||
* 3. Delete the account. Not a hand-listed `deleteMany` sweep, which can only
|
||||
* empty the tables somebody remembered: `user.delete()` cascades, so what
|
||||
* survives is exactly what the schema says survives. A model that is
|
||||
* neither wiped nor restored would otherwise be counted as recovered on
|
||||
* the strength of the row that was never removed.
|
||||
* 4. Re-create the account under the same id and restore through the real
|
||||
* `POST /api/admin/backups/[id]/restore`.
|
||||
* 5. Count each model back. Zero means the account came back short.
|
||||
*
|
||||
* The registry below is keyed by `TwoEndedModel`, so a name added to the plan's
|
||||
* two-ended list without a row seeded and counted here does not compile.
|
||||
*
|
||||
* What this still does not prove: that a restored row carries the right VALUES.
|
||||
* It asks whether the rows came back, not whether they came back intact —
|
||||
* `admin-backups-canonical-roundtrip.test.ts` is where field-level fidelity is
|
||||
* asserted. A restore that wrote one row per model with every column defaulted
|
||||
* would satisfy this file and fail that one.
|
||||
*/
|
||||
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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "round-trip-owner";
|
||||
const AT = (iso: string) => new Date(iso);
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
/** Counts what belongs to `userId`, following the relation where there is no own column. */
|
||||
const COUNT_BACK: Record<
|
||||
TwoEndedModel,
|
||||
(prisma: PrismaClient, userId: string) => Promise<number>
|
||||
> = {
|
||||
Measurement: (p, userId) => p.measurement.count({ where: { userId } }),
|
||||
IntradayCumulativeProfile: (p, userId) =>
|
||||
p.intradayCumulativeProfile.count({ where: { userId } }),
|
||||
Medication: (p, userId) => p.medication.count({ where: { userId } }),
|
||||
MedicationSchedule: (p, userId) =>
|
||||
p.medicationSchedule.count({ where: { medication: { userId } } }),
|
||||
MedicationIntakeEvent: (p, userId) =>
|
||||
p.medicationIntakeEvent.count({ where: { userId } }),
|
||||
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
|
||||
MoodEntryTagLink: (p, userId) =>
|
||||
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
|
||||
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
|
||||
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
|
||||
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
|
||||
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
|
||||
FamilyHistoryEntry: (p, userId) =>
|
||||
p.familyHistoryEntry.count({ where: { userId } }),
|
||||
IllnessEpisode: (p, userId) => p.illnessEpisode.count({ where: { userId } }),
|
||||
IllnessDayLog: (p, userId) => p.illnessDayLog.count({ where: { userId } }),
|
||||
IllnessSymptomLink: (p, userId) =>
|
||||
p.illnessSymptomLink.count({ where: { dayLog: { userId } } }),
|
||||
UserHealthProfile: (p, userId) =>
|
||||
p.userHealthProfile.count({ where: { userId } }),
|
||||
HealthProfileFactRevision: (p, userId) =>
|
||||
p.healthProfileFactRevision.count({ where: { userId } }),
|
||||
CycleProfile: (p, userId) => p.cycleProfile.count({ where: { userId } }),
|
||||
MenstrualCycle: (p, userId) => p.menstrualCycle.count({ where: { userId } }),
|
||||
CycleDayLog: (p, userId) => p.cycleDayLog.count({ where: { userId } }),
|
||||
CycleSymptom: (p, userId) => p.cycleSymptom.count({ where: { userId } }),
|
||||
CycleSymptomLink: (p, userId) =>
|
||||
p.cycleSymptomLink.count({ where: { dayLog: { userId } } }),
|
||||
CustomMetric: (p, userId) => p.customMetric.count({ where: { userId } }),
|
||||
CustomMetricEntry: (p, userId) =>
|
||||
p.customMetricEntry.count({ where: { userId } }),
|
||||
CorrelationPattern: (p, userId) =>
|
||||
p.correlationPattern.count({ where: { userId } }),
|
||||
HealthScoreRecord: (p, userId) =>
|
||||
p.healthScoreRecord.count({ where: { userId } }),
|
||||
Workout: (p, userId) => p.workout.count({ where: { userId } }),
|
||||
NutrientIntakeDay: (p, userId) =>
|
||||
p.nutrientIntakeDay.count({ where: { userId } }),
|
||||
InboundDocument: (p, userId) =>
|
||||
p.inboundDocument.count({ where: { userId } }),
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of every two-ended model, for one account.
|
||||
*
|
||||
* Minimal on purpose: this file asks whether a row survives the round trip, and
|
||||
* a fat fixture only makes the answer slower to get. The mood factor and the
|
||||
* cycle symptom are the account's OWN custom rows rather than seeded catalogue
|
||||
* entries, because a catalogue row survives the account being deleted and would
|
||||
* make the link tables look restored when nothing restored them.
|
||||
*/
|
||||
async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "WEIGHT",
|
||||
value: 74.2,
|
||||
unit: "kg",
|
||||
measuredAt: AT("2026-07-01T07:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
await prisma.intradayCumulativeProfile.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-01",
|
||||
// One slot per hour — the restore refuses a profile of any other length,
|
||||
// because a short row lands in the table and is dropped on read.
|
||||
hourlyCumulative: Array.from({ length: 24 }, (_, hour) => hour * 350),
|
||||
dayTotal: 8050,
|
||||
sampleCount: 24,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Round-trip tablet",
|
||||
dose: "5 mg",
|
||||
schedules: {
|
||||
create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: AT("2026-07-01T08:00:00.000Z"),
|
||||
takenAt: AT("2026-07-01T08:04:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
// A RATED tag the account defined itself — the export carries only rated
|
||||
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
|
||||
const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({
|
||||
where: { userId: null },
|
||||
});
|
||||
const moodTag = await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
categoryId: moodCategory.id,
|
||||
key: "round_trip_factor",
|
||||
labelKey: "mood.tag.roundTripFactor",
|
||||
kind: "RATED",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
},
|
||||
});
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: AT("2026-07-01T20:00:00.000Z"),
|
||||
tagLinks: { create: { moodTagId: moodTag.id, rating: 4 } },
|
||||
},
|
||||
});
|
||||
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
||||
});
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
biomarkerId: biomarker.id,
|
||||
analyte: "Ferritin",
|
||||
value: 91,
|
||||
unit: "ng/mL",
|
||||
takenAt: AT("2026-06-30T09:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.allergy.create({
|
||||
data: { userId: OWNER_ID, substance: "Penicillin" },
|
||||
});
|
||||
await prisma.familyHistoryEntry.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
relationship: "MOTHER",
|
||||
condition: "Type 2 diabetes",
|
||||
},
|
||||
});
|
||||
|
||||
const episode = await prisma.illnessEpisode.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
onsetAt: AT("2026-06-20T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const illnessSymptom = await prisma.illnessSymptom.create({
|
||||
data: { key: "round_trip_cough", labelKey: "illness.symptom.roundTrip" },
|
||||
});
|
||||
await prisma.illnessDayLog.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
episodeId: episode.id,
|
||||
date: "2026-06-21",
|
||||
symptomLinks: { create: { symptomId: illnessSymptom.id, severity: 2 } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.userHealthProfile.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
aboutMeEncrypted: encryptToBytes("Runs three times a week"),
|
||||
},
|
||||
});
|
||||
await prisma.healthProfileFactRevision.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
kind: "SMOKING_STATUS",
|
||||
valueEncrypted: encryptToBytes("never"),
|
||||
validFrom: AT("2026-01-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.cycleProfile.create({
|
||||
data: { userId: OWNER_ID, cycleTrackingEnabled: true },
|
||||
});
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
data: { userId: OWNER_ID, startDate: "2026-06-01" },
|
||||
});
|
||||
const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
const cycleSymptom = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
categoryId: cycleCategory.id,
|
||||
key: "custom:round-trip-cramps",
|
||||
labelKey: "cycle.symptom.custom",
|
||||
labelEncrypted: encrypt("Round-trip cramps"),
|
||||
},
|
||||
});
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
cycleId: cycle.id,
|
||||
date: "2026-06-02",
|
||||
flow: "HEAVY",
|
||||
symptomLinks: { create: { symptomId: cycleSymptom.id } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.customMetric.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Grip strength",
|
||||
unit: "kg",
|
||||
entries: {
|
||||
create: {
|
||||
userId: OWNER_ID,
|
||||
value: 44,
|
||||
unit: "kg",
|
||||
measuredAt: AT("2026-07-01T18:00:00.000Z"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.correlationPattern.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
canonicalKey: `p1:${"a1".repeat(32)}`,
|
||||
family: "sleep",
|
||||
factorKey: "sleep_duration",
|
||||
outcomeKey: "weight",
|
||||
lagDays: 1,
|
||||
sampleSize: 30,
|
||||
effectSize: 0.42,
|
||||
pValue: 0.01,
|
||||
evidenceHash: "b2".repeat(32),
|
||||
lastComputedAt: AT("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.healthScoreRecord.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
dayKey: "2026-07-01",
|
||||
timezone: "Europe/Berlin",
|
||||
composite: 71,
|
||||
band: "green",
|
||||
scoreVersion: 1,
|
||||
composition: ["activity", "sleep"],
|
||||
pillarScores: { activity: 70, sleep: 72 },
|
||||
inputFingerprint: "c3".repeat(32),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workout.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
sportType: "RUNNING",
|
||||
startedAt: AT("2026-06-29T06:00:00.000Z"),
|
||||
endedAt: AT("2026-06-29T06:30:00.000Z"),
|
||||
durationSec: 1800,
|
||||
},
|
||||
});
|
||||
await prisma.nutrientIntakeDay.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
day: "2026-07-01",
|
||||
nutrient: "WATER",
|
||||
amount: 2100,
|
||||
unit: "ml",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
const documentBytes = encryptBytes(Buffer.from("round-trip document bytes"));
|
||||
const contentEncrypted = new Uint8Array(
|
||||
new ArrayBuffer(documentBytes.byteLength),
|
||||
);
|
||||
contentEncrypted.set(documentBytes);
|
||||
await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
kind: "LAB_RESULT",
|
||||
title: "June labs",
|
||||
filename: "june-labs.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: documentBytes.byteLength,
|
||||
contentEncrypted,
|
||||
contentCodec: "binary2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createOwner(prisma: PrismaClient) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "round-trip-owner",
|
||||
email: "round-trip-owner@example.test",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAdminSession(prisma: PrismaClient) {
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "round-trip-admin",
|
||||
email: "round-trip-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return admin;
|
||||
}
|
||||
|
||||
describe("every model the plan claims two-ended survives a real restore", () => {
|
||||
it("carries a seeded row of each model out of the account and back in", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
await seedEveryTwoEndedModel(prisma);
|
||||
|
||||
// The fixture has to be complete before the account is deleted, or a model
|
||||
// seeded with zero rows would come back with zero and read as restored.
|
||||
const seeded = await Promise.all(
|
||||
TWO_ENDED_MODELS.map(async (model) => [
|
||||
model,
|
||||
await COUNT_BACK[model](prisma, OWNER_ID),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
seeded.filter(([, count]) => count === 0).map(([model]) => model),
|
||||
"seeded no row for these, so the assertion after the restore would " +
|
||||
"compare nothing against nothing and pass",
|
||||
).toEqual([]);
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
|
||||
// Cascade rather than a hand-listed sweep — see the file comment.
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
const emptied = await Promise.all(
|
||||
TWO_ENDED_MODELS.map((model) => COUNT_BACK[model](prisma, OWNER_ID)),
|
||||
);
|
||||
expect(
|
||||
emptied.reduce((total, count) => total + count, 0),
|
||||
"the account still owns rows after being deleted, so the restore is " +
|
||||
"not what put them there",
|
||||
).toBe(0);
|
||||
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
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);
|
||||
expect(
|
||||
body.data.skipped.links,
|
||||
"a dropped link is a row that did not " +
|
||||
"come back, reported rather than silent",
|
||||
).toBe(0);
|
||||
|
||||
const restored = await Promise.all(
|
||||
TWO_ENDED_MODELS.map(async (model) => [
|
||||
model,
|
||||
await COUNT_BACK[model](prisma, OWNER_ID),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
restored.filter(([, count]) => count === 0).map(([model]) => model),
|
||||
"the plan claims these travel both ways; the account was restored " +
|
||||
"without them, which is a backup that reports success and hands back " +
|
||||
"less than it was given",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user