Files
HealthLog/tests/integration/backup-round-trip.test.ts
T

3143 lines
115 KiB
TypeScript

/**
* 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 the count-back alone 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 for the file as a whole. A restore that wrote one row
* per model with every column defaulted would satisfy the counts and fail that
* one.
*
* So each section added since has brought a field-level assertion of its own
* for the column whose loss the count cannot see: the side effect's encrypted
* note beside its legacy plaintext one, the open pause era's null `resumedAt`,
* the Coach's permanent fence flag and its two bare-id references, the stock
* count that must not be recalculated from its ledger, the dose ramp's order,
* the reminder's snooze and skip cursors — and, at the bottom of this file, the
* document filing's PAIRING and the review decision on a staged fact. Each one
* is a thing a restore could get wrong while returning the right number of
* rows, which is the only test worth writing here.
*/
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 { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import {
decryptFactData,
decryptFactProvenance,
encryptFactData,
encryptFactProvenance,
} from "@/lib/documents/store";
import {
decryptWaveformFromBytes,
encryptWaveformToBytes,
} from "@/lib/withings/ecg-waveform-codec";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import { decryptNoteFromBytes } from "@/lib/labs/store";
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
import { POST } from "./restore-job-driver";
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);
const PHQ9_RESPONSES = JSON.stringify({
items: [2, 1, 2, 2, 1, 1, 2, 1, 2],
schema: 1,
});
const CONSENT_ARTEFACT = "JVBERi0xLjQKJWZpeHR1cmU=";
const INVENTORY_NOTE = "second pack, kept in the kitchen drawer";
const COACH_FACT = "does not tolerate ACE inhibitors";
const COACH_PLAN_CUE = "if the evening reading is over 140";
const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow";
const COACH_REMINDER_NOTE = "ask how the evening walks are going";
const COACH_SUMMARY = "earlier turns: weight trend and evening walks";
const METRIC_LEGACY_NOTE = "left hand, after the long ride";
const METRIC_SEALED_NOTE = "right hand, rested";
const COACH_USER_TURN = "my readings look higher this week, is that real?";
const COACH_ASSISTANT_TURN =
"the last seven mornings average 4 mmHg above the fortnight before";
/** The table of values the assistant turn read: ciphertext at rest. */
const COACH_RESULTS_JSON = JSON.stringify([
{
ref: "r1",
source: {
tool: "get_metric_series",
domain: "bp",
window: "last7days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.metricByPeriod",
title: "Blood pressure by day",
rowCount: 2,
chartKind: "line",
displayed: true,
columns: [
{
key: "period",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "sys",
kind: "number",
labelKey: "coach.result.column.systolic",
label: "Systolic",
unit: "mmHg",
},
],
rows: [
["2026-07-19", 131],
["2026-07-20", null],
],
truncated: false,
chart: { kind: "line", x: "period", series: ["sys"] },
},
]);
const DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)";
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
/** A short micro-volt strip. Long enough that a defaulted empty array shows. */
const ECG_SAMPLES = [-12, 4, 118, -33, 7, 2, -5, 61];
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 } }),
MedicationSideEffect: (p, userId) =>
p.medicationSideEffect.count({ where: { userId } }),
MedicationPauseEra: (p, userId) =>
p.medicationPauseEra.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationDoseChange: (p, userId) =>
p.medicationDoseChange.count({ where: { medication: { userId } } }),
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
MoodContext: (p, userId) => p.moodContext.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 } }),
Practitioner: (p, userId) => p.practitioner.count({ where: { userId } }),
Encounter: (p, userId) => p.encounter.count({ where: { userId } }),
EncounterDocumentLink: (p, userId) =>
p.encounterDocumentLink.count({ where: { userId } }),
EncounterLabLink: (p, userId) =>
p.encounterLabLink.count({ where: { userId } }),
EncounterConditionLink: (p, userId) =>
p.encounterConditionLink.count({ where: { userId } }),
VaccinationRecord: (p, userId) =>
p.vaccinationRecord.count({ where: { userId } }),
VaccinationDocumentLink: (p, userId) =>
p.vaccinationDocumentLink.count({ where: { userId } }),
MeasurementReminder: (p, userId) =>
p.measurementReminder.count({ where: { userId } }),
MeasurementReminderEvent: (p, userId) =>
p.measurementReminderEvent.count({ where: { userId } }),
CoachConversation: (p, userId) =>
p.coachConversation.count({ where: { userId } }),
// Neither the turn nor the attachment carries its own `userId` — both are
// reached through the thread that holds them.
CoachMessage: (p, userId) =>
p.coachMessage.count({ where: { conversation: { userId } } }),
CoachConversationDocument: (p, userId) =>
p.coachConversationDocument.count({ where: { conversation: { userId } } }),
CoachFact: (p, userId) => p.coachFact.count({ where: { userId } }),
CoachPlan: (p, userId) => p.coachPlan.count({ where: { userId } }),
CoachReminder: (p, userId) => p.coachReminder.count({ where: { userId } }),
MedicationInventoryItem: (p, userId) =>
p.medicationInventoryItem.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationInventoryEvent: (p, userId) =>
p.medicationInventoryEvent.count({ where: { medication: { userId } } }),
MoodTagCategory: (p, userId) =>
p.moodTagCategory.count({ where: { userId } }),
MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }),
ReminderPhaseConfig: (p, userId) =>
p.reminderPhaseConfig.count({ where: { medication: { userId } } }),
MentalHealthAssessment: (p, userId) =>
p.mentalHealthAssessment.count({ where: { userId } }),
ConsentReceipt: (p, userId) => p.consentReceipt.count({ where: { userId } }),
DocumentConditionLink: (p, userId) =>
p.documentConditionLink.count({ where: { userId } }),
ExtractedFact: (p, userId) => p.extractedFact.count({ where: { userId } }),
PersonalRecord: (p, userId) => p.personalRecord.count({ where: { userId } }),
UserAchievement: (p, userId) =>
p.userAchievement.count({ where: { userId } }),
EnvironmentContext: (p, userId) =>
p.environmentContext.count({ where: { userId } }),
EnvironmentTravelLocation: (p, userId) =>
p.environmentTravelLocation.count({ where: { userId } }),
EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationEfficacyTarget: (p, userId) =>
p.medicationEfficacyTarget.count({ where: { medication: { userId } } }),
MedicationScheduleRevision: (p, userId) =>
p.medicationScheduleRevision.count({ where: { medication: { userId } } }),
OnboardingRecord: (p, userId) =>
p.onboardingRecord.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> {
// Held rather than discarded: the personal best below points at this row
// through a column that is a real foreign key in the database, so the
// fixture needs the id to build the reference the restore has to resolve.
const weightMeasurement = 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",
},
});
// A DELETED reading, kept as a tombstone. It is here because the obvious way
// to make the weekly backup cheaper — stop exporting soft-deleted rows, which
// on a real record is four rows in five — is wrong, and nothing in the tree
// said so out loud. A tombstone inside the retention window is what tells a
// synced client the row is gone (`/api/sync/changes` reads exactly these) and
// what makes a re-sent Apple Health sample a no-op instead of a resurrection.
// Drop them from a disaster-recovery file and a restore quietly brings back
// every measurement the account ever deleted on the next device sync.
await prisma.measurement.create({
data: {
userId: OWNER_ID,
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: AT("2026-07-02T06:30:00.000Z"),
source: "APPLE_HEALTH",
externalId: "round-trip-deleted-sample",
syncVersion: 4,
deletedAt: AT("2026-07-20T09:15:00.000Z"),
},
});
// The EVENT row an ECG strip is filed against, and the strip itself. The
// pairing is the point: `EcgRecording.measurementId` is a real foreign key,
// so this is the reference whose remap has to survive the round trip rather
// than fail the transaction.
const rhythmEvent = await prisma.measurement.create({
data: {
userId: OWNER_ID,
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "event",
measuredAt: AT("2026-06-15T14:22:00.000Z"),
source: "WITHINGS",
rhythmClassification: "IRREGULAR",
},
});
await prisma.ecgRecording.create({
data: {
userId: OWNER_ID,
source: "WITHINGS",
externalRecordingId: "round-trip-signal-1",
recordedAt: AT("2026-06-15T14:22:00.000Z"),
waveformEncrypted: encryptWaveformToBytes(ECG_SAMPLES),
samplingFrequency: 300,
sampleCount: ECG_SAMPLES.length,
durationSeconds: ECG_SAMPLES.length / 300,
lead: "I",
averageHeartRate: 88,
rhythmClassification: "IRREGULAR",
measurementId: rhythmEvent.id,
},
});
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"),
},
});
// The one row in this fixture that is also checked field by field after the
// restore — see the assertion at the end of the test for why.
await prisma.medicationSideEffect.create({
data: {
userId: OWNER_ID,
medicationId: medication.id,
occurredAt: AT("2026-07-01T21:00:00.000Z"),
category: "GI",
entry: "NAUSEA",
severity: 3,
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
},
});
// Two eras, and the open one carries the weight. `resumedAt: null` means the
// drug is paused right now; a restore that closed it at restore time would
// still count one row back here and read as recovered. The field-level
// assertion at the end of the test is what holds the null.
// A two-step ramp. The ORDER is the content here: same drug, same unit,
// 5 mg then 10 mg. A restore that returned them reversed would still count
// two rows back and would describe the opposite clinical story.
// One open pack and one used up, plus the two ledger rows behind the open
// one. The count on the pack is deliberately NOT the sum of its events: 30
// in, 8 consumed, and the remaining count says 21 because the person threw
// one away without recording it. A restore that recomputed the count from
// the ledger would "fix" that to 22 and disagree with every low-stock
// reminder the account has already received.
// A PHQ-9 administration whose item 9 was answered above zero. The flag and
// the encrypted answers are seeded to DISAGREE with a naive recomputation:
// the flag says true, and a restore that decided to recompute it would have
// to decrypt the blob to do so, which is the handling this data is kept away
// from. The assertion below reads the flag and the ciphertext separately.
await prisma.mentalHealthAssessment.create({
data: {
userId: OWNER_ID,
instrument: "PHQ9",
locale: "de",
version: "standard",
responsesEncrypted: encryptToBytes(PHQ9_RESPONSES),
totalScore: 14,
severityBand: "moderate",
item9Flagged: true,
crisisShownAt: AT("2026-07-05T18:02:00.000Z"),
takenAt: AT("2026-07-05T18:00:00.000Z"),
tz: "Europe/Berlin",
source: "WEB",
},
});
// One active consent and one revoked. The revocation is the part a restore
// can quietly lose: dropping `revokedAt` turns a withdrawn agreement back
// into a standing one, and the latest-per-user lookup shortcuts on exactly
// that column.
await prisma.consentReceipt.createMany({
data: [
{
userId: OWNER_ID,
kind: "ai-processing",
artefact: CONSENT_ARTEFACT,
signedAt: AT("2026-06-01T09:00:00.000Z"),
},
{
userId: OWNER_ID,
kind: "research-sharing",
artefact: "eyJhbGciOiJub25lIn0.e30.",
signedAt: AT("2026-06-02T09:00:00.000Z"),
revokedAt: AT("2026-07-10T11:00:00.000Z"),
},
],
});
// Tuned away from every default, and one threshold in PERCENT rather than
// MINUTES: a restore that fell back to the schema defaults would return four
// plausible numbers and silently change when this drug turns orange.
await prisma.reminderPhaseConfig.create({
data: {
medicationId: medication.id,
greenValue: 45,
greenMode: "MINUTES",
yellowValue: 20,
yellowMode: "PERCENT",
orangeValue: 5,
orangeMode: "MINUTES",
redValue: 180,
redMode: "MINUTES",
},
});
const openPack = await prisma.medicationInventoryItem.create({
data: {
userId: OWNER_ID,
medicationId: medication.id,
state: "ACTIVE",
containerType: "BLISTER",
unitsTotal: "30",
unitsRemaining: "21",
firstUseAt: AT("2026-07-01T08:00:00.000Z"),
expiresAt: AT("2027-01-31T00:00:00.000Z"),
manufacturer: "Fixture Pharma",
doseStrength: "10 mg",
notesEncrypted: encryptToBytes(INVENTORY_NOTE),
},
});
await prisma.medicationInventoryItem.create({
data: {
userId: OWNER_ID,
medicationId: medication.id,
state: "USED_UP",
containerType: "BOTTLE",
unitsTotal: "60",
unitsRemaining: "0",
},
});
await prisma.medicationInventoryEvent.createMany({
data: [
{
medicationId: medication.id,
delta: 30,
reason: "purchased",
occurredAt: AT("2026-07-01T07:00:00.000Z"),
},
{
medicationId: medication.id,
delta: -8,
reason: "consumed",
occurredAt: AT("2026-07-09T07:00:00.000Z"),
},
],
});
await prisma.medicationDoseChange.createMany({
data: [
{
medicationId: medication.id,
effectiveFrom: AT("2026-04-01T08:00:00.000Z"),
doseValue: 5,
doseUnit: "mg",
noteEncrypted: encryptToBytes(DOSE_CHANGE_NOTE),
},
{
medicationId: medication.id,
effectiveFrom: AT("2026-06-01T08:00:00.000Z"),
doseValue: 10,
doseUnit: "mg",
noteEncrypted: null,
},
],
});
// Three archived eras, and the CHAIN through them is the content. The
// middle one was corrected, so it points at the third; the first and the
// third stand on their own. A restore that returned three rows with every
// link null satisfies any count and has destroyed the only thing the
// pointer says — every era consumer skips a superseded row, so the
// corrected middle era would come back live beside the correction that
// replaced it and the timeline would show the same window twice.
await prisma.medicationScheduleRevision.create({
data: {
medicationId: medication.id,
validFrom: AT("2026-01-01T00:00:00.000Z"),
validUntil: AT("2026-03-01T00:00:00.000Z"),
payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }],
source: "ARCHIVED",
},
});
const correctionEra = await prisma.medicationScheduleRevision.create({
data: {
medicationId: medication.id,
validFrom: AT("2026-03-01T00:00:00.000Z"),
validUntil: AT("2026-05-01T00:00:00.000Z"),
payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }],
source: "MANUAL",
},
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: medication.id,
validFrom: AT("2026-02-01T00:00:00.000Z"),
validUntil: AT("2026-03-01T00:00:00.000Z"),
payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }],
source: "ARCHIVED",
supersededByRevisionId: correctionEra.id,
},
});
await prisma.medicationPauseEra.createMany({
data: [
{
userId: OWNER_ID,
medicationId: medication.id,
pausedAt: AT("2026-05-02T08:00:00.000Z"),
resumedAt: AT("2026-05-20T08:00:00.000Z"),
},
{
userId: OWNER_ID,
medicationId: medication.id,
pausedAt: AT("2026-07-15T08:00:00.000Z"),
resumedAt: null,
},
],
});
// 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 },
});
// A category the account created itself, not a seeded one. It cascades away
// with the user, and `MoodTag.categoryId` is a real foreign key — so before
// the category travelled, this one row made the restore violate
// `mood_tags_category_id_fkey` and answer 500 with the account left empty.
const ownCategory = await prisma.moodTagCategory.create({
data: {
userId: OWNER_ID,
key: "round_trip_own_category",
labelKey: "mood.category.roundTripOwn",
sortOrder: 90,
},
});
const moodTag = await prisma.moodTag.create({
data: {
userId: OWNER_ID,
categoryId: ownCategory.id,
key: "round_trip_factor",
labelKey: "mood.tag.roundTripFactor",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
// A SEEDED tag the account chose to hide. The id differs per instance, so
// this is the row that proves the hidden set travels by key.
const seededTagToHide = await prisma.moodTag.findFirstOrThrow({
where: { userId: null },
});
await prisma.moodTagHidden.create({
data: { userId: OWNER_ID, moodTagId: seededTagToHide.id },
});
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 } },
context: {
create: {
userId: OWNER_ID,
workStatus: "regular",
contactCircles: JSON.stringify(["family"]),
leisureJoy: 6,
},
},
},
});
const biomarker = await prisma.biomarker.create({
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
});
const labResult = 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"),
},
});
// What the drug above is FOR, pinned to the analyte just created. The two
// rows are seeded at opposite ends of this fixture on purpose: the drug is
// restored hundreds of lines before the analyte, so this pairing is what
// proves the second pass runs late enough to resolve the name.
await prisma.medicationEfficacyTarget.create({
data: {
medicationId: medication.id,
biomarkerId: biomarker.id,
primary: true,
},
});
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"),
},
});
// A SECOND condition, and the one the document below is actually filed
// against. Two episodes is what makes the filing assertion mean something:
// with one, a restore that pointed every link at the first condition it
// found would return the right count and read as correct.
const filedEpisode = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Iron deficiency",
type: "CHRONIC",
onsetAt: AT("2026-03-01T00:00:00.000Z"),
// v1.39.2 — a condition's body site and side travel too.
bodySiteEncrypted: encryptToBytes("Stomach"),
laterality: "BOTH",
},
});
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",
// v1.39.3 — one reading whose note is still readable (a row the
// free-text backfill has not reached) and one sealed the current way.
// Both must come back sealed, and neither readable.
entries: {
create: [
{
userId: OWNER_ID,
value: 44,
unit: "kg",
measuredAt: AT("2026-07-01T18:00:00.000Z"),
note: METRIC_LEGACY_NOTE,
},
{
userId: OWNER_ID,
value: 45,
unit: "kg",
measuredAt: AT("2026-07-02T18:00:00.000Z"),
noteEncrypted: encryptToBytes(METRIC_SEALED_NOTE),
},
],
},
},
});
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.onboardingRecord.create({
data: {
userId: OWNER_ID,
needsJson: {
recordTarget: "me",
areas: ["blood-pressure", "sleep"],
medication: "yes",
sources: ["withings"],
visit: "within-a-month",
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
},
stepsJson: [
{ id: "who", status: "done" },
{ id: "areas", status: "done" },
{ id: "sources", status: "skipped" },
],
firstResultJson: {
task: "connect-source",
target: "withings",
completedAt: "2026-07-01T08:30:00.000Z",
},
// The once-only latch on the module derivation. Seeded non-null so the
// assertion after the restore can show it came back rather than
// defaulting — a null here would hand the next confirm permission to
// re-apply the questionnaire over every module decision made since.
modulesDerivedAt: AT("2026-07-01T08:00:00.000Z"),
completedAt: AT("2026-07-01T08: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);
const document = 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",
// Imported (#1038): the source key has to survive the trip, or a
// re-run of the importer after a restore stores the page twice.
sourceSystem: "PAPERLESS",
sourceId: "4711",
// Held back from automatic AI reading; a restore that dropped the
// marker would hand the page to the summary catch-up.
aiReadDeferred: true,
},
});
// The page filed under the SECOND condition, not the first. A restore that
// kept the count and lost the pairing would leave the vault sorted wrongly
// rather than visibly unsorted, which is harder to notice and worse.
await prisma.documentConditionLink.create({
data: {
userId: OWNER_ID,
documentId: document.id,
episodeId: filedEpisode.id,
createdAt: AT("2026-07-02T09:00:00.000Z"),
},
});
// One staged fact, already reviewed, approved and committed to the ferritin
// lab row above. Every column that records the decision is set AWAY from its
// schema default on purpose: `PENDING`, `needsReview: true` and two NULL
// commitment columns are what a restore that ignores them writes, and the
// assertion after the restore is what catches that. A fact handed back as
// PENDING is offered for review again, and approving it a second time writes
// a second lab result for a reading the account already has.
await prisma.extractedFact.create({
data: {
userId: OWNER_ID,
documentId: document.id,
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.94,
needsReview: false,
committedRecordId: labResult.id,
committedRecordType: "labResult",
dataEncrypted: encryptFactData({
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
effectiveDate: "2026-06-30",
}),
provenanceEncrypted: encryptFactProvenance({
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.94,
}),
},
});
// Two Coach threads, and the pair is the point. One is an ordinary health
// conversation; the other is FENCED — `documentScoped` true, grounded in the
// document seeded above. A restore that let the flag default to false would
// return both threads, satisfy every count, and quietly hand the tool loop
// back to the one conversation whose history may contain document-derived
// text. That is why the assertion below reads the flag per thread rather
// than counting rows.
await prisma.coachConversation.create({
data: {
userId: OWNER_ID,
title: "How is my blood pressure trending?",
documentScoped: false,
summaryEncrypted: encryptToBytes(COACH_SUMMARY),
summaryUpdatedAt: AT("2026-07-20T10:00:00.000Z"),
summaryTurnCount: 4,
messages: {
create: [
{
role: "user",
encryptedContent: encryptToBytes(COACH_USER_TURN),
createdAt: AT("2026-07-20T09:58:00.000Z"),
},
{
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
providerType: "anthropic",
model: "claude-opus-5",
tokensUsed: 812,
createdAt: AT("2026-07-20T09:59:00.000Z"),
},
],
},
},
});
await prisma.coachConversation.create({
data: {
userId: OWNER_ID,
// v1.39.3 — sealed the current way; the thread above keeps a readable
// legacy title, the shape of a row the backfill has not reached.
titleEncrypted: encryptToBytes("About my June labs"),
documentScoped: true,
messages: {
create: [
{
role: "user",
encryptedContent: encryptToBytes("What does the ferritin mean?"),
createdAt: AT("2026-07-21T08:00:00.000Z"),
},
],
},
attachments: {
create: [
{ documentId: document.id, addedAt: AT("2026-07-21T07:59:00.000Z") },
],
},
},
});
// What the Coach keeps between threads, seeded so both references are
// exercised: the fact and the plan point back at the thread they came out
// of, and the reminder points at the plan.
const healthThread = await prisma.coachConversation.findFirstOrThrow({
where: { userId: OWNER_ID, documentScoped: false },
});
await prisma.coachFact.create({
data: {
userId: OWNER_ID,
factEncrypted: encryptToBytes(COACH_FACT),
category: "constraint",
confidence: 80,
sourceConversationId: healthThread.id,
},
});
const plan = await prisma.coachPlan.create({
data: {
userId: OWNER_ID,
metric: "BLOOD_PRESSURE",
ifCueEncrypted: encryptToBytes(COACH_PLAN_CUE),
thenActionEncrypted: encryptToBytes(COACH_PLAN_ACTION),
targetEncrypted: encryptToBytes("under 130 by October"),
status: "active",
reviewDate: AT("2026-10-01T00:00:00.000Z"),
sourceConversationId: healthThread.id,
},
});
await prisma.coachReminder.create({
data: {
userId: OWNER_ID,
noteEncrypted: encryptToBytes(COACH_REMINDER_NOTE),
metric: "BLOOD_PRESSURE",
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: AT("2026-09-01T07:00:00.000Z"),
status: "active",
source: "extractor",
sourceConversationId: healthThread.id,
surfaceCount: 2,
},
});
// One visit, the practice it was at, and one link of each of the three
// kinds. The links reach the document, the lab result and the condition
// episode seeded above rather than fresh rows, because a link to something
// the restore did not put back is the exact case the restore reports as a
// skip — seeding it against a row that does come back is what makes a
// returning count mean the filing survived.
const practitioner = await prisma.practitioner.create({
data: {
userId: OWNER_ID,
name: "Round-trip practice",
specialty: "Internal medicine",
noteEncrypted: encryptToBytes("ring the upper bell"),
},
});
// One Vorsorge reminder with the v1.37.20 skip/snooze columns all set, and
// two completion-ledger rows — one honest satisfy, one honest skip. Fat like
// the vaccination fixture below and for the same reason: the count-back
// proves the rows returned, the column-by-column assertion after the restore
// proves they returned WHOLE. The encounter and the dose below point their
// `reminderId` here, because both references now remap against the restored
// reminders and this fixture is what proves they survive rather than drop.
const reminder = await prisma.measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Blutdruck messen",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
notifyHour: 8,
location: "Zuhause",
nextDueAt: AT("2026-07-08T06:00:00.000Z"),
lastSatisfiedAt: AT("2026-07-01T06:05:00.000Z"),
vaccinationAntigen: "tetanus",
snoozedUntil: AT("2026-07-08T06:00:00.000Z"),
lastSkippedAt: AT("2026-06-24T06:00:00.000Z"),
skipCount: 2,
},
});
await prisma.measurementReminderEvent.create({
data: {
userId: OWNER_ID,
reminderId: reminder.id,
kind: "SATISFIED",
occurredAt: AT("2026-07-01T06:05:00.000Z"),
onTime: true,
source: "manual",
},
});
await prisma.measurementReminderEvent.create({
data: {
userId: OWNER_ID,
reminderId: reminder.id,
kind: "SKIPPED",
occurredAt: AT("2026-06-24T06:00:00.000Z"),
onTime: false,
source: "skip",
},
});
const encounter = await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: AT("2026-06-30T08:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
practitionerId: practitioner.id,
reasonEncrypted: encryptToBytes("annual check"),
outcomeEncrypted: encryptToBytes(
"ferritin back in range, recheck in a year",
),
reminderId: reminder.id,
},
});
await prisma.encounterDocumentLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
documentId: document.id,
},
});
await prisma.encounterLabLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
labResultId: labResult.id,
},
});
await prisma.encounterConditionLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
episodeId: episode.id,
},
});
// One dose with every optional column filled, and the page it was
// transcribed from. Fat on purpose, against this file's own minimal-fixture
// rule: a count-back proves the row returned, and the assertion after the
// restore reads each column back so it also proves the row returned WHOLE.
// The practitioner, the encounter and the reminder are the ones seeded
// above, because all three are remapped on the way back and a reference to
// something the restore did not put back is a different case (covered by
// the skip test below). `reminderId` used to be deliberately absent here —
// it was the one reference that could never resolve — and is deliberately
// present since v1.37.20, because resolving is now the behaviour under test.
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: AT("2026-05-14T00:00:00.000Z"),
antigenSlug: "tdap",
vaccineName: "Tetanus, diphtheria and pertussis",
doseNumber: 3,
seriesDoses: 3,
lotNumber: "RT-4471",
site: "LEFT_ARM",
practitionerId: practitioner.id,
encounterId: encounter.id,
reminderId: reminder.id,
noteEncrypted: encryptToBytes("sore arm for a day, nothing else"),
},
});
await prisma.vaccinationDocumentLink.create({
data: {
userId: OWNER_ID,
vaccinationId: vaccination.id,
documentId: document.id,
},
});
// Three bests, chosen for what a count cannot see.
//
// The first points at the measurement it was found in. That column is a
// foreign key in the database while the Prisma schema declares no relation
// for it, so this row is what proves the pointer resolves against the
// measurements the restore wrote, and would prove the opposite loudly, by
// failing the constraint and taking the whole restore with it.
//
// The other two share a metric type AND an instant and differ only in the
// sport slot: the same 5 km and 10 km bests a runner has. A restore that
// dropped the slot would return two rows describing one record twice, and
// the faster time would be presented as the 10 km best.
await prisma.personalRecord.create({
data: {
userId: OWNER_ID,
metricType: "WEIGHT",
direction: "MIN",
value: 74.2,
unit: "kg",
achievedAt: AT("2026-07-01T07:00:00.000Z"),
sourceMeasurementId: weightMeasurement.id,
source: "MANUAL",
},
});
await prisma.personalRecord.createMany({
data: [
{
userId: OWNER_ID,
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_5km_time",
direction: "MIN",
value: 1512,
unit: "s",
achievedAt: AT("2026-05-18T09:20:00.000Z"),
source: "APPLE_HEALTH",
externalId: "workout-pr-5km",
},
{
userId: OWNER_ID,
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_10km_time",
direction: "MIN",
value: 3184,
unit: "s",
achievedAt: AT("2026-05-18T09:20:00.000Z"),
source: "APPLE_HEALTH",
externalId: "workout-pr-10km",
},
],
});
// Two badges, both earned long before this fixture runs. The second names a
// definition no catalogue in this build ships: a file can be older than the
// release reading it, and the restore must carry the row rather than judge
// it, exactly as it carries a retired antigen slug.
await prisma.userAchievement.createMany({
data: [
{
userId: OWNER_ID,
achievementId: "intake-total-10",
unlockedAt: AT("2026-02-21T10:15:00.000Z"),
},
{
userId: OWNER_ID,
achievementId: "retired-badge-from-an-older-release",
unlockedAt: AT("2025-11-05T08:00:00.000Z"),
},
],
});
// A fortnight in Barcelona, and one day of it recorded as a reading.
//
// The pair is the fixture. The home day and the trip day carry different
// coordinates and different weather, and the trip day's reading only makes
// sense next to the period that explains it: with the period gone, the next
// refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over
// it. The assertion after the restore reads the two back TOGETHER. The
// coordinates are at 1 decimal, the precision the app stores since v1.39.4;
// a restore rounds anything finer, so finer values would not round-trip.
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER_ID,
startDate: "2026-06-10",
endDate: "2026-06-20",
lat: 41.4,
lon: 2.2,
label: "Barcelona",
},
});
await prisma.environmentContext.createMany({
data: [
{
userId: OWNER_ID,
date: "2026-06-15",
lat: 41.4,
lon: 2.2,
locationLabel: "Barcelona",
source: "TRAVEL",
tempMin: 19.4,
tempMax: 28.1,
tempMean: 23.6,
apparentMean: 25.2,
sunshineSec: 39_600,
daylightSec: 52_800,
precipSum: 0,
pressureMean: 1016.4,
pressureDelta: 3.2,
humidityMean: 63,
cloudMean: 12,
weatherCode: 1,
fetchedAt: AT("2026-06-16T03:15:00.000Z"),
},
{
userId: OWNER_ID,
date: "2026-07-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Berlin",
source: "HOME",
tempMin: 13.1,
tempMax: 22.7,
tempMean: 17.9,
apparentMean: 17.1,
sunshineSec: 28_800,
daylightSec: 59_400,
precipSum: 4.6,
pressureMean: 1008.9,
pressureDelta: 7.8,
humidityMean: 74,
cloudMean: 68,
weatherCode: 61,
fetchedAt: AT("2026-07-02T03:15:00.000Z"),
},
],
});
}
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([]);
// Pinned so the two writers below describe the same instant and can be
// compared byte for byte; `exportedAt` otherwise defaults to `new Date()`.
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
exportedAt,
});
// The writer the weekly job actually uses never builds this payload: it
// pulls the unbounded tables through page by page and serialises each row
// straight into the cipher, because materialising them is what used to
// exhaust the heap. Two writers is two chances to drift, so the file it
// produces is pinned against the one above — every per-table assertion
// below then covers BOTH, since the row that gets restored comes from the
// streamed bytes.
let streamedJson = "";
await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
streamedJson += chunk;
},
{ purpose: "disaster-recovery", exportedAt },
);
expect(
streamedJson,
"the streaming writer and the materialising builder disagree; the " +
"weekly backup no longer contains what this test proves restorable",
).toBe(JSON.stringify(payload));
// The tombstone has to be IN the file. A disaster-recovery payload that
// carries only live rows is smaller and cheaper and wrong: the restore
// wipes the account first, so a deletion that is not in the file is a
// deletion the account no longer knows it made, and the next device sync
// re-inserts every one of those samples as fresh readings.
const exportedMeasurements = payload.measurements as Array<{
externalId: string | null;
deletedAt: string | null;
syncVersion: number;
}>;
expect(
exportedMeasurements.find(
(row) => row.externalId === "round-trip-deleted-sample",
),
"a soft-deleted reading is missing from the disaster-recovery payload",
).toMatchObject({
deletedAt: "2026-07-20T09:15:00.000Z",
syncVersion: 4,
});
// 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",
// The envelope the weekly worker actually writes: stream the JSON
// through gzip and the cipher without ever holding a whole copy. The
// three cases further down deliberately keep the plain `encrypt(json)`
// form, which is what every row written before any of this existed
// looks like — an operator's newest usable copy may well be one of
// those, so every arm has to reach the restore route.
data: await legacyStreamedBlobFrom(async (write) => {
await write(streamedJson);
}),
},
});
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([]);
const sideEffect = await prisma.medicationSideEffect.findFirstOrThrow({
where: { userId: OWNER_ID },
include: { medication: { select: { name: true, userId: true } } },
});
expect({
category: sideEffect.category,
entry: sideEffect.entry,
severity: sideEffect.severity,
occurredAt: sideEffect.occurredAt.toISOString(),
medication: sideEffect.medication.name,
medicationOwner: sideEffect.medication.userId,
note: readNote(sideEffect.notesEncrypted, sideEffect.notes),
}).toEqual({
category: "GI",
entry: "NAUSEA",
severity: 3,
occurredAt: "2026-07-01T21:00:00.000Z",
medication: "Round-trip tablet",
medicationOwner: OWNER_ID,
note: SIDE_EFFECT_NOTE,
});
// The note must be BACK IN THE COLUMN it came from, not carried as
// plaintext: a restore that writes a portable export's decrypted note into
// `notes` reads identically through `readNote` above and has quietly
// un-encrypted the account's free text.
expect(sideEffect.notes, "plaintext must not come back in the column").toBe(
null,
);
// Both pause eras, and the open one specifically. The count above only
// says two rows returned; a restore that closed the running pause at
// restore time would satisfy it exactly, and the account would come back
// saying a medication it is still off had been resumed — which the
// compliance recomputation then reads as doses missed on purpose-taken
// days. `null` is the assertion that matters here.
const eras = await prisma.medicationPauseEra.findMany({
where: { userId: OWNER_ID },
orderBy: { pausedAt: "asc" },
select: { pausedAt: true, resumedAt: true },
});
expect(
eras.map((e) => ({
pausedAt: e.pausedAt.toISOString(),
resumedAt: e.resumedAt ? e.resumedAt.toISOString() : null,
})),
"an open pause era must come back open",
).toEqual([
{
pausedAt: "2026-05-02T08:00:00.000Z",
resumedAt: "2026-05-20T08:00:00.000Z",
},
{ pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null },
]);
// The setup answers came back as answers, not as an empty questionnaire.
//
// Asserted column by column rather than by count: the row returning proves
// nothing about `modulesDerivedAt`, and that field is the once-only latch
// on the module derivation — a restore that let it default to null would
// hand the next confirm permission to re-apply the questionnaire over
// every module decision the account has taken since, silently.
const restoredOnboarding = await prisma.onboardingRecord.findUniqueOrThrow({
where: { userId: OWNER_ID },
});
expect({
needs: restoredOnboarding.needsJson,
steps: restoredOnboarding.stepsJson,
firstResult: restoredOnboarding.firstResultJson,
modulesDerivedAt: restoredOnboarding.modulesDerivedAt?.toISOString(),
completedAt: restoredOnboarding.completedAt?.toISOString(),
}).toEqual({
needs: {
recordTarget: "me",
areas: ["blood-pressure", "sleep"],
medication: "yes",
sources: ["withings"],
visit: "within-a-month",
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
},
// The stored blob is a status LOOKUP, and the reader answers with the
// full ordered nine — a step the fixture never mentioned comes back
// `pending` rather than missing, which is what lets the flow grow a
// screen without a migration.
steps: [
{ id: "who", status: "done" },
{ id: "areas", status: "done" },
{ id: "medication", status: "pending" },
{ id: "sources", status: "skipped" },
{ id: "visit", status: "pending" },
{ id: "units", status: "pending" },
{ id: "confirm", status: "pending" },
{ id: "first-result", status: "pending" },
{ id: "done", status: "pending" },
],
firstResult: {
task: "connect-source",
target: "withings",
completedAt: "2026-07-01T08:30:00.000Z",
},
modulesDerivedAt: "2026-07-01T08:00:00.000Z",
completedAt: "2026-07-01T08:00:00.000Z",
});
// The Coach came back able to speak, and the fence held.
//
// `documentScoped` is asserted per thread rather than in aggregate: a
// restore that wrote `false` everywhere would still return two threads,
// two counts and every turn, and the only visible difference would be a
// fenced conversation quietly holding a tool loop again.
// Resolved from the database rather than remembered from the fixture: the
// attachment has to point at the document the RESTORE put back, not at an
// id that happens to match one the seeding used.
const vaultDocument = await prisma.inboundDocument.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
sourceSystem: vaultDocument.sourceSystem,
sourceId: vaultDocument.sourceId,
aiReadDeferred: vaultDocument.aiReadDeferred,
}).toEqual({
sourceSystem: "PAPERLESS",
sourceId: "4711",
aiReadDeferred: true,
});
const metricEntries = await prisma.customMetricEntry.findMany({
where: { userId: OWNER_ID },
orderBy: { measuredAt: "asc" },
});
expect(
metricEntries.map((entry) => ({
note: readNote(entry.noteEncrypted, entry.note),
readable: entry.note,
})),
"both reading notes come back, sealed, with nothing readable",
).toEqual([
{ note: METRIC_LEGACY_NOTE, readable: null },
{ note: METRIC_SEALED_NOTE, readable: null },
]);
const threads = await prisma.coachConversation.findMany({
where: { userId: OWNER_ID },
orderBy: { createdAt: "asc" },
include: {
messages: { orderBy: { createdAt: "asc" } },
attachments: true,
},
});
expect(
threads.map((thread) => ({
title: readNote(thread.titleEncrypted, thread.title),
// A restore seals every title and never writes the readable column.
readableTitle: thread.title,
documentScoped: thread.documentScoped,
summary: thread.summaryEncrypted
? decryptFromBytes(thread.summaryEncrypted)
: null,
summaryTurnCount: thread.summaryTurnCount,
turns: thread.messages.map((message) => ({
role: message.role,
content: decryptFromBytes(message.encryptedContent),
model: message.model,
tokensUsed: message.tokensUsed,
results: message.resultsEncrypted
? decryptFromBytes(message.resultsEncrypted)
: null,
})),
attachments: thread.attachments.map((a) => a.documentId),
})),
"the transcript, the fence and the provenance all come back",
).toEqual([
{
title: "How is my blood pressure trending?",
readableTitle: null,
documentScoped: false,
summary: COACH_SUMMARY,
summaryTurnCount: 4,
turns: [
{
role: "user",
content: COACH_USER_TURN,
model: null,
tokensUsed: null,
results: null,
},
{
role: "assistant",
content: COACH_ASSISTANT_TURN,
model: "claude-opus-5",
tokensUsed: 812,
results: COACH_RESULTS_JSON,
},
],
attachments: [],
},
{
title: "About my June labs",
readableTitle: null,
documentScoped: true,
summary: null,
summaryTurnCount: 0,
turns: [
{
role: "user",
content: "What does the ferritin mean?",
model: null,
tokensUsed: null,
results: null,
},
],
attachments: [vaultDocument.id],
},
]);
// The Coach's memory, and both of its references still pointing at
// something. Neither is a foreign key, so a restore that wrote them back
// as null would break nothing, satisfy every count, and leave the account
// with a Coach that remembers a fact and no longer knows where it came
// from.
const restoredThread = await prisma.coachConversation.findFirstOrThrow({
where: { userId: OWNER_ID, documentScoped: false },
});
const restoredPlan = await prisma.coachPlan.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const fact = await prisma.coachFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
fact: decryptFromBytes(fact.factEncrypted),
category: fact.category,
confidence: fact.confidence,
source: fact.sourceConversationId,
deletedAt: fact.deletedAt,
}).toEqual({
fact: COACH_FACT,
category: "constraint",
confidence: 80,
source: restoredThread.id,
deletedAt: null,
});
expect({
metric: restoredPlan.metric,
ifCue: decryptFromBytes(restoredPlan.ifCueEncrypted),
thenAction: decryptFromBytes(restoredPlan.thenActionEncrypted),
target: restoredPlan.targetEncrypted
? decryptFromBytes(restoredPlan.targetEncrypted)
: null,
status: restoredPlan.status,
source: restoredPlan.sourceConversationId,
}).toEqual({
metric: "BLOOD_PRESSURE",
ifCue: COACH_PLAN_CUE,
thenAction: COACH_PLAN_ACTION,
target: "under 130 by October",
status: "active",
source: restoredThread.id,
});
const reminder = await prisma.coachReminder.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(
{
note: decryptFromBytes(reminder.noteEncrypted),
relatedPlanId: reminder.relatedPlanId,
source: reminder.sourceConversationId,
surfaceCount: reminder.surfaceCount,
status: reminder.status,
},
"the reminder still knows which plan it belongs to",
).toEqual({
note: COACH_REMINDER_NOTE,
relatedPlanId: restoredPlan.id,
source: restoredThread.id,
surfaceCount: 2,
status: "active",
});
// The screener came back readable, and the flag came back as recorded.
const screener = await prisma.mentalHealthAssessment.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
instrument: screener.instrument,
totalScore: screener.totalScore,
severityBand: screener.severityBand,
item9Flagged: screener.item9Flagged,
crisisShown: screener.crisisShownAt?.toISOString() ?? null,
responses: decryptFromBytes(screener.responsesEncrypted),
}).toEqual({
instrument: "PHQ9",
totalScore: 14,
severityBand: "moderate",
item9Flagged: true,
crisisShown: "2026-07-05T18:02:00.000Z",
responses: PHQ9_RESPONSES,
});
// Both consents, and the revocation still standing. A restore that lost
// `revokedAt` would return the same two rows and turn a withdrawn
// agreement back into an active one.
const consents = await prisma.consentReceipt.findMany({
where: { userId: OWNER_ID },
orderBy: { signedAt: "asc" },
});
expect(
consents.map((c) => ({
kind: c.kind,
artefact: c.artefact,
revoked: c.revokedAt?.toISOString() ?? null,
})),
"a revoked consent must not come back as an active one",
).toEqual([
{ kind: "ai-processing", artefact: CONSENT_ARTEFACT, revoked: null },
{
kind: "research-sharing",
artefact: "eyJhbGciOiJub25lIn0.e30.",
revoked: "2026-07-10T11:00:00.000Z",
},
]);
// The account's own grouping came back, and the hidden tag is hidden
// again — resolved by key, so it points at whatever id this instance uses.
const ownCategories = await prisma.moodTagCategory.findMany({
where: { userId: OWNER_ID },
});
expect(
ownCategories.map((c) => ({ key: c.key, labelKey: c.labelKey })),
).toEqual([
{
key: "round_trip_own_category",
labelKey: "mood.category.roundTripOwn",
},
]);
const restoredTag = await prisma.moodTag.findFirstOrThrow({
where: { key: "round_trip_factor" },
});
expect(
restoredTag.categoryId,
"the custom tag is grouped under the account's own category",
).toBe(ownCategories[0].id);
const hidden = await prisma.moodTagHidden.findMany({
where: { userId: OWNER_ID },
include: { moodTag: { select: { key: true, userId: true } } },
});
// Exactly one, and it points at a SEEDED tag — which is only possible if
// the key was resolved against this instance's catalogue rather than an id
// carried in the file.
expect(hidden).toHaveLength(1);
expect(hidden[0].moodTag.userId).toBeNull();
// Every threshold as tuned, including the one that is not in minutes.
const phase = await prisma.reminderPhaseConfig.findFirstOrThrow({
where: { medication: { userId: OWNER_ID } },
});
expect({
green: [phase.greenValue, phase.greenMode],
yellow: [phase.yellowValue, phase.yellowMode],
orange: [phase.orangeValue, phase.orangeMode],
red: [phase.redValue, phase.redMode],
}).toEqual({
green: [45, "MINUTES"],
yellow: [20, "PERCENT"],
orange: [5, "MINUTES"],
red: [180, "MINUTES"],
});
// The shelf, and the count that must not be recomputed.
const packs = await prisma.medicationInventoryItem.findMany({
where: { userId: OWNER_ID },
orderBy: { createdAt: "asc" },
});
expect(
packs.map((pack) => ({
state: pack.state,
containerType: pack.containerType,
total: pack.unitsTotal.toString(),
remaining: pack.unitsRemaining.toString(),
manufacturer: pack.manufacturer,
note: readNote(pack.notesEncrypted, pack.notes),
plaintextColumn: pack.notes,
})),
"the remaining count comes back as recorded, not as recalculated",
).toEqual([
{
state: "ACTIVE",
containerType: "BLISTER",
total: "30",
remaining: "21",
manufacturer: "Fixture Pharma",
note: INVENTORY_NOTE,
plaintextColumn: null,
},
{
state: "USED_UP",
containerType: "BOTTLE",
total: "60",
remaining: "0",
manufacturer: null,
note: null,
plaintextColumn: null,
},
]);
const stockLedger = await prisma.medicationInventoryEvent.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { occurredAt: "asc" },
});
expect(
stockLedger.map((e) => ({ delta: e.delta, reason: e.reason })),
).toEqual([
{ delta: 30, reason: "purchased" },
{ delta: -8, reason: "consumed" },
]);
// The ramp, in order, with the note back in its column. Counting says two
// rows returned; only this says they came back as the same ramp, and that
// the encrypted note did not land in the plaintext column on the way.
const doses = await prisma.medicationDoseChange.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { effectiveFrom: "asc" },
});
expect(
doses.map((d) => ({
effectiveFrom: d.effectiveFrom.toISOString(),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
note: readNote(d.noteEncrypted, d.note),
plaintextColumn: d.note,
})),
"the titration must come back as the same ramp, in order",
).toEqual([
{
effectiveFrom: "2026-04-01T08:00:00.000Z",
doseValue: 5,
doseUnit: "mg",
note: DOSE_CHANGE_NOTE,
plaintextColumn: null,
},
{
effectiveFrom: "2026-06-01T08:00:00.000Z",
doseValue: 10,
doseUnit: "mg",
note: null,
plaintextColumn: null,
},
]);
// The dose, read back column by column. The count above says a row
// returned; a restore that wrote one row with every optional column
// defaulted would satisfy it and hand back an Impfpass line with no
// antigen, no batch code and no arm. The two remapped references are
// asserted as ids that exist rather than as the ids the file carried,
// because that is the property the remap owes.
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
where: { userId: OWNER_ID },
include: {
practitioner: { select: { name: true } },
encounter: { select: { occurredAt: true } },
reminder: { select: { label: true } },
documentLinks: { select: { documentId: true } },
},
});
expect({
occurredAt: dose.occurredAt.toISOString(),
antigenSlug: dose.antigenSlug,
vaccineName: dose.vaccineName,
doseNumber: dose.doseNumber,
seriesDoses: dose.seriesDoses,
lotNumber: dose.lotNumber,
site: dose.site,
practitioner: dose.practitioner?.name ?? null,
encounterAt: dose.encounter?.occurredAt.toISOString() ?? null,
// The booster reference, resolved through the relation: non-null proves
// the id survived the round trip instead of dropping to NULL the way it
// had to before the reminders travelled.
reminder: dose.reminder?.label ?? null,
links: dose.documentLinks.length,
note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null,
}).toEqual({
occurredAt: "2026-05-14T00:00:00.000Z",
antigenSlug: "tdap",
vaccineName: "Tetanus, diphtheria and pertussis",
doseNumber: 3,
seriesDoses: 3,
lotNumber: "RT-4471",
site: "LEFT_ARM",
practitioner: "Round-trip practice",
encounterAt: "2026-06-30T08:00:00.000Z",
reminder: "Blutdruck messen",
links: 1,
note: "sore arm for a day, nothing else",
});
// The reminder, read back column by column — including the three
// v1.37.20 columns (snooze cursor, last skip, skip counter), because a
// restore that defaulted them would hand back a cadence that looks never
// snoozed and never skipped, which is a history the account does not have.
const restoredReminder = await prisma.measurementReminder.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
label: restoredReminder.label,
measurementType: restoredReminder.measurementType,
intervalDays: restoredReminder.intervalDays,
rrule: restoredReminder.rrule,
anchorDate: restoredReminder.anchorDate,
endsOn: restoredReminder.endsOn,
origin: restoredReminder.origin,
notifyHour: restoredReminder.notifyHour,
location: restoredReminder.location,
nextDueAt: restoredReminder.nextDueAt?.toISOString() ?? null,
lastSatisfiedAt: restoredReminder.lastSatisfiedAt?.toISOString() ?? null,
enabled: restoredReminder.enabled,
vaccinationAntigen: restoredReminder.vaccinationAntigen,
snoozedUntil: restoredReminder.snoozedUntil?.toISOString() ?? null,
lastSkippedAt: restoredReminder.lastSkippedAt?.toISOString() ?? null,
skipCount: restoredReminder.skipCount,
deletedAt: restoredReminder.deletedAt,
}).toEqual({
label: "Blutdruck messen",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 8,
location: "Zuhause",
nextDueAt: "2026-07-08T06:00:00.000Z",
lastSatisfiedAt: "2026-07-01T06:05:00.000Z",
enabled: true,
vaccinationAntigen: "tetanus",
snoozedUntil: "2026-07-08T06:00:00.000Z",
lastSkippedAt: "2026-06-24T06:00:00.000Z",
skipCount: 2,
deletedAt: null,
});
// Both ledger rows, whole: the satisfy and the skip, with the resolved
// `onTime` verdicts. A restore cannot re-derive `onTime` — the pre-event
// `nextDueAt` is gone — so a defaulted value here would be an invented
// punctuality record.
const ledger = await prisma.measurementReminderEvent.findMany({
where: { userId: OWNER_ID },
orderBy: { occurredAt: "asc" },
});
expect(
ledger.map((event) => ({
reminderId: event.reminderId,
kind: event.kind,
occurredAt: event.occurredAt.toISOString(),
onTime: event.onTime,
source: event.source,
})),
).toEqual([
{
reminderId: restoredReminder.id,
kind: "SKIPPED",
occurredAt: "2026-06-24T06:00:00.000Z",
onTime: false,
source: "skip",
},
{
reminderId: restoredReminder.id,
kind: "SATISFIED",
occurredAt: "2026-07-01T06:05:00.000Z",
onTime: true,
source: "manual",
},
]);
// The vault came back SORTED, not merely populated.
//
// The count says one filing returned. It cannot say which page was filed
// under which condition, and the account has two conditions — so a restore
// that paired the document with the first episode it found would satisfy
// every count above and hand back a lab report filed under a head cold.
// The pair is resolved through the relations rather than compared against
// the ids the fixture used, because both ends were re-created by the
// restore and it is the pairing that has to survive, not the identifiers.
const filing = await prisma.documentConditionLink.findFirstOrThrow({
where: { userId: OWNER_ID },
include: {
document: { select: { title: true } },
episode: { select: { label: true } },
},
});
expect(
{
document: filing.document.title,
condition: filing.episode.label,
createdAt: filing.createdAt.toISOString(),
},
"the page must come back filed under the condition it was filed under",
).toEqual({
document: "June labs",
condition: "Iron deficiency",
createdAt: "2026-07-02T09:00:00.000Z",
});
// The condition's body site and side (v1.39.2). A restore that dropped
// either would still return the right number of episodes; the site must
// come back as ciphertext that reads as what was typed, and the side as
// it was.
const sitedEpisode = await prisma.illnessEpisode.findFirstOrThrow({
where: { userId: OWNER_ID, label: "Iron deficiency" },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(
{
bodySite: sitedEpisode.bodySiteEncrypted
? decryptFromBytes(sitedEpisode.bodySiteEncrypted)
: null,
laterality: sitedEpisode.laterality,
},
"a condition's body site and side must survive the round trip",
).toEqual({ bodySite: "Stomach", laterality: "BOTH" });
// The staged fact, and the decision on it.
//
// This is the assertion the count cannot make. `status`, `needsReview`,
// `committedRecordId` and `committedRecordType` all default to "nobody has
// looked at this yet", so a restore that wrote the row and ignored them
// returns exactly one fact, exactly as the plan promises, and hands the
// account a reviewed and committed reading back in its review queue. The
// confirm endpoint acts only on a PENDING fact and commits it through the
// normal create, so approving it a second time writes a SECOND ferritin
// result. The commitment is checked against the lab row the restore itself
// wrote, because a pointer that survives as a string but no longer names a
// live row is the same loss wearing a value.
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const stagedFact = await prisma.extractedFact.findFirstOrThrow({
where: { userId: OWNER_ID },
include: { document: { select: { title: true } } },
});
expect(
{
document: stagedFact.document.title,
factType: stagedFact.factType,
status: stagedFact.status,
confidence: stagedFact.confidence,
needsReview: stagedFact.needsReview,
committedRecordId: stagedFact.committedRecordId,
committedRecordType: stagedFact.committedRecordType,
data: decryptFactData(stagedFact.dataEncrypted),
provenance: decryptFactProvenance(stagedFact.provenanceEncrypted),
},
"a reviewed and committed fact must not come back up for review",
).toEqual({
document: "June labs",
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.94,
needsReview: false,
committedRecordId: restoredLab.id,
committedRecordType: "labResult",
data: {
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
effectiveDate: "2026-06-30",
},
provenance: {
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.94,
},
});
// The archived eras, and the CHAIN through them.
//
// Counting says three eras returned, and three eras with every link null
// would satisfy it exactly while having thrown away the only thing the
// pointer states. That is not a cosmetic loss: every era consumer skips a
// superseded row, so a corrected era whose link came back null goes live
// again beside the correction that replaced it, and the account gets two
// overlapping plans for one window with its past compliance minted against
// whichever the query happened to read.
//
// Asserted as the shape of the chain rather than as ids: the ids are fresh
// on a portable restore, and what the two-pass write owes is that era two
// still points at era three and that nothing else points anywhere.
const scheduleEras = await prisma.medicationScheduleRevision.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: [{ validFrom: "asc" }, { createdAt: "asc" }, { id: "asc" }],
});
const eraPositionById = new Map(
scheduleEras.map((era, index) => [era.id, index]),
);
expect(
scheduleEras.map((era) => ({
validFrom: era.validFrom.toISOString(),
validUntil: era.validUntil.toISOString(),
source: era.source,
payload: era.payload,
supersedes: era.supersededByRevisionId
? (eraPositionById.get(era.supersededByRevisionId) ?? "dangling")
: null,
})),
"the corrected era must come back still pointing at its correction",
).toEqual([
{
validFrom: "2026-01-01T00:00:00.000Z",
validUntil: "2026-03-01T00:00:00.000Z",
source: "ARCHIVED",
payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }],
supersedes: null,
},
{
validFrom: "2026-02-01T00:00:00.000Z",
validUntil: "2026-03-01T00:00:00.000Z",
source: "ARCHIVED",
payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }],
supersedes: 2,
},
{
validFrom: "2026-03-01T00:00:00.000Z",
validUntil: "2026-05-01T00:00:00.000Z",
source: "MANUAL",
payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }],
supersedes: null,
},
]);
// What the pointer actually buys, stated as the thing the app reads: the
// timeline query drops superseded rows, so exactly two eras are live.
expect(
await prisma.medicationScheduleRevision.count({
where: {
medication: { userId: OWNER_ID },
supersededByRevisionId: null,
},
}),
"a lost pointer puts a corrected era back on the timeline",
).toBe(2);
// The pinned target, and the analyte it points at.
//
// Counting says one override returned. It cannot say the override still
// MEANS anything: a restore that wrote the row with `biomarkerId` nulled
// returns a drug with no statement of what it was for, which is precisely
// what the register said the alternative cost, and nothing anywhere would
// complain — the resolver silently falls back to deriving a target and the
// page still renders.
//
// Both ends are resolved from the DATABASE rather than remembered from the
// fixture. The property the second pass owes is that the target points at
// the biomarker THIS restore wrote, which an id carried over from the
// seeding would not distinguish from a restore that copied a stale value.
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const restoredMedication = await prisma.medication.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const target = await prisma.medicationEfficacyTarget.findFirstOrThrow({
where: { medication: { userId: OWNER_ID } },
include: { biomarker: { select: { name: true, userId: true } } },
});
expect({
medicationId: target.medicationId,
biomarkerId: target.biomarkerId,
biomarkerName: target.biomarker?.name ?? null,
biomarkerOwner: target.biomarker?.userId ?? null,
measurementType: target.measurementType,
primary: target.primary,
}).toEqual({
medicationId: restoredMedication.id,
biomarkerId: restoredBiomarker.id,
biomarkerName: "Ferritin",
biomarkerOwner: OWNER_ID,
measurementType: null,
primary: true,
});
// The strip, read back column by column.
//
// Counting proves a recording returned. It cannot tell that recording from
// one whose trace is an empty array, whose verdict defaulted to NULL and
// whose instant is the restore time — and each of those three is the whole
// value of the row on its own. A strip with no rhythm classification is a
// squiggle nobody has read; a strip with no `recordedAt` belongs to no day
// and cannot be put beside the event it explains; a strip with no samples
// is a claim that an ECG exists which nothing can draw.
//
// `measurementId` is resolved from the DATABASE rather than remembered
// from the fixture, because the property the remap owes is that the strip
// points at the event row THIS restore wrote.
const restoredEvent = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, type: "IRREGULAR_RHYTHM_NOTIFICATION" },
});
const strip = await prisma.ecgRecording.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
source: strip.source,
externalRecordingId: strip.externalRecordingId,
recordedAt: strip.recordedAt.toISOString(),
samplingFrequency: strip.samplingFrequency,
sampleCount: strip.sampleCount,
durationSeconds: strip.durationSeconds,
lead: strip.lead,
averageHeartRate: strip.averageHeartRate,
rhythmClassification: strip.rhythmClassification,
measurementId: strip.measurementId,
waveform: decryptWaveformFromBytes(strip.waveformEncrypted),
}).toEqual({
source: "WITHINGS",
externalRecordingId: "round-trip-signal-1",
recordedAt: "2026-06-15T14:22:00.000Z",
samplingFrequency: 300,
sampleCount: ECG_SAMPLES.length,
durationSeconds: ECG_SAMPLES.length / 300,
lead: "I",
averageHeartRate: 88,
rhythmClassification: "IRREGULAR",
measurementId: restoredEvent.id,
waveform: ECG_SAMPLES,
});
// The appointment's reminder reference survives too — same remap, other
// referrer.
const restoredEncounter = await prisma.encounter.findFirstOrThrow({
where: { userId: OWNER_ID },
select: { reminderId: true },
});
expect(
restoredEncounter.reminderId,
"the encounter's reminder reference must survive now that the reminder travels",
).toBe(restoredReminder.id);
// The bests, read back column by column.
//
// Three rows came back, which the count above already said. What it could
// not say is that they still describe three different records: the two
// running bests share a metric type and an instant, so the slot is the
// only thing separating "best 5 km" from "best 10 km", and `direction` is
// the only thing that stops a best time being read as a worst one. The
// measurement pointer is asserted against the row the RESTORE wrote rather
// than against the id the fixture used, because resolving to something
// that exists is the property the reference owes.
const restoredTombstone = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, externalId: "round-trip-deleted-sample" },
});
expect(
{
deletedAt: restoredTombstone.deletedAt?.toISOString() ?? null,
syncVersion: restoredTombstone.syncVersion,
},
"the deleted reading came back, but not as a deletion — a tombstone " +
"restored without its `deletedAt` is a resurrected measurement",
).toEqual({ deletedAt: "2026-07-20T09:15:00.000Z", syncVersion: 4 });
const restoredMeasurement = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, type: "WEIGHT" },
});
const bests = await prisma.personalRecord.findMany({
where: { userId: OWNER_ID },
orderBy: [{ achievedAt: "asc" }, { metricSlot: "asc" }],
});
expect(
bests.map((best) => ({
metricType: best.metricType,
metricSlot: best.metricSlot,
direction: best.direction,
value: best.value,
unit: best.unit,
achievedAt: best.achievedAt.toISOString(),
sourceMeasurementId: best.sourceMeasurementId,
source: best.source,
externalId: best.externalId,
})),
"each best must come back as its own record, with the direction and the sport slot that make it one",
).toEqual([
{
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_10km_time",
direction: "MIN",
value: 3184,
unit: "s",
achievedAt: "2026-05-18T09:20:00.000Z",
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: "workout-pr-10km",
},
{
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_5km_time",
direction: "MIN",
value: 1512,
unit: "s",
achievedAt: "2026-05-18T09:20:00.000Z",
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: "workout-pr-5km",
},
{
metricType: "WEIGHT",
metricSlot: null,
direction: "MIN",
value: 74.2,
unit: "kg",
achievedAt: "2026-07-01T07:00:00.000Z",
// Not `null`: the pointer resolved against a measurement this restore
// actually wrote. A restore that nulled it would report the drop, and
// one that wrote it before the measurements existed would fail the
// foreign key rather than reach this line.
sourceMeasurementId: restoredMeasurement.id,
source: "MANUAL",
externalId: null,
},
]);
// The badges, and the only field on them that matters.
//
// Both rows would come back from a restore that stamped `unlockedAt` with
// the moment of the restore, and every count in this file would still be
// green, while the account was handed a wall of badges it had apparently
// earned all at once, today. Several of these dates cannot be re-derived
// at all: the evaluator prefers a persisted date, and the counters behind
// the login and Easter-egg badges are not in any backup.
const badges = await prisma.userAchievement.findMany({
where: { userId: OWNER_ID },
orderBy: { unlockedAt: "asc" },
});
expect(
badges.map((badge) => ({
achievementId: badge.achievementId,
unlockedAt: badge.unlockedAt.toISOString(),
})),
"a badge comes back with the day it was earned, and with the id it was earned under",
).toEqual([
{
// A definition this build does not ship, kept verbatim rather than
// judged against the catalogue, the same answer the restore gives a
// retired antigen slug.
achievementId: "retired-badge-from-an-older-release",
unlockedAt: "2025-11-05T08:00:00.000Z",
},
{
achievementId: "intake-total-10",
unlockedAt: "2026-02-21T10:15:00.000Z",
},
]);
// The environmental history, read back as a PAIR.
//
// Counting says two readings and one location period returned. It cannot
// say that the trip day still knows it was a trip day, which is the whole
// content of these rows: the coordinates, the label and `source` are what
// separate a fortnight in Barcelona from a fortnight at home, and the
// period is what keeps the next refresh from re-resolving the day and
// upserting Berlin's weather over it.
const readings = await prisma.environmentContext.findMany({
where: { userId: OWNER_ID },
orderBy: { date: "asc" },
});
expect(
readings.map((reading) => ({
date: reading.date,
lat: reading.lat,
lon: reading.lon,
locationLabel: reading.locationLabel,
source: reading.source,
tempMean: reading.tempMean,
pressureDelta: reading.pressureDelta,
daylightSec: reading.daylightSec,
weatherCode: reading.weatherCode,
// Verbatim, not re-stamped: this says when the feed was read, and a
// restore that wrote "now" would claim a two-year-old provisional
// reading had just been confirmed.
fetchedAt: reading.fetchedAt.toISOString(),
})),
"each day comes back at the place it was actually read for",
).toEqual([
{
date: "2026-06-15",
lat: 41.4,
lon: 2.2,
locationLabel: "Barcelona",
source: "TRAVEL",
tempMean: 23.6,
pressureDelta: 3.2,
daylightSec: 52_800,
weatherCode: 1,
fetchedAt: "2026-06-16T03:15:00.000Z",
},
{
date: "2026-07-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Berlin",
source: "HOME",
tempMean: 17.9,
pressureDelta: 7.8,
daylightSec: 59_400,
weatherCode: 61,
fetchedAt: "2026-07-02T03:15:00.000Z",
},
]);
// And the join between them, computed from what the restore wrote rather
// than from the fixture: the trip day has to fall inside the restored
// period and carry its coordinates. Both halves can be individually
// non-empty and still disagree: a period whose bounds shifted by a day no
// longer covers the reading it explains, and the reading goes back to
// being weather from nowhere.
const trip = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
expect(
{
startDate: trip.startDate,
endDate: trip.endDate,
label: trip.label,
covers:
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
sameLat: trip.lat === travelDay.lat,
sameLon: trip.lon === travelDay.lon,
},
"the period must still explain the reading it was exported beside",
).toEqual({
startDate: "2026-06-10",
endDate: "2026-06-20",
label: "Barcelona",
covers: true,
sameLat: true,
sameLon: true,
});
});
/**
* A supersede pointer that names a position the file does not have.
*
* The builder computes the index from the same list it writes, so no file
* this release produces can trip this. A hand-edited or truncated one can,
* and the question it asks has to be answered somewhere: the era it names
* does not exist, the column is a bare id with no constraint to stop a
* wrong value, and inventing a neighbouring era to point at would be worse
* than either dropping or refusing.
*
* The answer is that the ERA restores and the POINTER does not, and the
* position is reported. Refusing the file would cost an operator every era
* of every drug over one bad integer; writing the link would put a
* correction pointer at whatever row happened to be there.
*/
it("keeps an archived era whose supersede pointer names a position out of range, and reports the position", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const medication = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Out-of-range tablet", dose: "5 mg" },
});
await prisma.medicationScheduleRevision.createMany({
data: [
{
medicationId: medication.id,
validFrom: AT("2026-01-01T00:00:00.000Z"),
validUntil: AT("2026-02-01T00:00:00.000Z"),
payload: [{ windowStart: "07:00", windowEnd: "09:00" }],
source: "ARCHIVED",
},
{
medicationId: medication.id,
validFrom: AT("2026-02-01T00:00:00.000Z"),
validUntil: AT("2026-03-01T00:00:00.000Z"),
payload: [{ windowStart: "08:00", windowEnd: "10:00" }],
source: "MANUAL",
},
],
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
// Hand-edit the file the way a person with a text editor would: point the
// first era at an era that is not there.
const medications = payload.medications as Array<{
scheduleRevisions: Array<{ supersededByIndex: number | null }>;
}>;
medications[0].scheduleRevisions[0].supersededByIndex = 7;
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
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);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
links: number;
}>;
expect(
reported.filter((entry) => entry.catalogue === "scheduleRevisionLink"),
"the position the file named is not in the file, so dropping the link " +
"is correct and saying nothing about it is not",
).toEqual([
{
catalogue: "scheduleRevisionLink",
key: "medications[0].scheduleRevisions[0].supersededByIndex=7",
links: 1,
},
]);
const restoredEras = await prisma.medicationScheduleRevision.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { validFrom: "asc" },
});
expect(
restoredEras.map((era) => ({
validFrom: era.validFrom.toISOString(),
source: era.source,
supersededByRevisionId: era.supersededByRevisionId,
})),
"both eras come back; only the pointer is lost",
).toEqual([
{
validFrom: "2026-01-01T00:00:00.000Z",
source: "ARCHIVED",
supersededByRevisionId: null,
},
{
validFrom: "2026-02-01T00:00:00.000Z",
source: "MANUAL",
supersededByRevisionId: null,
},
]);
});
/**
* The reference a portable file genuinely lacks, and the slug the restore
* must not judge.
*
* The reminders travel since v1.37.20, so a reference to one is no longer a
* guaranteed drop — the round trip above proves it surviving. What can still
* genuinely be missing is a TOMBSTONED reminder in a portable export: the
* builder omits it (a restore must not resurrect a deletion) and omits its
* completion ledger with it, while a live dose that recorded satisfying it
* keeps its reference in the database. That reference then points at a row
* the file does not carry, which is exactly the case the restore reports.
*
* Separated from the round trip above because this is correct behaviour
* that shows up as a reported skip, and that test asserts nothing was
* skipped. Reported is the whole point: a reference dropped in silence is
* the same defect as a row dropped in silence, one field smaller.
*/
it("reports the tombstoned reminder a portable file omits and keeps a slug it cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const reminder = await prisma.measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Tetanus booster",
intervalDays: 3650,
vaccinationAntigen: "tetanus",
deletedAt: AT("2026-07-01T00:00:00.000Z"),
},
});
// A ledger row on the tombstoned reminder. The builder must drop it from
// the file along with its reminder — a ledger row whose reminder is not
// carried could only ever restore as a reported loss.
await prisma.measurementReminderEvent.create({
data: {
userId: OWNER_ID,
reminderId: reminder.id,
kind: "SATISFIED",
occurredAt: AT("2026-06-01T09:00:00.000Z"),
onTime: true,
source: "vaccination",
},
});
await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: AT("1987-09-02T00:00:00.000Z"),
// A slug no catalogue this release ships resolves. It must come back
// exactly as written: the renderer degrades to `vaccineName`, and a
// restore that dropped or rewrote it would lose what a person was
// actually given.
antigenSlug: "retired-antigen-from-an-older-release",
vaccineName: "Whatever the Pass called it in 1987",
reminderId: reminder.id,
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
// The file must be silent about the tombstoned reminder AND its ledger —
// carrying the events of a reminder it refuses to restore would make
// every portable file this release writes restore with a reported loss.
expect(payload.measurementReminders).toEqual([]);
expect(payload.measurementReminderEvents).toEqual([]);
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
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);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
}>;
expect(
reported.filter((entry) => entry.catalogue === "vaccinationReference"),
"the reminder the dose pointed at is not in the backup at all, so " +
"dropping the reference is correct and saying nothing about it is not",
).toEqual([
{ catalogue: "vaccinationReference", key: reminder.id, links: 1 },
]);
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
antigenSlug: dose.antigenSlug,
vaccineName: dose.vaccineName,
reminderId: dose.reminderId,
}).toEqual({
antigenSlug: "retired-antigen-from-an-older-release",
vaccineName: "Whatever the Pass called it in 1987",
reminderId: null,
});
});
/**
* The hand-edited file, which is the only way the vault filing can carry a
* reference the restore cannot place.
*
* The builder carries a filing or a staged fact only when both of its ends
* are carried, so no file this release writes reaches the drop path. That is
* exactly why the path is worth a test of its own: an arm nothing exercises
* is an arm nobody notices is wrong, and here being wrong has two very
* different prices. `documentConditionLink.episodeId` is a real foreign key,
* so writing it unchecked does not lose one edge — it violates a constraint
* and rolls the WHOLE account back, which is what the mood categories did
* before they travelled. `ExtractedFact.committedRecordId` is a bare id
* column with no relation, so writing it unchecked costs no error at all and
* simply leaves a fact pointing at a lab result that is not there.
*
* Both references are broken in the same file so the two answers can be seen
* side by side: a dropped row and a nulled pointer, each named in the report,
* and a restore that still answers 200.
*/
/**
* The Coach's stored tables through a portable file.
*
* A disaster-recovery file carries them as ciphertext (the round trip at
* the top of this file). A portable file has to carry them readable, like
* the prose they sit beside, and the restore has to seal them again under
* the target's key. A restore that dropped the readable JSON would bring
* the thread back with every answer's table missing and nothing to say so.
*/
it("carries a turn's tables readable in a portable file and seals them again on restore", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
await prisma.coachConversation.create({
data: {
userId: OWNER_ID,
titleEncrypted: encryptToBytes("How is my blood pressure trending?"),
documentScoped: false,
messages: {
create: [
{
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
createdAt: AT("2026-07-20T09:59:00.000Z"),
},
],
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
const { coachConversations } = payload as {
coachConversations: Array<{ messages: Array<Record<string, unknown>> }>;
};
const [turn] = coachConversations[0].messages;
expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON });
expect(turn).not.toHaveProperty("resultsEncrypted");
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
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 }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.coachMessage.findFirstOrThrow({
where: { conversation: { userId: OWNER_ID } },
});
expect(restored.resultsEncrypted).not.toBeNull();
expect(decryptFromBytes(restored.resultsEncrypted!)).toBe(
COACH_RESULTS_JSON,
);
});
it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const episode = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Iron deficiency",
type: "CHRONIC",
onsetAt: AT("2026-03-01T00:00:00.000Z"),
},
});
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,
analyte: "Ferritin",
value: 91,
unit: "ng/mL",
takenAt: AT("2026-06-30T09:00:00.000Z"),
},
});
const documentBytes = encryptBytes(Buffer.from("truncated-file fixture"));
const contentEncrypted = new Uint8Array(
new ArrayBuffer(documentBytes.byteLength),
);
contentEncrypted.set(documentBytes);
const document = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "LAB_RESULT",
title: "June labs",
mimeType: "application/pdf",
byteSize: documentBytes.byteLength,
contentEncrypted,
contentCodec: "binary2",
},
});
await prisma.documentConditionLink.create({
data: {
userId: OWNER_ID,
documentId: document.id,
episodeId: episode.id,
},
});
await prisma.extractedFact.create({
data: {
userId: OWNER_ID,
documentId: document.id,
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.9,
needsReview: false,
committedRecordId: labResult.id,
committedRecordType: "labResult",
dataEncrypted: encryptFactData({
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-30",
}),
provenanceEncrypted: encryptFactProvenance({
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.9,
}),
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
// The edit a truncated or hand-repaired file makes: both references now
// name rows the file no longer carries.
const edited = payload as typeof payload & {
documentConditionLinks: Array<{ episodeId: string }>;
extractedFacts: Array<{ committedRecordId: string | null }>;
};
edited.documentConditionLinks[0].episodeId = "condition-not-in-this-file";
edited.extractedFacts[0].committedRecordId = "lab-not-in-this-file";
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
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,
"a filing naming a condition the file does not carry must cost that " +
"filing, not the account",
).toBe(200);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
links: number;
}>;
expect(
reported.filter((entry) =>
["documentConditionLink", "factCommitment"].includes(entry.catalogue),
),
).toEqual([
{
catalogue: "documentConditionLink",
key: "condition-not-in-this-file",
links: 1,
},
{ catalogue: "factCommitment", key: "lab-not-in-this-file", links: 1 },
]);
// The filing is gone because it had nowhere to hang. The fact is NOT: what
// it lost is a pointer that was already going nowhere, and the transcribed
// reading is still the account's.
expect(
await prisma.documentConditionLink.count({ where: { userId: OWNER_ID } }),
).toBe(0);
const survivor = await prisma.extractedFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
status: survivor.status,
needsReview: survivor.needsReview,
committedRecordId: survivor.committedRecordId,
committedRecordType: survivor.committedRecordType,
data: decryptFactData(survivor.dataEncrypted),
}).toEqual({
status: "APPROVED",
needsReview: false,
// Nulled as a pair: a type with no id would claim a commitment the row
// cannot name.
committedRecordId: null,
committedRecordType: null,
data: {
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-30",
},
});
});
/**
* A note this instance can no longer decrypt leaves the export as a marker,
* not as nothing.
*
* A fail-soft decrypt answers `null` on a wrong or dropped key, and in a
* portable file `null` is byte-for-byte what a note that was never written
* looks like. The restore importer then writes no note, so a note the person
* did write is gone and no one is told — the whole loss fits inside a
* fail-soft `catch` written for a list view, where returning null is right.
*
* The Coach transcript and the doctor report both refuse that trade, one
* with a placeholder sentence and one with a rendered flag. This asserts the
* lab note now does the same, and that the marker is still there after the
* file has been through a real restore: the ciphertext is unreadable either
* way, but a person opening the restored account can see that something was
* there rather than believing they never wrote it.
*/
it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const biomarker = await prisma.biomarker.create({
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
});
// Ciphertext under a key id this instance does not hold — the shape a row
// written before a key rotation that dropped its key has. `decrypt` is
// fail-closed on an unknown key id, so this throws rather than returning
// garbage, which is the case the soft helper swallowed.
const unreadable = new Uint8Array(
Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"),
);
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"),
noteEncrypted: unreadable,
},
});
await prisma.biomarker.update({
where: { id: biomarker.id },
data: { contextEncrypted: unreadable },
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
const exported = payload.labResults as Array<{ note: string | null }>;
expect(exported).toHaveLength(1);
expect(exported[0].note).toContain("unreadable");
const exportedBiomarkers = payload.biomarkers as Array<{
context: string | null;
}>;
expect(exportedBiomarkers[0].context).toContain("unreadable");
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
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);
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId: OWNER_ID },
});
// Back in the column it came from, readable again because the restore
// re-encrypted it under the CURRENT key — and saying what happened.
expect(restoredLab.noteEncrypted).not.toBeNull();
expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe(
UNREADABLE_EXPORT_MARKER,
);
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
UNREADABLE_EXPORT_MARKER,
);
});
/**
* #1028 — a medication's creation instant is the floor of its expected
* slots: every adherence surface counts a slot before it only when a
* recorded dose claims it. A restore that stamped the restore time as the
* creation turned the whole restored history into "before the medication
* existed", so its misses dropped out of the rate. Both file purposes must
* bring the creation instant back.
*/
for (const purpose of ["portable-export", "disaster-recovery"] as const) {
it(`restores a medication's creation instant from a ${purpose} file`, async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const createdAt = AT("2025-11-03T16:05:00.000Z");
await prisma.medication.create({
data: {
userId: OWNER_ID,
name: "Metformin",
dose: "500mg",
createdAt,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
},
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose,
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
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 }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.medication.findFirstOrThrow({
where: { userId: OWNER_ID, name: "Metformin" },
});
expect(restored.createdAt.toISOString()).toBe(createdAt.toISOString());
});
}
});