mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
4482 lines
161 KiB
TypeScript
4482 lines
161 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 { createHash } from "node:crypto";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
|
|
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
|
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 {
|
|
resolveMedicationCategories,
|
|
setMedicationCategory,
|
|
} from "@/lib/medication-category";
|
|
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
|
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
|
import {
|
|
decryptContextFromBytes,
|
|
encryptContextToBytes,
|
|
} from "@/lib/labs/biomarker-store";
|
|
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
|
|
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
import { summarizeBackup } from "@/lib/validations/backup-summary";
|
|
import {
|
|
TWO_ENDED_MODELS,
|
|
USER_COLUMN_BACKUP_CLASS,
|
|
type TwoEndedModel,
|
|
} from "@/lib/export/backup-plan";
|
|
import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup";
|
|
import { parseOnboardingSteps } from "@/lib/onboarding/needs";
|
|
import { POST } from "./restore-job-driver";
|
|
import {
|
|
type AuditColumn,
|
|
type AuditModel,
|
|
enumValuesFromSchema,
|
|
isDefaultValue,
|
|
normalise,
|
|
ownerWhere,
|
|
readAuditModels,
|
|
syntheticValue,
|
|
} from "./backup-column-audit";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
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);
|
|
// The restore drops a measurement tombstone older than
|
|
// `TOMBSTONE_RETENTION_DAYS` on purpose, so the fixture's deletion has to stay
|
|
// recent on whatever day the suite runs. A fixed date aged out on 2026-10-03.
|
|
const TOMBSTONE_DELETED_AT = new Date(
|
|
Math.floor((Date.now() - 10 * 86_400_000) / 1000) * 1000,
|
|
).toISOString();
|
|
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";
|
|
// v1.41 — the model text of a turn's trail, stored encrypted beside the turn.
|
|
const COACH_TRAIL_JSON = JSON.stringify({
|
|
entries: [{ id: "a1", title: "Checking the trend", text: "Read 30 days." }],
|
|
});
|
|
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 ROUND_TRIP_CATEGORY_KEY = "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10";
|
|
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 } } }),
|
|
// No own `userId` column — reached through the drug, like the schedules.
|
|
MedicationCategoryAssignment: (p, userId) =>
|
|
p.medicationCategoryAssignment.count({ where: { medication: { userId } } }),
|
|
MedicationCategoryLabel: (p, userId) =>
|
|
p.medicationCategoryLabel.count({ where: { userId } }),
|
|
MedicationIngredient: (p, userId) =>
|
|
p.medicationIngredient.count({ where: { userId } }),
|
|
MedicationCourse: (p, userId) =>
|
|
p.medicationCourse.count({ where: { 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 } }),
|
|
SymptomDefinition: (p, userId) =>
|
|
p.symptomDefinition.count({ where: { userId } }),
|
|
SymptomEvent: (p, userId) => p.symptomEvent.count({ where: { userId } }),
|
|
LifeEvent: (p, userId) => p.lifeEvent.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 } }),
|
|
CustomVaccine: (p, userId) => p.customVaccine.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(TOMBSTONE_DELETED_AT),
|
|
},
|
|
});
|
|
// 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",
|
|
},
|
|
});
|
|
|
|
// Two courses with a gap, and the window the row projects from them (the
|
|
// latest). The earlier course exists nowhere else once it has ended.
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip tablet",
|
|
dose: "5 mg",
|
|
startsOn: AT("2026-06-10T00:00:00.000Z"),
|
|
endsOn: AT("2026-06-16T00:00:00.000Z"),
|
|
schedules: {
|
|
create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
|
|
},
|
|
courses: {
|
|
create: [
|
|
{
|
|
userId: OWNER_ID,
|
|
startsOn: AT("2026-03-01T00:00:00.000Z"),
|
|
endsOn: AT("2026-03-07T00:00:00.000Z"),
|
|
noteEncrypted: encryptToBytes("the March flu"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
startsOn: AT("2026-06-10T00:00:00.000Z"),
|
|
endsOn: AT("2026-06-16T00:00:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
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"),
|
|
},
|
|
});
|
|
// Not OTHER: a missing row already reads as OTHER, so only a chosen value
|
|
// proves the category came back. A category the person named, so the
|
|
// assignment can only resolve if the label came back before it.
|
|
await prisma.medicationCategoryLabel.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
key: ROUND_TRIP_CATEGORY_KEY,
|
|
labelEncrypted: encryptToBytes("Heart and circulation"),
|
|
sortOrder: 2,
|
|
},
|
|
});
|
|
await prisma.medicationCategoryAssignment.create({
|
|
data: { medicationId: medication.id, category: ROUND_TRIP_CATEGORY_KEY },
|
|
});
|
|
// 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,
|
|
},
|
|
});
|
|
|
|
// v1.43 — a composition entered in a non-canonical unit, so the entered
|
|
// amount and unit are set too.
|
|
await prisma.medicationIngredient.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
nutrient: "vitamin_d",
|
|
amountPerUnit: 25,
|
|
unit: "ug",
|
|
enteredAmount: 1000,
|
|
enteredUnit: "IU",
|
|
source: "LABEL_AI",
|
|
},
|
|
});
|
|
|
|
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 } },
|
|
},
|
|
});
|
|
|
|
// v1.40 — two of the person's own symptoms and three occurrences: one filed
|
|
// against the open cold, one with a note, one on its own in the gap.
|
|
const aura = await prisma.symptomDefinition.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
labelEncrypted: encryptToBytes("Aura"),
|
|
icon: "Zap",
|
|
sortOrder: 0,
|
|
},
|
|
});
|
|
const headache = await prisma.symptomDefinition.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
labelEncrypted: encryptToBytes("Headache"),
|
|
icon: "Brain",
|
|
sortOrder: 1,
|
|
isActive: false,
|
|
},
|
|
});
|
|
await prisma.symptomEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
definitionId: aura.id,
|
|
occurredAt: AT("2026-06-21T09:00:00.000Z"),
|
|
intensity: 4,
|
|
episodeId: episode.id,
|
|
},
|
|
});
|
|
await prisma.symptomEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
definitionId: headache.id,
|
|
occurredAt: AT("2026-06-21T11:00:00.000Z"),
|
|
intensity: 8,
|
|
noteEncrypted: encryptToBytes("left side, behind the eye"),
|
|
},
|
|
});
|
|
await prisma.symptomEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
definitionId: aura.id,
|
|
occurredAt: AT("2026-07-10T18:30:00.000Z"),
|
|
intensity: 2,
|
|
},
|
|
});
|
|
|
|
// v1.42 — two life events: a move known to the month, with a note, and a
|
|
// period known to the day.
|
|
await prisma.lifeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
category: "HOME",
|
|
startDate: "2023-09-01",
|
|
precision: "MONTH",
|
|
titleEncrypted: encryptToBytes("Moved to the coast"),
|
|
noteEncrypted: encryptToBytes("the flat with the long hallway"),
|
|
},
|
|
});
|
|
await prisma.lifeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
category: "WORK",
|
|
startDate: "2026-03-02",
|
|
endDate: "2026-05-29",
|
|
precision: "DAY",
|
|
titleEncrypted: encryptToBytes("Parental leave"),
|
|
},
|
|
});
|
|
|
|
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),
|
|
trailEncrypted: encryptToBytes(COACH_TRAIL_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 },
|
|
});
|
|
const healthAnswer = await prisma.coachMessage.findFirstOrThrow({
|
|
where: { conversationId: healthThread.id, role: "assistant" },
|
|
});
|
|
await prisma.coachFact.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
factEncrypted: encryptToBytes(COACH_FACT),
|
|
category: "constraint",
|
|
confidence: 80,
|
|
sourceConversationId: healthThread.id,
|
|
source: "user",
|
|
lastUsedAt: AT("2026-07-20T10:00:00.000Z"),
|
|
sourceMessageId: healthAnswer.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.
|
|
// v1.42 (#1005) — the person's own vaccine definition the dose also names.
|
|
// The catalogue slug still wins on read; the reference is what has to
|
|
// survive the trip, and it can only if the definition comes back first.
|
|
const customVaccine = await prisma.customVaccine.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip travel combo",
|
|
components: ["typhoid", "hepatitis-a"],
|
|
typicalSeriesDoses: 2,
|
|
boosterIntervalMonths: 36,
|
|
},
|
|
});
|
|
const vaccination = await prisma.vaccinationRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: AT("2026-05-14T00:00:00.000Z"),
|
|
customVaccineId: customVaccine.id,
|
|
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.
|
|
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
|
|
// the writers store it; it is the only copy since v1.43.
|
|
await prisma.environmentTravelLocation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate: "2026-06-10",
|
|
endDate: "2026-06-20",
|
|
locationEncrypted: sealLocation({
|
|
lat: 41.4,
|
|
lon: 2.2,
|
|
label: "Barcelona",
|
|
}),
|
|
},
|
|
});
|
|
await prisma.environmentContext.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
date: "2026-06-15",
|
|
locationEncrypted: sealLocation({
|
|
lat: 41.4,
|
|
lon: 2.2,
|
|
label: "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",
|
|
locationEncrypted: sealLocation({
|
|
lat: 52.5,
|
|
lon: 13.4,
|
|
label: "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 = "";
|
|
const previewCollector = await createBackupPreviewCollector();
|
|
const streamedCounts = await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
streamedJson += chunk;
|
|
},
|
|
{
|
|
purpose: "disaster-recovery",
|
|
exportedAt,
|
|
observe: previewCollector.observe,
|
|
},
|
|
);
|
|
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 weekly copy's restore preview is taken from the writer's counts and
|
|
// the sections it was shown, never by reading the copy. On an account
|
|
// with a row in every model, it has to say what a full read of the copy
|
|
// says, count for count and key use for key use.
|
|
const fromWriter = previewCollector.finish(streamedCounts);
|
|
expect(fromWriter).not.toBeNull();
|
|
expect(
|
|
fromWriter!.summary,
|
|
"the preview taken from the writer's counts disagrees with a full read",
|
|
).toEqual(summarizeBackup(parseBackupPayload(streamedJson)));
|
|
const readBack = new BackupKeyIdCollector();
|
|
readBack.visit(JSON.parse(streamedJson));
|
|
expect(fromWriter!.keys.toStored()).toEqual(readBack.toStored());
|
|
|
|
// 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: TOMBSTONE_DELETED_AT,
|
|
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,
|
|
trail: message.trailEncrypted
|
|
? decryptFromBytes(message.trailEncrypted)
|
|
: 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,
|
|
trail: null,
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: COACH_ASSISTANT_TURN,
|
|
model: "claude-opus-5",
|
|
tokensUsed: 812,
|
|
results: COACH_RESULTS_JSON,
|
|
trail: COACH_TRAIL_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,
|
|
trail: 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 },
|
|
});
|
|
const restoredAnswer = await prisma.coachMessage.findFirstOrThrow({
|
|
where: { conversationId: restoredThread.id, role: "assistant" },
|
|
});
|
|
expect({
|
|
fact: decryptFromBytes(fact.factEncrypted),
|
|
category: fact.category,
|
|
confidence: fact.confidence,
|
|
source: fact.sourceConversationId,
|
|
origin: fact.source,
|
|
lastUsedAt: fact.lastUsedAt?.toISOString(),
|
|
sourceMessageId: fact.sourceMessageId,
|
|
deletedAt: fact.deletedAt,
|
|
}).toEqual({
|
|
fact: COACH_FACT,
|
|
category: "constraint",
|
|
confidence: 80,
|
|
source: restoredThread.id,
|
|
origin: "user",
|
|
lastUsedAt: "2026-07-20T10:00:00.000Z",
|
|
sourceMessageId: restoredAnswer.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 } },
|
|
customVaccine: 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,
|
|
customVaccine: dose.customVaccine
|
|
? {
|
|
name: dose.customVaccine.name,
|
|
components: dose.customVaccine.components,
|
|
typicalSeriesDoses: dose.customVaccine.typicalSeriesDoses,
|
|
boosterIntervalMonths: dose.customVaccine.boosterIntervalMonths,
|
|
}
|
|
: 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",
|
|
customVaccine: {
|
|
name: "Round-trip travel combo",
|
|
components: ["typhoid", "hepatitis-a"],
|
|
typicalSeriesDoses: 2,
|
|
boosterIntervalMonths: 36,
|
|
},
|
|
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" });
|
|
|
|
// v1.40 — the person's own symptoms: the names decrypt to what was typed,
|
|
// each occurrence keeps its time, intensity and note, and the one filed
|
|
// against the cold still points at the cold.
|
|
const restoredSymptoms = await prisma.symptomDefinition.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { sortOrder: "asc" },
|
|
include: {
|
|
events: {
|
|
orderBy: { occurredAt: "asc" },
|
|
include: { episode: { select: { label: true } } },
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
restoredSymptoms.map((definition) => ({
|
|
label: decryptFromBytes(definition.labelEncrypted),
|
|
icon: definition.icon,
|
|
isActive: definition.isActive,
|
|
events: definition.events.map((event) => ({
|
|
at: event.occurredAt.toISOString(),
|
|
intensity: event.intensity,
|
|
note: event.noteEncrypted
|
|
? decryptFromBytes(event.noteEncrypted)
|
|
: null,
|
|
episode: event.episode?.label ?? null,
|
|
})),
|
|
})),
|
|
"symptom definitions and their occurrences must survive the round trip",
|
|
).toEqual([
|
|
{
|
|
label: "Aura",
|
|
icon: "Zap",
|
|
isActive: true,
|
|
events: [
|
|
{
|
|
at: "2026-06-21T09:00:00.000Z",
|
|
intensity: 4,
|
|
note: null,
|
|
episode: "Cold",
|
|
},
|
|
{
|
|
at: "2026-07-10T18:30:00.000Z",
|
|
intensity: 2,
|
|
note: null,
|
|
episode: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "Headache",
|
|
icon: "Brain",
|
|
isActive: false,
|
|
events: [
|
|
{
|
|
at: "2026-06-21T11:00:00.000Z",
|
|
intensity: 8,
|
|
note: "left side, behind the eye",
|
|
episode: null,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
// v1.42 — the life events: the titles and the note decrypt to what was
|
|
// typed, and the dates keep their precision.
|
|
const restoredLifeEvents = await prisma.lifeEvent.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { startDate: "asc" },
|
|
});
|
|
expect(
|
|
restoredLifeEvents.map((event) => ({
|
|
category: event.category,
|
|
startDate: event.startDate,
|
|
endDate: event.endDate,
|
|
precision: event.precision,
|
|
title: decryptFromBytes(event.titleEncrypted),
|
|
note: event.noteEncrypted
|
|
? decryptFromBytes(event.noteEncrypted)
|
|
: null,
|
|
})),
|
|
"life events must survive the round trip",
|
|
).toEqual([
|
|
{
|
|
category: "HOME",
|
|
startDate: "2023-09-01",
|
|
endDate: null,
|
|
precision: "MONTH",
|
|
title: "Moved to the coast",
|
|
note: "the flat with the long hallway",
|
|
},
|
|
{
|
|
category: "WORK",
|
|
startDate: "2026-03-02",
|
|
endDate: "2026-05-29",
|
|
precision: "DAY",
|
|
title: "Parental leave",
|
|
note: null,
|
|
},
|
|
]);
|
|
|
|
// 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: TOMBSTONE_DELETED_AT, 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,
|
|
// The sealed location, opened (v1.42).
|
|
...(({ lat, lon, label }) => ({ lat, lon, locationLabel: label }))(
|
|
openLocation(reading.locationEncrypted!),
|
|
),
|
|
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")!;
|
|
const tripPlace = openLocation(trip.locationEncrypted!);
|
|
const dayPlace = openLocation(travelDay.locationEncrypted!);
|
|
expect(
|
|
{
|
|
startDate: trip.startDate,
|
|
endDate: trip.endDate,
|
|
label: tripPlace.label,
|
|
covers:
|
|
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
|
|
sameLat: tripPlace.lat === dayPlace.lat,
|
|
sameLon: tripPlace.lon === dayPlace.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.each(["disaster-recovery", "portable-export"] as const)(
|
|
"carries each medication's category through a %s round trip",
|
|
async (purpose) => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
const thyroid = await prisma.medication.create({
|
|
data: { userId: OWNER_ID, name: "Levothyroxine", dose: "50 µg" },
|
|
});
|
|
const mood = await prisma.medication.create({
|
|
data: { userId: OWNER_ID, name: "Sertraline", dose: "50 mg" },
|
|
});
|
|
await prisma.medication.create({
|
|
data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" },
|
|
});
|
|
const travel = await prisma.medication.create({
|
|
data: { userId: OWNER_ID, name: "Tamiflu", dose: "75 mg" },
|
|
});
|
|
await setMedicationCategory(thyroid.id, "THYROID");
|
|
await setMedicationCategory(mood.id, "MENTAL_HEALTH");
|
|
// v1.40 — a category the person named. Hidden, because a hidden
|
|
// category still files the medication and must travel with it.
|
|
await prisma.medicationCategoryLabel.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
key: ROUND_TRIP_CATEGORY_KEY,
|
|
labelEncrypted: encryptToBytes("Travel kit"),
|
|
isActive: false,
|
|
},
|
|
});
|
|
await setMedicationCategory(travel.id, ROUND_TRIP_CATEGORY_KEY);
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose,
|
|
});
|
|
const exported = Object.fromEntries(
|
|
(payload.medications as Array<{ name: string; category?: string }>).map(
|
|
(m) => [m.name, m.category],
|
|
),
|
|
);
|
|
expect(exported).toEqual({
|
|
Levothyroxine: "THYROID",
|
|
Sertraline: "MENTAL_HEALTH",
|
|
Unfiled: "OTHER",
|
|
Tamiflu: ROUND_TRIP_CATEGORY_KEY,
|
|
});
|
|
expect(payload.customMedicationCategories).toEqual([
|
|
{
|
|
key: ROUND_TRIP_CATEGORY_KEY,
|
|
label: "Travel kit",
|
|
sortOrder: 0,
|
|
isActive: false,
|
|
createdAt: expect.any(String),
|
|
},
|
|
]);
|
|
|
|
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.findMany({
|
|
where: { userId: OWNER_ID },
|
|
select: { id: true, name: true },
|
|
});
|
|
const categories = await resolveMedicationCategories(
|
|
restored.map((m) => m.id),
|
|
);
|
|
expect(
|
|
Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])),
|
|
).toEqual({
|
|
Levothyroxine: { category: "THYROID", categoryLabel: null },
|
|
Sertraline: { category: "MENTAL_HEALTH", categoryLabel: null },
|
|
Unfiled: { category: "OTHER", categoryLabel: null },
|
|
Tamiflu: {
|
|
category: ROUND_TRIP_CATEGORY_KEY,
|
|
categoryLabel: "Travel kit",
|
|
},
|
|
});
|
|
const label = await prisma.medicationCategoryLabel.findUniqueOrThrow({
|
|
where: { key: ROUND_TRIP_CATEGORY_KEY },
|
|
});
|
|
expect(label.isActive).toBe(false);
|
|
},
|
|
);
|
|
|
|
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),
|
|
trailEncrypted: encryptToBytes(COACH_TRAIL_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,
|
|
trailJson: COACH_TRAIL_JSON,
|
|
});
|
|
expect(turn).not.toHaveProperty("resultsEncrypted");
|
|
expect(turn).not.toHaveProperty("trailEncrypted");
|
|
|
|
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,
|
|
);
|
|
expect(restored.trailEncrypted).not.toBeNull();
|
|
expect(decryptFromBytes(restored.trailEncrypted!)).toBe(COACH_TRAIL_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());
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Columns the restore is right not to bring back verbatim, by `Model.column`.
|
|
* Every other column of every two-ended model must come back with the value
|
|
* it left with. An entry here whose column no longer exists fails the run.
|
|
*/
|
|
const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
|
"MeasurementReminder.lastNotifiedAt":
|
|
"operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour",
|
|
// When a join row was written. Nothing reads these: the links are read for
|
|
// which tag, symptom or context applies, never for when the row appeared,
|
|
// and the parent entry carries its own timestamps, which do travel.
|
|
"MoodContext.createdAt":
|
|
"insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel",
|
|
"MoodEntryTagLink.createdAt":
|
|
"insert stamp of a tag link; no reader, the mood entry's own timestamps travel",
|
|
"CycleSymptomLink.createdAt":
|
|
"insert stamp of a symptom link; no reader, the day log's own timestamps travel",
|
|
"IllnessSymptomLink.createdAt":
|
|
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
|
|
};
|
|
|
|
/**
|
|
* Columns the restore carries on purpose as "set where it was set" rather
|
|
* than the same value, each with the reason. A presence check still fails a
|
|
* column that stops coming back at all.
|
|
*/
|
|
const COLUMN_PRESENCE_ONLY: Readonly<Record<string, string>> = {
|
|
"Medication.autoMarkTakenSince":
|
|
"the auto-take floor restarts at the restore, so doses a portable file cannot carry as removed are not filled again; pinned in the restore test for it",
|
|
};
|
|
|
|
/**
|
|
* Columns a release adds ahead of the backup ends that carry them, so the
|
|
* packages built in parallel on its branch each own their own section of the
|
|
* export and the restore. NOT an exclusion: each entry names the package that
|
|
* carries it, and the companion assertion fails as soon as an entry's column
|
|
* starts coming back, so the entry has to go in the change that carries it.
|
|
* The list is empty again before the release is tagged.
|
|
*/
|
|
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
|
|
"IntradayCumulativeProfile.hourlyIncrements":
|
|
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
|
|
"IntradayCumulativeProfile.basis":
|
|
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
|
|
"IntradayCumulativeProfile.bucketCount":
|
|
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
|
|
};
|
|
|
|
/**
|
|
* Columns every reader parses through one function before using, compared
|
|
* through that same function: the restore writes the parsed form.
|
|
*/
|
|
const READ_THROUGH: Readonly<Record<string, (value: unknown) => unknown>> = {
|
|
"OnboardingRecord.stepsJson": parseOnboardingSteps,
|
|
};
|
|
|
|
/**
|
|
* Values for columns the fixture leaves empty where the type alone does not
|
|
* produce one the export and the restore accept.
|
|
*/
|
|
const FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
|
"MedicationSchedule.rrule": "FREQ=WEEKLY;BYDAY=MO,TH",
|
|
"InboundDocument.sourceInstance": "https://paperless.example.test",
|
|
// The digest of the fixture's own document bytes, so a restore that checks
|
|
// it against the content finds them consistent.
|
|
"InboundDocument.contentSha256": createHash("sha256")
|
|
.update("round-trip document bytes")
|
|
.digest("hex"),
|
|
"Biomarker.contextEncrypted": () =>
|
|
encryptContextToBytes("fasted, morning draw"),
|
|
"MoodEntry.tags": JSON.stringify(["round-trip-tag"]),
|
|
"CorrelationPattern.qValue": 0.04,
|
|
"CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32),
|
|
// CHECK in migration 0408: the profile is built from samples or from hour buckets.
|
|
"IntradayCumulativeProfile.basis": "HOUR_BUCKETS",
|
|
};
|
|
|
|
/**
|
|
* Rows for the columns a CHECK constraint ties to a shape the base fixture
|
|
* does not have: a stage only on a sleep reading, a context only on a glucose
|
|
* reading, a rolling interval only on a schedule without a calendar rule.
|
|
*/
|
|
async function seedColumnShapes(prisma: PrismaClient): Promise<void> {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 5.4,
|
|
unit: "mmol/L",
|
|
measuredAt: AT("2026-07-03T06:45:00.000Z"),
|
|
source: "MANUAL",
|
|
glucoseContext: "FASTING",
|
|
},
|
|
});
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "SLEEP_DURATION",
|
|
value: 95,
|
|
unit: "min",
|
|
measuredAt: AT("2026-07-03T02:00:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "round-trip-deep-sleep",
|
|
sleepStage: "DEEP",
|
|
},
|
|
});
|
|
// A start logged out of order and folded into the one before it: soft
|
|
// deleted, and pointing at the start that absorbed it so that removing that
|
|
// start can bring this one back.
|
|
const absorbing = await prisma.menstrualCycle.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, startDate: "2026-06-01" },
|
|
});
|
|
await prisma.menstrualCycle.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate: "2026-06-03",
|
|
deletedAt: AT("2026-06-04T07:00:00.000Z"),
|
|
absorbedIntoId: absorbing.id,
|
|
},
|
|
});
|
|
const medication = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
await prisma.medicationSchedule.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
windowStart: "20:00",
|
|
windowEnd: "21:00",
|
|
label: "Every third evening",
|
|
rollingIntervalDays: 3,
|
|
},
|
|
});
|
|
}
|
|
|
|
type Delegate = {
|
|
findMany(args: {
|
|
where: Record<string, unknown>;
|
|
}): Promise<Record<string, unknown>[]>;
|
|
update(args: {
|
|
where: Record<string, unknown>;
|
|
data: Record<string, unknown>;
|
|
}): Promise<unknown>;
|
|
};
|
|
|
|
function delegateOf(prisma: PrismaClient, model: AuditModel): Delegate {
|
|
return (prisma as unknown as Record<string, Delegate>)[model.delegate];
|
|
}
|
|
|
|
function primaryKeyWhere(
|
|
model: AuditModel,
|
|
row: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
if (model.primaryKey.length === 1) {
|
|
return { [model.primaryKey[0]]: row[model.primaryKey[0]] };
|
|
}
|
|
return {
|
|
[model.primaryKey.join("_")]: Object.fromEntries(
|
|
model.primaryKey.map((k) => [k, row[k]]),
|
|
),
|
|
};
|
|
}
|
|
|
|
/** How a column is compared, or why it is not. */
|
|
function comparison(
|
|
column: AuditColumn,
|
|
): "value" | "presence" | { excluded: string } {
|
|
const key = `${column.model}.${column.name}`;
|
|
if (key in COLUMN_EXCLUSIONS) return { excluded: COLUMN_EXCLUSIONS[key] };
|
|
if (column.isId) {
|
|
return { excluded: "primary key; the restore may mint a fresh one" };
|
|
}
|
|
if (column.name === "userId") {
|
|
return { excluded: "owner; re-derived from the restoring account" };
|
|
}
|
|
if (column.isUpdatedAt) {
|
|
return { excluded: "@updatedAt; Prisma stamps it on the restore's write" };
|
|
}
|
|
// A declared foreign key may be re-pointed at a row the restore minted, so
|
|
// it is held to being set where it was set, not to the same id.
|
|
if (column.isForeignKey) return "presence";
|
|
if (key in COLUMN_PRESENCE_ONLY) return "presence";
|
|
return "value";
|
|
}
|
|
|
|
/** Plaintext behind a sealed value, or the value itself when it is not one. */
|
|
function opened(value: unknown): string {
|
|
if (value === null || value === undefined) return "null";
|
|
if (typeof value === "string") {
|
|
try {
|
|
return `text:${decrypt(value)}`;
|
|
} catch {
|
|
return `text:${value}`;
|
|
}
|
|
}
|
|
if (value instanceof Uint8Array) {
|
|
for (const open of BYTE_OPENERS) {
|
|
try {
|
|
return `text:${open(value)}`;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return normalise(value);
|
|
}
|
|
|
|
/** Every codec a sealed byte column is written with. */
|
|
const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
|
|
decryptFromBytes,
|
|
decryptNoteFromBytes,
|
|
decryptContextFromBytes,
|
|
// v1.42 — the sealed environment locations, under their own label.
|
|
(value) => JSON.stringify(openLocation(value)),
|
|
(value) => decryptBytes(Buffer.from(value)).toString("base64"),
|
|
];
|
|
|
|
/**
|
|
* A legacy plaintext column beside its sealed twin (`note` / `noteEncrypted`)
|
|
* is one value in two places: the restore seals what it finds in either, and
|
|
* clears the plaintext. So the pair is compared as the value a reader sees.
|
|
*/
|
|
function readerValue(
|
|
row: Record<string, unknown>,
|
|
column: AuditColumn,
|
|
): string {
|
|
const parse = READ_THROUGH[`${column.model}.${column.name}`];
|
|
if (parse) return normalise(parse(row[column.name]));
|
|
const twin = `${column.name}Encrypted`;
|
|
if (twin in row) {
|
|
const sealed = opened(row[twin]);
|
|
return sealed !== "null" ? sealed : opened(row[column.name]);
|
|
}
|
|
return opened(row[column.name]);
|
|
}
|
|
|
|
function columnValues(
|
|
rows: Record<string, unknown>[],
|
|
column: AuditColumn,
|
|
how: "value" | "presence",
|
|
): string[] {
|
|
return rows
|
|
.map((row) =>
|
|
how === "presence"
|
|
? row[column.name] === null || row[column.name] === undefined
|
|
? "null"
|
|
: "set"
|
|
: readerValue(row, column),
|
|
)
|
|
.sort();
|
|
}
|
|
|
|
async function snapshot(
|
|
prisma: PrismaClient,
|
|
models: AuditModel[],
|
|
wheres: Map<string, Record<string, unknown>>,
|
|
): Promise<Map<string, Record<string, unknown>[]>> {
|
|
const out = new Map<string, Record<string, unknown>[]>();
|
|
for (const model of models) {
|
|
out.set(
|
|
model.name,
|
|
await delegateOf(prisma, model).findMany({
|
|
where: wheres.get(model.name)!,
|
|
}),
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Columns whose value differs between two snapshots, by `Model.column`. Pure,
|
|
* so the test can show it catches a column it was never told about.
|
|
*/
|
|
function lostColumns(
|
|
models: AuditModel[],
|
|
before: Map<string, Record<string, unknown>[]>,
|
|
after: Map<string, Record<string, unknown>[]>,
|
|
): { lost: string[]; compared: number } {
|
|
const lost: string[] = [];
|
|
let compared = 0;
|
|
for (const model of models) {
|
|
for (const column of model.columns) {
|
|
const how = comparison(column);
|
|
if (typeof how === "object") continue;
|
|
const plain = column.name.replace(/Encrypted$/, "");
|
|
if (
|
|
plain !== column.name &&
|
|
model.columns.some((c) => c.name === plain)
|
|
) {
|
|
continue; // compared through its plaintext twin, see readerValue
|
|
}
|
|
compared += 1;
|
|
const was = columnValues(before.get(model.name) ?? [], column, how);
|
|
const is = columnValues(after.get(model.name) ?? [], column, how);
|
|
if (normalise(was) !== normalise(is)) {
|
|
lost.push(`${model.name}.${column.name}`);
|
|
}
|
|
}
|
|
}
|
|
return { lost: lost.sort(), compared };
|
|
}
|
|
|
|
/**
|
|
* Sections a portable file may carry stored ciphertext in, each with the
|
|
* reason. Empty: every sealed column travels readable in a portable file.
|
|
*/
|
|
const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap<string, string> = new Map();
|
|
|
|
describe("every column of every two-ended model survives a real restore", () => {
|
|
it("brings each column back with the value it left with", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
// A catalogue row the fixture adds, which survives both the account delete
|
|
// and the per-test truncation; left behind by the first test in this file.
|
|
await prisma.illnessSymptom.deleteMany({
|
|
where: { key: "round_trip_cough" },
|
|
});
|
|
await seedEveryTwoEndedModel(prisma);
|
|
await seedColumnShapes(prisma);
|
|
|
|
const models = readAuditModels(prisma, TWO_ENDED_MODELS);
|
|
const byName = new Map(models.map((m) => [m.name, m]));
|
|
const wheres = new Map(
|
|
models.map((m) => [m.name, ownerWhere(byName, prisma, m.name, OWNER_ID)]),
|
|
);
|
|
const enumValues = enumValuesFromSchema();
|
|
|
|
// Fill what the fixture left empty, so an empty column cannot come back
|
|
// empty and read as carried. One column at a time on one row, so a value
|
|
// the database refuses names its column instead of failing the batch.
|
|
const unfillable: string[] = [];
|
|
const seededRows = await snapshot(prisma, models, wheres);
|
|
for (const model of models) {
|
|
const rows = seededRows.get(model.name)!;
|
|
expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0);
|
|
for (const column of model.columns) {
|
|
if (
|
|
comparison(column) !== "value" &&
|
|
!(`${model.name}.${column.name}` in COLUMN_PRESENCE_ONLY)
|
|
) {
|
|
continue;
|
|
}
|
|
// Filling it would turn the fixture's row into a tombstone, and which
|
|
// models carry tombstones is a row decision, not a column one.
|
|
if (column.name === "deletedAt") continue;
|
|
if (rows.some((row) => !isDefaultValue(column, row[column.name]))) {
|
|
continue;
|
|
}
|
|
try {
|
|
await delegateOf(prisma, model).update({
|
|
where: primaryKeyWhere(model, rows[0]),
|
|
data: {
|
|
[column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
unfillable.push(
|
|
`${model.name}.${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
expect(unfillable, "columns the fill could not set").toEqual([]);
|
|
|
|
const before = await snapshot(prisma, models, wheres);
|
|
|
|
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
|
|
let json = "";
|
|
const previewCollector = await createBackupPreviewCollector();
|
|
const counts = await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
json += chunk;
|
|
},
|
|
{
|
|
purpose: "disaster-recovery",
|
|
exportedAt,
|
|
observe: previewCollector.observe,
|
|
},
|
|
);
|
|
// Every sealed column is filled here: the key uses the preview takes from
|
|
// the sections as they are written are the ones a read of the file finds.
|
|
const keysReadBack = new BackupKeyIdCollector();
|
|
keysReadBack.visit(JSON.parse(json));
|
|
expect(keysReadBack.keyIds().length).toBeGreaterThan(0);
|
|
expect(previewCollector.finish(counts)!.keys.toStored()).toEqual(
|
|
keysReadBack.toStored(),
|
|
);
|
|
|
|
// ── No ciphertext in a portable file ──────────────────────────────────
|
|
//
|
|
// Every sealed column is filled at this point, so the portable file of
|
|
// this account is the one place a column still exported as stored
|
|
// ciphertext shows up. A portable file is read and restored on hosts
|
|
// with other keys; ciphertext in it is noise there, and a restore either
|
|
// refuses the file or writes values nobody can open. That class was
|
|
// fixed one section at a time (cycle, mood labels, visits, vaccinations)
|
|
// and kept coming back, because each builder decides its own encoding.
|
|
// This reads what the builders actually wrote, rather than how their
|
|
// source is spelt. Limit: it sees members named `…Encrypted` only, the
|
|
// naming the key preflight relies on too.
|
|
const sealedIn = (text: string) => {
|
|
const collector = new BackupKeyIdCollector();
|
|
collector.visit(JSON.parse(text));
|
|
const sections = new Map<string, number>();
|
|
for (const [, use] of collector.entries()) {
|
|
for (const section of use.sections) {
|
|
sections.set(section, (sections.get(section) ?? 0) + use.count);
|
|
}
|
|
}
|
|
return sections;
|
|
};
|
|
// The floor: the same matcher over the disaster-recovery file of the same
|
|
// account, which carries every sealed column as ciphertext. A matcher
|
|
// that finds nothing there would make the portable check pass vacuously.
|
|
const drSealed = sealedIn(json);
|
|
expect(
|
|
[...drSealed.values()].reduce((a, b) => a + b, 0),
|
|
).toBeGreaterThanOrEqual(40);
|
|
expect(drSealed.size).toBeGreaterThanOrEqual(15);
|
|
let portableJson = "";
|
|
await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
portableJson += chunk;
|
|
},
|
|
{ purpose: "portable-export", exportedAt },
|
|
);
|
|
const portableSealed = [...sealedIn(portableJson).keys()].filter(
|
|
(section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section),
|
|
);
|
|
expect(
|
|
portableSealed,
|
|
"a portable file carries these sections' sealed columns as stored " +
|
|
"ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " +
|
|
"and seal them again on restore, or name the section in " +
|
|
"PORTABLE_CIPHERTEXT_ALLOWED with the reason",
|
|
).toEqual([]);
|
|
|
|
await prisma.user.delete({ where: { id: OWNER_ID } });
|
|
await createOwner(prisma);
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "COLUMN_ROUND_TRIP",
|
|
data: await legacyStreamedBlobFrom(async (write) => {
|
|
await write(json);
|
|
}),
|
|
},
|
|
});
|
|
const response = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
const body = await response.json();
|
|
expect(response.status, JSON.stringify(body)).toBe(200);
|
|
|
|
const after = await snapshot(prisma, models, wheres);
|
|
const { lost, compared } = lostColumns(models, before, after);
|
|
|
|
// A comparison over no columns agrees with everything. 632 at the time of
|
|
// writing; the headroom is for a column dropped by a migration, not for a
|
|
// data model that stopped being read.
|
|
expect(compared).toBeGreaterThanOrEqual(620);
|
|
expect(
|
|
lost.filter((key) => !(key in RELEASE_PENDING_COLUMNS)),
|
|
"these columns left with a value and came back without it. Carry each " +
|
|
"through the export and the restore, or name it in COLUMN_EXCLUSIONS " +
|
|
"with the reason the restore is right to drop it",
|
|
).toEqual([]);
|
|
expect(
|
|
Object.keys(RELEASE_PENDING_COLUMNS).filter((key) => !lost.includes(key)),
|
|
"these RELEASE_PENDING_COLUMNS entries come back now — drop them",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("notices a column nobody told it about", () => {
|
|
const model: AuditModel = {
|
|
name: "Probe",
|
|
delegate: "probe",
|
|
primaryKey: ["id"],
|
|
parents: {},
|
|
columns: [
|
|
{
|
|
model: "Probe",
|
|
name: "addedTomorrow",
|
|
kind: "scalar",
|
|
type: "String",
|
|
isList: false,
|
|
isOptional: true,
|
|
isId: false,
|
|
isUpdatedAt: false,
|
|
isForeignKey: false,
|
|
defaultText: null,
|
|
},
|
|
],
|
|
};
|
|
const before = new Map([["Probe", [{ id: "a", addedTomorrow: "kept" }]]]);
|
|
const after = new Map([["Probe", [{ id: "b", addedTomorrow: null }]]]);
|
|
expect(lostColumns([model], before, after)).toEqual({
|
|
lost: ["Probe.addedTomorrow"],
|
|
compared: 1,
|
|
});
|
|
});
|
|
|
|
it("names no exclusion for a column that no longer exists", () => {
|
|
const models = readAuditModels(getPrismaClient(), TWO_ENDED_MODELS);
|
|
const known = new Set(
|
|
models.flatMap((m) => m.columns.map((c) => `${m.name}.${c.name}`)),
|
|
);
|
|
const stale = [
|
|
...Object.keys(COLUMN_EXCLUSIONS),
|
|
...Object.keys(COLUMN_PRESENCE_ONLY),
|
|
...Object.keys(RELEASE_PENDING_COLUMNS),
|
|
...Object.keys(READ_THROUGH),
|
|
...Object.keys(FILL_OVERRIDES),
|
|
].filter((key) => !known.has(key));
|
|
expect(stale).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A 1x1 PNG: the smallest picture the avatar upload, and so the restore,
|
|
* accepts.
|
|
*/
|
|
const AVATAR_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
"base64",
|
|
);
|
|
|
|
/**
|
|
* Values for `User` columns where the type alone would produce one the
|
|
* restore rightly refuses: a vocabulary, a time zone, an AI endpoint, a
|
|
* threshold band, an image.
|
|
*/
|
|
const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
|
"User.gender": "FEMALE",
|
|
"User.locale": "fr",
|
|
"User.timezone": "America/Chicago",
|
|
"User.homeTimezone": "Asia/Tokyo",
|
|
"User.unitPreference": "imperial",
|
|
"User.glucoseUnit": "mmol/L",
|
|
"User.insightsPrivacyMode": "raw",
|
|
"User.aiProvider": "LOCAL",
|
|
"User.aiBaseUrl": "https://llm.example.com/v1",
|
|
"User.aiCompatBaseUrl": "https://gateway.example.com/v1",
|
|
"User.thresholdsJson": { WEIGHT: { min: 60, max: 80 } },
|
|
"User.globalExcludedInjectionSites": ["THIGH_LEFT"],
|
|
"User.avatarBytes": () => {
|
|
const bytes = new Uint8Array(new ArrayBuffer(AVATAR_PNG.byteLength));
|
|
bytes.set(AVATAR_PNG);
|
|
return bytes;
|
|
},
|
|
"User.avatarContentType": "image/png",
|
|
// v1.42 — a sealed home the way the writers seal it, so it opens.
|
|
"User.homeLocationEncrypted": () =>
|
|
sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }),
|
|
};
|
|
|
|
/**
|
|
* Columns of the account row the restore writes for reasons of its own, so
|
|
* "unchanged on the target" does not apply to them.
|
|
*/
|
|
const USER_ROW_STAMPED: Readonly<Record<string, string>> = {
|
|
updatedAt: "@updatedAt; Prisma stamps it on the restore's write",
|
|
syncResetAt:
|
|
"the restore stamps it so paired clients' delta cursors are refused",
|
|
};
|
|
|
|
describe("the account's own settings survive a real restore", () => {
|
|
it("brings every setting column back and leaves the rest of the target's row alone", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
|
|
const [userModel] = readAuditModels(prisma, ["User"]);
|
|
const enumValues = enumValuesFromSchema();
|
|
const settings = new Set<string>(ACCOUNT_SETTING_COLUMNS);
|
|
const classes = USER_COLUMN_BACKUP_CLASS as Readonly<
|
|
Record<string, string>
|
|
>;
|
|
|
|
// Every column the source account can be given a non-default value in,
|
|
// settings and everything else alike: a credential that came back on the
|
|
// target is as much a failure as a setting that did not.
|
|
const fillable = userModel.columns.filter(
|
|
(column) =>
|
|
!column.isId &&
|
|
!column.isUpdatedAt &&
|
|
column.name !== "username" &&
|
|
column.name !== "role",
|
|
);
|
|
const unfillable: string[] = [];
|
|
for (const column of fillable) {
|
|
try {
|
|
await prisma.user.update({
|
|
where: { id: OWNER_ID },
|
|
data: {
|
|
[column.name]: syntheticValue(
|
|
column,
|
|
enumValues,
|
|
USER_FILL_OVERRIDES,
|
|
),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
unfillable.push(
|
|
`${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`,
|
|
);
|
|
}
|
|
}
|
|
expect(unfillable, "columns the fill could not set").toEqual([]);
|
|
const source = (await prisma.user.findUniqueOrThrow({
|
|
where: { id: OWNER_ID },
|
|
})) as unknown as Record<string, unknown>;
|
|
const defaulted = [...settings].filter((name) =>
|
|
isDefaultValue(
|
|
userModel.columns.find((c) => c.name === name)!,
|
|
source[name],
|
|
),
|
|
);
|
|
expect(
|
|
defaulted,
|
|
"these settings still hold their default, so coming back with the " +
|
|
"default would read as carried",
|
|
).toEqual([]);
|
|
|
|
let json = "";
|
|
await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
json += chunk;
|
|
},
|
|
{
|
|
purpose: "disaster-recovery",
|
|
exportedAt: new Date("2026-08-01T00:00:00.000Z"),
|
|
},
|
|
);
|
|
// Nothing but setting columns reaches the file, and no secret at all.
|
|
const section = (JSON.parse(json) as { accountSettings: object })
|
|
.accountSettings;
|
|
expect(
|
|
Object.keys(section).filter(
|
|
(key) => !settings.has(key) && key !== "insuranceNumber",
|
|
),
|
|
).toEqual([]);
|
|
|
|
// A fresh account under the same id, the way an operator recreates one
|
|
// on a new host, given credentials of its own that must survive.
|
|
await prisma.user.delete({ where: { id: OWNER_ID } });
|
|
await createOwner(prisma);
|
|
await prisma.user.update({
|
|
where: { id: OWNER_ID },
|
|
data: {
|
|
passwordHash: "target-account-password-hash",
|
|
aiOpenaiKeyEncrypted: encrypt("target account key"),
|
|
totpSecretEncrypted: encrypt("target account totp"),
|
|
},
|
|
});
|
|
const target = (await prisma.user.findUniqueOrThrow({
|
|
where: { id: OWNER_ID },
|
|
})) as unknown as Record<string, unknown>;
|
|
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "ACCOUNT_SETTINGS_ROUND_TRIP",
|
|
data: await legacyStreamedBlobFrom(async (write) => {
|
|
await write(json);
|
|
}),
|
|
},
|
|
});
|
|
const response = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
const body = await response.json();
|
|
expect(response.status, JSON.stringify(body)).toBe(200);
|
|
const refused = (
|
|
body.data.skipped.catalogueKeys as Array<{ catalogue: string }>
|
|
).filter((entry) => entry.catalogue === "accountSetting");
|
|
expect(refused, "the restore refused a setting the fixture set").toEqual(
|
|
[],
|
|
);
|
|
|
|
const after = (await prisma.user.findUniqueOrThrow({
|
|
where: { id: OWNER_ID },
|
|
})) as unknown as Record<string, unknown>;
|
|
|
|
const lost = [...settings].filter(
|
|
(name) => opened(after[name]) !== opened(source[name]),
|
|
);
|
|
expect(
|
|
lost,
|
|
"these settings left with a value and came back without it",
|
|
).toEqual([]);
|
|
expect(settings.size).toBeGreaterThanOrEqual(55);
|
|
|
|
const overwritten = userModel.columns
|
|
.map((column) => column.name)
|
|
.filter(
|
|
(name) =>
|
|
classes[name] !== "SETTING" && !Object.hasOwn(USER_ROW_STAMPED, name),
|
|
)
|
|
.filter((name) => opened(after[name]) !== opened(target[name]));
|
|
expect(
|
|
overwritten,
|
|
"the restore changed these identity, credential or bookkeeping columns " +
|
|
"on the account it restored into",
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("automatic dose recording after a restore", () => {
|
|
it("starts its floor at the restore, so a dose removed before the export stays removed", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
await prisma.user.update({
|
|
where: { id: OWNER_ID },
|
|
data: { timezone: "UTC" },
|
|
});
|
|
const now = Date.now();
|
|
const dayAt12 = (offset: number) => {
|
|
const d = new Date(now + offset * 86_400_000);
|
|
return new Date(
|
|
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12),
|
|
);
|
|
};
|
|
const since = dayAt12(-10);
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Evening supplement",
|
|
dose: "1",
|
|
createdAt: dayAt12(-20),
|
|
autoMarkTaken: true,
|
|
autoMarkTakenSince: since,
|
|
schedules: {
|
|
create: {
|
|
windowStart: "12:00",
|
|
windowEnd: "12:00",
|
|
timesOfDay: ["12:00"],
|
|
rrule: "FREQ=DAILY",
|
|
scheduleType: "SCHEDULED",
|
|
},
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
// The pass filled the last days; the person removed the dose two days
|
|
// ago (they did not take it). A portable file carries no removal.
|
|
const removed = dayAt12(-2);
|
|
for (let offset = -6; offset <= -1; offset++) {
|
|
const at = dayAt12(offset);
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
scheduledFor: at,
|
|
takenAt: at,
|
|
source: "REMINDER",
|
|
autoRecorded: true,
|
|
deletedAt:
|
|
at.getTime() === removed.getTime()
|
|
? new Date(at.getTime() + 3_600_000)
|
|
: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "portable-export",
|
|
});
|
|
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 restoredFrom = Date.now();
|
|
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).toBe(200);
|
|
|
|
const restored = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
select: { id: true, autoMarkTaken: true, autoMarkTakenSince: true },
|
|
});
|
|
expect(restored.autoMarkTaken).toBe(true);
|
|
expect(restored.autoMarkTakenSince!.getTime()).toBeGreaterThanOrEqual(
|
|
restoredFrom,
|
|
);
|
|
|
|
const { runIntakeAutoTakePass } =
|
|
await import("@/lib/jobs/intake-auto-take");
|
|
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
|
|
const onRemovedSlot = await prisma.medicationIntakeEvent.count({
|
|
where: {
|
|
medicationId: restored.id,
|
|
deletedAt: null,
|
|
scheduledFor: removed,
|
|
},
|
|
});
|
|
expect(onRemovedSlot).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("a document kind added after the backup format", () => {
|
|
it("restores a sick note as a sick note", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
const documentBytes = encryptBytes(Buffer.from("sick note fixture"));
|
|
const contentEncrypted = new Uint8Array(
|
|
new ArrayBuffer(documentBytes.byteLength),
|
|
);
|
|
contentEncrypted.set(documentBytes);
|
|
await prisma.inboundDocument.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
kind: "SICK_NOTE",
|
|
title: "Certificate of incapacity",
|
|
mimeType: "application/pdf",
|
|
byteSize: documentBytes.byteLength,
|
|
contentEncrypted,
|
|
contentCodec: "binary2",
|
|
},
|
|
});
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
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.inboundDocument.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect(restored.kind).toBe("SICK_NOTE");
|
|
expect(restored.title).toBe("Certificate of incapacity");
|
|
});
|
|
});
|