diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 393292ad3..f02ba8651 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -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(); - 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 { + const source = readFileSync(SCHEMA_PATH, "utf8"); + const targets = new Set(); + 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(BACKED_UP_MODELS); - const twoEnded = new Set(TWO_ENDED_MODELS); + const twoEnded = new Set(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) diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 837ded31d..13d9e01c9 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -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> = { + 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 diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts new file mode 100644 index 000000000..e8058e90c --- /dev/null +++ b/tests/integration/backup-round-trip.test.ts @@ -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 +> = { + 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 { + 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([]); + }); +});