mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The compression that landed last release was real and was measured in the wrong place. On a development machine with a multi-gigabyte heap the pass finished; the container the job runs in is capped at 1 GB, which gives V8 a 524 MB limit, and there the same pass died of memory about fifteen seconds in. Because the job shares the application process, that was not one failed backup — it was a restart, and every signed-in session on the host went with it. Reproduced before touching anything, under `--max-old-space-size=450` against a seeded account of 445 000 measurements, 30 000 mood entries and 60 000 intake events: `FATAL ERROR: Reached heap limit` at 34 s. The same fixture doubled to 890 000 measurements now completes inside a 296 MB limit, holding 16 MB of live heap where the old writer held 168 MB. The document is written incrementally now. `buildFullBackupPayload` takes a `deferBulk` mode that declares the three unbounded tables instead of reading them, and `streamFullBackupJson` walks the payload in key order, pulls those tables through page by page, and releases every other section as soon as its JSON reaches the sink. `packBackupBlobStreaming` feeds that into gzip and an AES-256-GCM stream, so nothing between the database rows and the stored value ever exists whole. Both writers go through the same per-row serialisers, and the round-trip suite pins their output byte for byte rather than trusting that they agree. The intake and mood reads are ordered totally now. A keyset cursor needs it, and a partial order also let two exports of one account differ over a tie, which every multi-dose day has. The streamed ciphertext is a new shape at rest, because GCM only produces its tag once the last block is in and a leading tag is what makes the old layout unstreamable. It carries a marker no older writer can produce, so the two families are disjoint by construction rather than by sniffing. All three stored shapes — plain, compressed, streamed — restore, proved both ways. The writer also watches its own live heap and stops at 80 % of V8's limit. An account too large for its host is now one failed backup, counted in the run's meta, instead of a restart for everybody on it. The stored column is the ceiling that is left: `data_backups.data` is a single `text` value, so the finished artifact has to exist as one string, and that is the only part of the job still growing with the record. The operator runbook says so, with the number at which it starts to matter.
2814 lines
102 KiB
TypeScript
2814 lines
102 KiB
TypeScript
/**
|
|
* Every model the plan claims travels both ways, proven by making it travel.
|
|
*
|
|
* `src/__tests__/backup-plan-classification.test.ts` reads source text. That
|
|
* catches a reader or a restore branch that was never written, and it is cheap
|
|
* enough to run on every `pnpm test` — but it cannot tell a branch that RUNS
|
|
* from one that merely exists. Verified rather than assumed: wrapping the
|
|
* nutrient restore in `if (false && …)` left all twelve of its checks green.
|
|
* Correct, green, and inert in production is the failure mode this repository
|
|
* has already shipped once, so the claim needs an end-to-end answer as well.
|
|
*
|
|
* This is that answer, and it is deliberately the LONG way round:
|
|
*
|
|
* 1. Seed one row of every model in `TWO_ENDED_MODELS` for one account.
|
|
* 2. Export through the real `buildFullBackupPayload`, disaster-recovery
|
|
* purpose — the same builder both export routes and the weekly worker use.
|
|
* 3. Delete the account. Not a hand-listed `deleteMany` sweep, which can only
|
|
* empty the tables somebody remembered: `user.delete()` cascades, so what
|
|
* survives is exactly what the schema says survives. A model that is
|
|
* neither wiped nor restored would otherwise be counted as recovered on
|
|
* the strength of the row that was never removed.
|
|
* 4. Re-create the account under the same id and restore through the real
|
|
* `POST /api/admin/backups/[id]/restore`.
|
|
* 5. Count each model back. Zero means the account came back short.
|
|
*
|
|
* The registry below is keyed by `TwoEndedModel`, so a name added to the plan's
|
|
* two-ended list without a row seeded and counted here does not compile.
|
|
*
|
|
* What the count-back alone does not prove: that a restored row carries the
|
|
* right VALUES. It asks whether the rows came back, not whether they came back
|
|
* intact — `admin-backups-canonical-roundtrip.test.ts` is where field-level
|
|
* fidelity is asserted for the file as a whole. A restore that wrote one row
|
|
* per model with every column defaulted would satisfy the counts and fail that
|
|
* one.
|
|
*
|
|
* So each section added since has brought a field-level assertion of its own
|
|
* for the column whose loss the count cannot see: the side effect's encrypted
|
|
* note beside its legacy plaintext one, the open pause era's null `resumedAt`,
|
|
* the Coach's permanent fence flag and its two bare-id references, the stock
|
|
* count that must not be recalculated from its ledger, the dose ramp's order,
|
|
* the reminder's snooze and skip cursors — and, at the bottom of this file, the
|
|
* document filing's PAIRING and the review decision on a staged fact. Each one
|
|
* is a thing a restore could get wrong while returning the right number of
|
|
* rows, which is the only test worth writing here.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { encrypt, encryptBytes } from "@/lib/crypto";
|
|
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { readNote } from "@/lib/crypto/note-cipher";
|
|
import {
|
|
decryptFactData,
|
|
decryptFactProvenance,
|
|
encryptFactData,
|
|
encryptFactProvenance,
|
|
} from "@/lib/documents/store";
|
|
import {
|
|
decryptWaveformFromBytes,
|
|
encryptWaveformToBytes,
|
|
} from "@/lib/withings/ecg-waveform-codec";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
|
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
|
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
|
import { packBackupBlobStreaming } from "@/lib/export/backup-blob";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
|
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserData: vi.fn(),
|
|
}));
|
|
|
|
const OWNER_ID = "round-trip-owner";
|
|
const AT = (iso: string) => new Date(iso);
|
|
const PHQ9_RESPONSES = JSON.stringify({
|
|
items: [2, 1, 2, 2, 1, 1, 2, 1, 2],
|
|
schema: 1,
|
|
});
|
|
const CONSENT_ARTEFACT = "JVBERi0xLjQKJWZpeHR1cmU=";
|
|
const INVENTORY_NOTE = "second pack, kept in the kitchen drawer";
|
|
const COACH_FACT = "does not tolerate ACE inhibitors";
|
|
const COACH_PLAN_CUE = "if the evening reading is over 140";
|
|
const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow";
|
|
const COACH_REMINDER_NOTE = "ask how the evening walks are going";
|
|
const COACH_SUMMARY = "earlier turns: weight trend and evening walks";
|
|
const 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";
|
|
const DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
|
|
const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)";
|
|
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
|
|
/** A short micro-volt strip. Long enough that a defaulted empty array shows. */
|
|
const ECG_SAMPLES = [-12, 4, 118, -33, 7, 2, -5, 61];
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
/** Counts what belongs to `userId`, following the relation where there is no own column. */
|
|
const COUNT_BACK: Record<
|
|
TwoEndedModel,
|
|
(prisma: PrismaClient, userId: string) => Promise<number>
|
|
> = {
|
|
Measurement: (p, userId) => p.measurement.count({ where: { userId } }),
|
|
IntradayCumulativeProfile: (p, userId) =>
|
|
p.intradayCumulativeProfile.count({ where: { userId } }),
|
|
Medication: (p, userId) => p.medication.count({ where: { userId } }),
|
|
MedicationSchedule: (p, userId) =>
|
|
p.medicationSchedule.count({ where: { medication: { userId } } }),
|
|
MedicationIntakeEvent: (p, userId) =>
|
|
p.medicationIntakeEvent.count({ where: { userId } }),
|
|
MedicationSideEffect: (p, userId) =>
|
|
p.medicationSideEffect.count({ where: { userId } }),
|
|
MedicationPauseEra: (p, userId) =>
|
|
p.medicationPauseEra.count({ where: { userId } }),
|
|
// No own `userId` column — reached through the drug, like the schedules.
|
|
MedicationDoseChange: (p, userId) =>
|
|
p.medicationDoseChange.count({ where: { medication: { userId } } }),
|
|
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
|
|
MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }),
|
|
MoodEntryTagLink: (p, userId) =>
|
|
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
|
|
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
|
|
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
|
|
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
|
|
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
|
|
FamilyHistoryEntry: (p, userId) =>
|
|
p.familyHistoryEntry.count({ where: { userId } }),
|
|
IllnessEpisode: (p, userId) => p.illnessEpisode.count({ where: { userId } }),
|
|
IllnessDayLog: (p, userId) => p.illnessDayLog.count({ where: { userId } }),
|
|
IllnessSymptomLink: (p, userId) =>
|
|
p.illnessSymptomLink.count({ where: { dayLog: { userId } } }),
|
|
UserHealthProfile: (p, userId) =>
|
|
p.userHealthProfile.count({ where: { userId } }),
|
|
HealthProfileFactRevision: (p, userId) =>
|
|
p.healthProfileFactRevision.count({ where: { userId } }),
|
|
CycleProfile: (p, userId) => p.cycleProfile.count({ where: { userId } }),
|
|
MenstrualCycle: (p, userId) => p.menstrualCycle.count({ where: { userId } }),
|
|
CycleDayLog: (p, userId) => p.cycleDayLog.count({ where: { userId } }),
|
|
CycleSymptom: (p, userId) => p.cycleSymptom.count({ where: { userId } }),
|
|
CycleSymptomLink: (p, userId) =>
|
|
p.cycleSymptomLink.count({ where: { dayLog: { userId } } }),
|
|
CustomMetric: (p, userId) => p.customMetric.count({ where: { userId } }),
|
|
CustomMetricEntry: (p, userId) =>
|
|
p.customMetricEntry.count({ where: { userId } }),
|
|
CorrelationPattern: (p, userId) =>
|
|
p.correlationPattern.count({ where: { userId } }),
|
|
HealthScoreRecord: (p, userId) =>
|
|
p.healthScoreRecord.count({ where: { userId } }),
|
|
Workout: (p, userId) => p.workout.count({ where: { userId } }),
|
|
NutrientIntakeDay: (p, userId) =>
|
|
p.nutrientIntakeDay.count({ where: { userId } }),
|
|
InboundDocument: (p, userId) =>
|
|
p.inboundDocument.count({ where: { userId } }),
|
|
Practitioner: (p, userId) => p.practitioner.count({ where: { userId } }),
|
|
Encounter: (p, userId) => p.encounter.count({ where: { userId } }),
|
|
EncounterDocumentLink: (p, userId) =>
|
|
p.encounterDocumentLink.count({ where: { userId } }),
|
|
EncounterLabLink: (p, userId) =>
|
|
p.encounterLabLink.count({ where: { userId } }),
|
|
EncounterConditionLink: (p, userId) =>
|
|
p.encounterConditionLink.count({ where: { userId } }),
|
|
VaccinationRecord: (p, userId) =>
|
|
p.vaccinationRecord.count({ where: { userId } }),
|
|
VaccinationDocumentLink: (p, userId) =>
|
|
p.vaccinationDocumentLink.count({ where: { userId } }),
|
|
MeasurementReminder: (p, userId) =>
|
|
p.measurementReminder.count({ where: { userId } }),
|
|
MeasurementReminderEvent: (p, userId) =>
|
|
p.measurementReminderEvent.count({ where: { userId } }),
|
|
CoachConversation: (p, userId) =>
|
|
p.coachConversation.count({ where: { userId } }),
|
|
// Neither the turn nor the attachment carries its own `userId` — both are
|
|
// reached through the thread that holds them.
|
|
CoachMessage: (p, userId) =>
|
|
p.coachMessage.count({ where: { conversation: { userId } } }),
|
|
CoachConversationDocument: (p, userId) =>
|
|
p.coachConversationDocument.count({ where: { conversation: { userId } } }),
|
|
CoachFact: (p, userId) => p.coachFact.count({ where: { userId } }),
|
|
CoachPlan: (p, userId) => p.coachPlan.count({ where: { userId } }),
|
|
CoachReminder: (p, userId) => p.coachReminder.count({ where: { userId } }),
|
|
MedicationInventoryItem: (p, userId) =>
|
|
p.medicationInventoryItem.count({ where: { userId } }),
|
|
// No own `userId` column — reached through the drug, like the schedules.
|
|
MedicationInventoryEvent: (p, userId) =>
|
|
p.medicationInventoryEvent.count({ where: { medication: { userId } } }),
|
|
MoodTagCategory: (p, userId) =>
|
|
p.moodTagCategory.count({ where: { userId } }),
|
|
MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }),
|
|
ReminderPhaseConfig: (p, userId) =>
|
|
p.reminderPhaseConfig.count({ where: { medication: { userId } } }),
|
|
MentalHealthAssessment: (p, userId) =>
|
|
p.mentalHealthAssessment.count({ where: { userId } }),
|
|
ConsentReceipt: (p, userId) => p.consentReceipt.count({ where: { userId } }),
|
|
DocumentConditionLink: (p, userId) =>
|
|
p.documentConditionLink.count({ where: { userId } }),
|
|
ExtractedFact: (p, userId) => p.extractedFact.count({ where: { userId } }),
|
|
PersonalRecord: (p, userId) => p.personalRecord.count({ where: { userId } }),
|
|
UserAchievement: (p, userId) =>
|
|
p.userAchievement.count({ where: { userId } }),
|
|
EnvironmentContext: (p, userId) =>
|
|
p.environmentContext.count({ where: { userId } }),
|
|
EnvironmentTravelLocation: (p, userId) =>
|
|
p.environmentTravelLocation.count({ where: { userId } }),
|
|
EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }),
|
|
// No own `userId` column — reached through the drug, like the schedules.
|
|
MedicationEfficacyTarget: (p, userId) =>
|
|
p.medicationEfficacyTarget.count({ where: { medication: { userId } } }),
|
|
MedicationScheduleRevision: (p, userId) =>
|
|
p.medicationScheduleRevision.count({ where: { medication: { userId } } }),
|
|
};
|
|
|
|
/**
|
|
* One row of every two-ended model, for one account.
|
|
*
|
|
* Minimal on purpose: this file asks whether a row survives the round trip, and
|
|
* a fat fixture only makes the answer slower to get. The mood factor and the
|
|
* cycle symptom are the account's OWN custom rows rather than seeded catalogue
|
|
* entries, because a catalogue row survives the account being deleted and would
|
|
* make the link tables look restored when nothing restored them.
|
|
*/
|
|
async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
|
// Held rather than discarded: the personal best below points at this row
|
|
// through a column that is a real foreign key in the database, so the
|
|
// fixture needs the id to build the reference the restore has to resolve.
|
|
const weightMeasurement = await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "WEIGHT",
|
|
value: 74.2,
|
|
unit: "kg",
|
|
measuredAt: AT("2026-07-01T07:00:00.000Z"),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
// A DELETED reading, kept as a tombstone. It is here because the obvious way
|
|
// to make the weekly backup cheaper — stop exporting soft-deleted rows, which
|
|
// on a real record is four rows in five — is wrong, and nothing in the tree
|
|
// said so out loud. A tombstone inside the retention window is what tells a
|
|
// synced client the row is gone (`/api/sync/changes` reads exactly these) and
|
|
// what makes a re-sent Apple Health sample a no-op instead of a resurrection.
|
|
// Drop them from a disaster-recovery file and a restore quietly brings back
|
|
// every measurement the account ever deleted on the next device sync.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: AT("2026-07-02T06:30:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "round-trip-deleted-sample",
|
|
syncVersion: 4,
|
|
deletedAt: AT("2026-07-20T09:15:00.000Z"),
|
|
},
|
|
});
|
|
// The EVENT row an ECG strip is filed against, and the strip itself. The
|
|
// pairing is the point: `EcgRecording.measurementId` is a real foreign key,
|
|
// so this is the reference whose remap has to survive the round trip rather
|
|
// than fail the transaction.
|
|
const rhythmEvent = await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
|
value: 1,
|
|
unit: "event",
|
|
measuredAt: AT("2026-06-15T14:22:00.000Z"),
|
|
source: "WITHINGS",
|
|
rhythmClassification: "IRREGULAR",
|
|
},
|
|
});
|
|
await prisma.ecgRecording.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
source: "WITHINGS",
|
|
externalRecordingId: "round-trip-signal-1",
|
|
recordedAt: AT("2026-06-15T14:22:00.000Z"),
|
|
waveformEncrypted: encryptWaveformToBytes(ECG_SAMPLES),
|
|
samplingFrequency: 300,
|
|
sampleCount: ECG_SAMPLES.length,
|
|
durationSeconds: ECG_SAMPLES.length / 300,
|
|
lead: "I",
|
|
averageHeartRate: 88,
|
|
rhythmClassification: "IRREGULAR",
|
|
measurementId: rhythmEvent.id,
|
|
},
|
|
});
|
|
await prisma.intradayCumulativeProfile.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "ACTIVITY_STEPS",
|
|
dateKey: "2026-07-01",
|
|
// One slot per hour — the restore refuses a profile of any other length,
|
|
// because a short row lands in the table and is dropped on read.
|
|
hourlyCumulative: Array.from({ length: 24 }, (_, hour) => hour * 350),
|
|
dayTotal: 8050,
|
|
sampleCount: 24,
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip tablet",
|
|
dose: "5 mg",
|
|
schedules: {
|
|
create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
|
|
},
|
|
},
|
|
});
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
scheduledFor: AT("2026-07-01T08:00:00.000Z"),
|
|
takenAt: AT("2026-07-01T08:04:00.000Z"),
|
|
},
|
|
});
|
|
// The one row in this fixture that is also checked field by field after the
|
|
// restore — see the assertion at the end of the test for why.
|
|
await prisma.medicationSideEffect.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
occurredAt: AT("2026-07-01T21:00:00.000Z"),
|
|
category: "GI",
|
|
entry: "NAUSEA",
|
|
severity: 3,
|
|
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
|
|
},
|
|
});
|
|
// Two eras, and the open one carries the weight. `resumedAt: null` means the
|
|
// drug is paused right now; a restore that closed it at restore time would
|
|
// still count one row back here and read as recovered. The field-level
|
|
// assertion at the end of the test is what holds the null.
|
|
// A two-step ramp. The ORDER is the content here: same drug, same unit,
|
|
// 5 mg then 10 mg. A restore that returned them reversed would still count
|
|
// two rows back and would describe the opposite clinical story.
|
|
// One open pack and one used up, plus the two ledger rows behind the open
|
|
// one. The count on the pack is deliberately NOT the sum of its events: 30
|
|
// in, 8 consumed, and the remaining count says 21 because the person threw
|
|
// one away without recording it. A restore that recomputed the count from
|
|
// the ledger would "fix" that to 22 and disagree with every low-stock
|
|
// reminder the account has already received.
|
|
// A PHQ-9 administration whose item 9 was answered above zero. The flag and
|
|
// the encrypted answers are seeded to DISAGREE with a naive recomputation:
|
|
// the flag says true, and a restore that decided to recompute it would have
|
|
// to decrypt the blob to do so, which is the handling this data is kept away
|
|
// from. The assertion below reads the flag and the ciphertext separately.
|
|
await prisma.mentalHealthAssessment.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
instrument: "PHQ9",
|
|
locale: "de",
|
|
version: "standard",
|
|
responsesEncrypted: encryptToBytes(PHQ9_RESPONSES),
|
|
totalScore: 14,
|
|
severityBand: "moderate",
|
|
item9Flagged: true,
|
|
crisisShownAt: AT("2026-07-05T18:02:00.000Z"),
|
|
takenAt: AT("2026-07-05T18:00:00.000Z"),
|
|
tz: "Europe/Berlin",
|
|
source: "WEB",
|
|
},
|
|
});
|
|
|
|
// One active consent and one revoked. The revocation is the part a restore
|
|
// can quietly lose: dropping `revokedAt` turns a withdrawn agreement back
|
|
// into a standing one, and the latest-per-user lookup shortcuts on exactly
|
|
// that column.
|
|
await prisma.consentReceipt.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
kind: "ai-processing",
|
|
artefact: CONSENT_ARTEFACT,
|
|
signedAt: AT("2026-06-01T09:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
kind: "research-sharing",
|
|
artefact: "eyJhbGciOiJub25lIn0.e30.",
|
|
signedAt: AT("2026-06-02T09:00:00.000Z"),
|
|
revokedAt: AT("2026-07-10T11:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
// Tuned away from every default, and one threshold in PERCENT rather than
|
|
// MINUTES: a restore that fell back to the schema defaults would return four
|
|
// plausible numbers and silently change when this drug turns orange.
|
|
await prisma.reminderPhaseConfig.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
greenValue: 45,
|
|
greenMode: "MINUTES",
|
|
yellowValue: 20,
|
|
yellowMode: "PERCENT",
|
|
orangeValue: 5,
|
|
orangeMode: "MINUTES",
|
|
redValue: 180,
|
|
redMode: "MINUTES",
|
|
},
|
|
});
|
|
|
|
const openPack = await prisma.medicationInventoryItem.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
state: "ACTIVE",
|
|
containerType: "BLISTER",
|
|
unitsTotal: "30",
|
|
unitsRemaining: "21",
|
|
firstUseAt: AT("2026-07-01T08:00:00.000Z"),
|
|
expiresAt: AT("2027-01-31T00:00:00.000Z"),
|
|
manufacturer: "Fixture Pharma",
|
|
doseStrength: "10 mg",
|
|
notesEncrypted: encryptToBytes(INVENTORY_NOTE),
|
|
},
|
|
});
|
|
await prisma.medicationInventoryItem.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
state: "USED_UP",
|
|
containerType: "BOTTLE",
|
|
unitsTotal: "60",
|
|
unitsRemaining: "0",
|
|
},
|
|
});
|
|
await prisma.medicationInventoryEvent.createMany({
|
|
data: [
|
|
{
|
|
medicationId: medication.id,
|
|
delta: 30,
|
|
reason: "purchased",
|
|
occurredAt: AT("2026-07-01T07:00:00.000Z"),
|
|
},
|
|
{
|
|
medicationId: medication.id,
|
|
delta: -8,
|
|
reason: "consumed",
|
|
occurredAt: AT("2026-07-09T07:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
await prisma.medicationDoseChange.createMany({
|
|
data: [
|
|
{
|
|
medicationId: medication.id,
|
|
effectiveFrom: AT("2026-04-01T08:00:00.000Z"),
|
|
doseValue: 5,
|
|
doseUnit: "mg",
|
|
noteEncrypted: encryptToBytes(DOSE_CHANGE_NOTE),
|
|
},
|
|
{
|
|
medicationId: medication.id,
|
|
effectiveFrom: AT("2026-06-01T08:00:00.000Z"),
|
|
doseValue: 10,
|
|
doseUnit: "mg",
|
|
noteEncrypted: null,
|
|
},
|
|
],
|
|
});
|
|
// Three archived eras, and the CHAIN through them is the content. The
|
|
// middle one was corrected, so it points at the third; the first and the
|
|
// third stand on their own. A restore that returned three rows with every
|
|
// link null satisfies any count and has destroyed the only thing the
|
|
// pointer says — every era consumer skips a superseded row, so the
|
|
// corrected middle era would come back live beside the correction that
|
|
// replaced it and the timeline would show the same window twice.
|
|
await prisma.medicationScheduleRevision.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-01-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-03-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }],
|
|
source: "ARCHIVED",
|
|
},
|
|
});
|
|
const correctionEra = await prisma.medicationScheduleRevision.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-03-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-05-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }],
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
await prisma.medicationScheduleRevision.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-02-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-03-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }],
|
|
source: "ARCHIVED",
|
|
supersededByRevisionId: correctionEra.id,
|
|
},
|
|
});
|
|
|
|
await prisma.medicationPauseEra.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
pausedAt: AT("2026-05-02T08:00:00.000Z"),
|
|
resumedAt: AT("2026-05-20T08:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
pausedAt: AT("2026-07-15T08:00:00.000Z"),
|
|
resumedAt: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
// A RATED tag the account defined itself — the export carries only rated
|
|
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
|
|
const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({
|
|
where: { userId: null },
|
|
});
|
|
// A category the account created itself, not a seeded one. It cascades away
|
|
// with the user, and `MoodTag.categoryId` is a real foreign key — so before
|
|
// the category travelled, this one row made the restore violate
|
|
// `mood_tags_category_id_fkey` and answer 500 with the account left empty.
|
|
const ownCategory = await prisma.moodTagCategory.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
key: "round_trip_own_category",
|
|
labelKey: "mood.category.roundTripOwn",
|
|
sortOrder: 90,
|
|
},
|
|
});
|
|
const moodTag = await prisma.moodTag.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
categoryId: ownCategory.id,
|
|
key: "round_trip_factor",
|
|
labelKey: "mood.tag.roundTripFactor",
|
|
kind: "RATED",
|
|
scaleMin: 1,
|
|
scaleMax: 5,
|
|
},
|
|
});
|
|
// A SEEDED tag the account chose to hide. The id differs per instance, so
|
|
// this is the row that proves the hidden set travels by key.
|
|
const seededTagToHide = await prisma.moodTag.findFirstOrThrow({
|
|
where: { userId: null },
|
|
});
|
|
await prisma.moodTagHidden.create({
|
|
data: { userId: OWNER_ID, moodTagId: seededTagToHide.id },
|
|
});
|
|
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
date: "2026-07-01",
|
|
mood: "GUT",
|
|
score: 4,
|
|
source: "MOODLOG",
|
|
moodLoggedAt: AT("2026-07-01T20:00:00.000Z"),
|
|
tagLinks: { create: { moodTagId: moodTag.id, rating: 4 } },
|
|
context: {
|
|
create: {
|
|
userId: OWNER_ID,
|
|
workStatus: "regular",
|
|
contactCircles: JSON.stringify(["family"]),
|
|
leisureJoy: 6,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const biomarker = await prisma.biomarker.create({
|
|
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
|
});
|
|
const labResult = await prisma.labResult.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
biomarkerId: biomarker.id,
|
|
analyte: "Ferritin",
|
|
value: 91,
|
|
unit: "ng/mL",
|
|
takenAt: AT("2026-06-30T09:00:00.000Z"),
|
|
},
|
|
});
|
|
// What the drug above is FOR, pinned to the analyte just created. The two
|
|
// rows are seeded at opposite ends of this fixture on purpose: the drug is
|
|
// restored hundreds of lines before the analyte, so this pairing is what
|
|
// proves the second pass runs late enough to resolve the name.
|
|
await prisma.medicationEfficacyTarget.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
biomarkerId: biomarker.id,
|
|
primary: true,
|
|
},
|
|
});
|
|
await prisma.allergy.create({
|
|
data: { userId: OWNER_ID, substance: "Penicillin" },
|
|
});
|
|
await prisma.familyHistoryEntry.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
relationship: "MOTHER",
|
|
condition: "Type 2 diabetes",
|
|
},
|
|
});
|
|
|
|
const episode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Cold",
|
|
type: "INFECTION",
|
|
onsetAt: AT("2026-06-20T00:00:00.000Z"),
|
|
},
|
|
});
|
|
// A SECOND condition, and the one the document below is actually filed
|
|
// against. Two episodes is what makes the filing assertion mean something:
|
|
// with one, a restore that pointed every link at the first condition it
|
|
// found would return the right count and read as correct.
|
|
const filedEpisode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Iron deficiency",
|
|
type: "CHRONIC",
|
|
onsetAt: AT("2026-03-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
const illnessSymptom = await prisma.illnessSymptom.create({
|
|
data: { key: "round_trip_cough", labelKey: "illness.symptom.roundTrip" },
|
|
});
|
|
await prisma.illnessDayLog.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
episodeId: episode.id,
|
|
date: "2026-06-21",
|
|
symptomLinks: { create: { symptomId: illnessSymptom.id, severity: 2 } },
|
|
},
|
|
});
|
|
|
|
await prisma.userHealthProfile.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
aboutMeEncrypted: encryptToBytes("Runs three times a week"),
|
|
},
|
|
});
|
|
await prisma.healthProfileFactRevision.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
kind: "SMOKING_STATUS",
|
|
valueEncrypted: encryptToBytes("never"),
|
|
validFrom: AT("2026-01-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
await prisma.cycleProfile.create({
|
|
data: { userId: OWNER_ID, cycleTrackingEnabled: true },
|
|
});
|
|
const cycle = await prisma.menstrualCycle.create({
|
|
data: { userId: OWNER_ID, startDate: "2026-06-01" },
|
|
});
|
|
const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
|
const cycleSymptom = await prisma.cycleSymptom.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
categoryId: cycleCategory.id,
|
|
key: "custom:round-trip-cramps",
|
|
labelKey: "cycle.symptom.custom",
|
|
labelEncrypted: encrypt("Round-trip cramps"),
|
|
},
|
|
});
|
|
await prisma.cycleDayLog.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
cycleId: cycle.id,
|
|
date: "2026-06-02",
|
|
flow: "HEAVY",
|
|
symptomLinks: { create: { symptomId: cycleSymptom.id } },
|
|
},
|
|
});
|
|
|
|
await prisma.customMetric.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Grip strength",
|
|
unit: "kg",
|
|
entries: {
|
|
create: {
|
|
userId: OWNER_ID,
|
|
value: 44,
|
|
unit: "kg",
|
|
measuredAt: AT("2026-07-01T18:00:00.000Z"),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await prisma.correlationPattern.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
canonicalKey: `p1:${"a1".repeat(32)}`,
|
|
family: "sleep",
|
|
factorKey: "sleep_duration",
|
|
outcomeKey: "weight",
|
|
lagDays: 1,
|
|
sampleSize: 30,
|
|
effectSize: 0.42,
|
|
pValue: 0.01,
|
|
evidenceHash: "b2".repeat(32),
|
|
lastComputedAt: AT("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
await prisma.healthScoreRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
dayKey: "2026-07-01",
|
|
timezone: "Europe/Berlin",
|
|
composite: 71,
|
|
band: "green",
|
|
scoreVersion: 1,
|
|
composition: ["activity", "sleep"],
|
|
pillarScores: { activity: 70, sleep: 72 },
|
|
inputFingerprint: "c3".repeat(32),
|
|
},
|
|
});
|
|
|
|
await prisma.workout.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
sportType: "RUNNING",
|
|
startedAt: AT("2026-06-29T06:00:00.000Z"),
|
|
endedAt: AT("2026-06-29T06:30:00.000Z"),
|
|
durationSec: 1800,
|
|
},
|
|
});
|
|
await prisma.nutrientIntakeDay.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
day: "2026-07-01",
|
|
nutrient: "WATER",
|
|
amount: 2100,
|
|
unit: "ml",
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
|
|
const documentBytes = encryptBytes(Buffer.from("round-trip document bytes"));
|
|
const contentEncrypted = new Uint8Array(
|
|
new ArrayBuffer(documentBytes.byteLength),
|
|
);
|
|
contentEncrypted.set(documentBytes);
|
|
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",
|
|
},
|
|
});
|
|
|
|
// 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),
|
|
providerType: "anthropic",
|
|
model: "claude-opus-5",
|
|
tokensUsed: 812,
|
|
createdAt: AT("2026-07-20T09:59:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await prisma.coachConversation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
title: "About my June labs",
|
|
documentScoped: true,
|
|
messages: {
|
|
create: [
|
|
{
|
|
role: "user",
|
|
encryptedContent: encryptToBytes("What does the ferritin mean?"),
|
|
createdAt: AT("2026-07-21T08:00:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
attachments: {
|
|
create: [
|
|
{ documentId: document.id, addedAt: AT("2026-07-21T07:59:00.000Z") },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
// What the Coach keeps between threads, seeded so both references are
|
|
// exercised: the fact and the plan point back at the thread they came out
|
|
// of, and the reminder points at the plan.
|
|
const healthThread = await prisma.coachConversation.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, documentScoped: false },
|
|
});
|
|
await prisma.coachFact.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
factEncrypted: encryptToBytes(COACH_FACT),
|
|
category: "constraint",
|
|
confidence: 80,
|
|
sourceConversationId: healthThread.id,
|
|
},
|
|
});
|
|
const plan = await prisma.coachPlan.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
metric: "BLOOD_PRESSURE",
|
|
ifCueEncrypted: encryptToBytes(COACH_PLAN_CUE),
|
|
thenActionEncrypted: encryptToBytes(COACH_PLAN_ACTION),
|
|
targetEncrypted: encryptToBytes("under 130 by October"),
|
|
status: "active",
|
|
reviewDate: AT("2026-10-01T00:00:00.000Z"),
|
|
sourceConversationId: healthThread.id,
|
|
},
|
|
});
|
|
await prisma.coachReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
noteEncrypted: encryptToBytes(COACH_REMINDER_NOTE),
|
|
metric: "BLOOD_PRESSURE",
|
|
relatedPlanId: plan.id,
|
|
triggerKind: "date",
|
|
dueAt: AT("2026-09-01T07:00:00.000Z"),
|
|
status: "active",
|
|
source: "extractor",
|
|
sourceConversationId: healthThread.id,
|
|
surfaceCount: 2,
|
|
},
|
|
});
|
|
|
|
// One visit, the practice it was at, and one link of each of the three
|
|
// kinds. The links reach the document, the lab result and the condition
|
|
// episode seeded above rather than fresh rows, because a link to something
|
|
// the restore did not put back is the exact case the restore reports as a
|
|
// skip — seeding it against a row that does come back is what makes a
|
|
// returning count mean the filing survived.
|
|
const practitioner = await prisma.practitioner.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip practice",
|
|
specialty: "Internal medicine",
|
|
noteEncrypted: encryptToBytes("ring the upper bell"),
|
|
},
|
|
});
|
|
|
|
// One Vorsorge reminder with the v1.37.20 skip/snooze columns all set, and
|
|
// two completion-ledger rows — one honest satisfy, one honest skip. Fat like
|
|
// the vaccination fixture below and for the same reason: the count-back
|
|
// proves the rows returned, the column-by-column assertion after the restore
|
|
// proves they returned WHOLE. The encounter and the dose below point their
|
|
// `reminderId` here, because both references now remap against the restored
|
|
// reminders and this fixture is what proves they survive rather than drop.
|
|
const reminder = await prisma.measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Blutdruck messen",
|
|
measurementType: "BLOOD_PRESSURE_SYS",
|
|
intervalDays: 7,
|
|
notifyHour: 8,
|
|
location: "Zuhause",
|
|
nextDueAt: AT("2026-07-08T06:00:00.000Z"),
|
|
lastSatisfiedAt: AT("2026-07-01T06:05:00.000Z"),
|
|
vaccinationAntigen: "tetanus",
|
|
snoozedUntil: AT("2026-07-08T06:00:00.000Z"),
|
|
lastSkippedAt: AT("2026-06-24T06:00:00.000Z"),
|
|
skipCount: 2,
|
|
},
|
|
});
|
|
await prisma.measurementReminderEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
reminderId: reminder.id,
|
|
kind: "SATISFIED",
|
|
occurredAt: AT("2026-07-01T06:05:00.000Z"),
|
|
onTime: true,
|
|
source: "manual",
|
|
},
|
|
});
|
|
await prisma.measurementReminderEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
reminderId: reminder.id,
|
|
kind: "SKIPPED",
|
|
occurredAt: AT("2026-06-24T06:00:00.000Z"),
|
|
onTime: false,
|
|
source: "skip",
|
|
},
|
|
});
|
|
|
|
const encounter = await prisma.encounter.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: AT("2026-06-30T08:00:00.000Z"),
|
|
status: "DONE",
|
|
kind: "ROUTINE",
|
|
practitionerId: practitioner.id,
|
|
reasonEncrypted: encryptToBytes("annual check"),
|
|
outcomeEncrypted: encryptToBytes(
|
|
"ferritin back in range, recheck in a year",
|
|
),
|
|
reminderId: reminder.id,
|
|
},
|
|
});
|
|
await prisma.encounterDocumentLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
await prisma.encounterLabLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
labResultId: labResult.id,
|
|
},
|
|
});
|
|
await prisma.encounterConditionLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
episodeId: episode.id,
|
|
},
|
|
});
|
|
|
|
// One dose with every optional column filled, and the page it was
|
|
// transcribed from. Fat on purpose, against this file's own minimal-fixture
|
|
// rule: a count-back proves the row returned, and the assertion after the
|
|
// restore reads each column back so it also proves the row returned WHOLE.
|
|
// The practitioner, the encounter and the reminder are the ones seeded
|
|
// above, because all three are remapped on the way back and a reference to
|
|
// something the restore did not put back is a different case (covered by
|
|
// the skip test below). `reminderId` used to be deliberately absent here —
|
|
// it was the one reference that could never resolve — and is deliberately
|
|
// present since v1.37.20, because resolving is now the behaviour under test.
|
|
const vaccination = await prisma.vaccinationRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: AT("2026-05-14T00:00:00.000Z"),
|
|
antigenSlug: "tdap",
|
|
vaccineName: "Tetanus, diphtheria and pertussis",
|
|
doseNumber: 3,
|
|
seriesDoses: 3,
|
|
lotNumber: "RT-4471",
|
|
site: "LEFT_ARM",
|
|
practitionerId: practitioner.id,
|
|
encounterId: encounter.id,
|
|
reminderId: reminder.id,
|
|
noteEncrypted: encryptToBytes("sore arm for a day, nothing else"),
|
|
},
|
|
});
|
|
await prisma.vaccinationDocumentLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
vaccinationId: vaccination.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
|
|
// Three bests, chosen for what a count cannot see.
|
|
//
|
|
// The first points at the measurement it was found in. That column is a
|
|
// foreign key in the database while the Prisma schema declares no relation
|
|
// for it, so this row is what proves the pointer resolves against the
|
|
// measurements the restore wrote, and would prove the opposite loudly, by
|
|
// failing the constraint and taking the whole restore with it.
|
|
//
|
|
// The other two share a metric type AND an instant and differ only in the
|
|
// sport slot: the same 5 km and 10 km bests a runner has. A restore that
|
|
// dropped the slot would return two rows describing one record twice, and
|
|
// the faster time would be presented as the 10 km best.
|
|
await prisma.personalRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
metricType: "WEIGHT",
|
|
direction: "MIN",
|
|
value: 74.2,
|
|
unit: "kg",
|
|
achievedAt: AT("2026-07-01T07:00:00.000Z"),
|
|
sourceMeasurementId: weightMeasurement.id,
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
await prisma.personalRecord.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "running_5km_time",
|
|
direction: "MIN",
|
|
value: 1512,
|
|
unit: "s",
|
|
achievedAt: AT("2026-05-18T09:20:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "workout-pr-5km",
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "running_10km_time",
|
|
direction: "MIN",
|
|
value: 3184,
|
|
unit: "s",
|
|
achievedAt: AT("2026-05-18T09:20:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "workout-pr-10km",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Two badges, both earned long before this fixture runs. The second names a
|
|
// definition no catalogue in this build ships: a file can be older than the
|
|
// release reading it, and the restore must carry the row rather than judge
|
|
// it, exactly as it carries a retired antigen slug.
|
|
await prisma.userAchievement.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
achievementId: "intake-total-10",
|
|
unlockedAt: AT("2026-02-21T10:15:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
achievementId: "retired-badge-from-an-older-release",
|
|
unlockedAt: AT("2025-11-05T08:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
// A fortnight in Barcelona, and one day of it recorded as a reading.
|
|
//
|
|
// The pair is the fixture. The home day and the trip day carry different
|
|
// coordinates and different weather, and the trip day's reading only makes
|
|
// sense next to the period that explains it: with the period gone, the next
|
|
// refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over
|
|
// it. The assertion after the restore reads the two back TOGETHER.
|
|
await prisma.environmentTravelLocation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate: "2026-06-10",
|
|
endDate: "2026-06-20",
|
|
lat: 41.3874,
|
|
lon: 2.1686,
|
|
label: "Barcelona",
|
|
},
|
|
});
|
|
await prisma.environmentContext.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
date: "2026-06-15",
|
|
lat: 41.3874,
|
|
lon: 2.1686,
|
|
locationLabel: "Barcelona",
|
|
source: "TRAVEL",
|
|
tempMin: 19.4,
|
|
tempMax: 28.1,
|
|
tempMean: 23.6,
|
|
apparentMean: 25.2,
|
|
sunshineSec: 39_600,
|
|
daylightSec: 52_800,
|
|
precipSum: 0,
|
|
pressureMean: 1016.4,
|
|
pressureDelta: 3.2,
|
|
humidityMean: 63,
|
|
cloudMean: 12,
|
|
weatherCode: 1,
|
|
fetchedAt: AT("2026-06-16T03:15:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
date: "2026-07-01",
|
|
lat: 52.52,
|
|
lon: 13.405,
|
|
locationLabel: "Berlin",
|
|
source: "HOME",
|
|
tempMin: 13.1,
|
|
tempMax: 22.7,
|
|
tempMean: 17.9,
|
|
apparentMean: 17.1,
|
|
sunshineSec: 28_800,
|
|
daylightSec: 59_400,
|
|
precipSum: 4.6,
|
|
pressureMean: 1008.9,
|
|
pressureDelta: 7.8,
|
|
humidityMean: 74,
|
|
cloudMean: 68,
|
|
weatherCode: 61,
|
|
fetchedAt: AT("2026-07-02T03:15:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
async function createOwner(prisma: PrismaClient) {
|
|
return prisma.user.create({
|
|
data: {
|
|
id: OWNER_ID,
|
|
username: "round-trip-owner",
|
|
email: "round-trip-owner@example.test",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function seedAdminSession(prisma: PrismaClient) {
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "round-trip-admin",
|
|
email: "round-trip-admin@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return admin;
|
|
}
|
|
|
|
describe("every model the plan claims two-ended survives a real restore", () => {
|
|
it("carries a seeded row of each model out of the account and back in", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
await seedEveryTwoEndedModel(prisma);
|
|
|
|
// The fixture has to be complete before the account is deleted, or a model
|
|
// seeded with zero rows would come back with zero and read as restored.
|
|
const seeded = await Promise.all(
|
|
TWO_ENDED_MODELS.map(async (model) => [
|
|
model,
|
|
await COUNT_BACK[model](prisma, OWNER_ID),
|
|
]),
|
|
);
|
|
expect(
|
|
seeded.filter(([, count]) => count === 0).map(([model]) => model),
|
|
"seeded no row for these, so the assertion after the restore would " +
|
|
"compare nothing against nothing and pass",
|
|
).toEqual([]);
|
|
|
|
// Pinned so the two writers below describe the same instant and can be
|
|
// compared byte for byte; `exportedAt` otherwise defaults to `new Date()`.
|
|
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
exportedAt,
|
|
});
|
|
|
|
// The writer the weekly job actually uses never builds this payload: it
|
|
// pulls the unbounded tables through page by page and serialises each row
|
|
// straight into the cipher, because materialising them is what used to
|
|
// exhaust the heap. Two writers is two chances to drift, so the file it
|
|
// produces is pinned against the one above — every per-table assertion
|
|
// below then covers BOTH, since the row that gets restored comes from the
|
|
// streamed bytes.
|
|
let streamedJson = "";
|
|
await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
streamedJson += chunk;
|
|
},
|
|
{ purpose: "disaster-recovery", exportedAt },
|
|
);
|
|
expect(
|
|
streamedJson,
|
|
"the streaming writer and the materialising builder disagree; the " +
|
|
"weekly backup no longer contains what this test proves restorable",
|
|
).toBe(JSON.stringify(payload));
|
|
|
|
// The tombstone has to be IN the file. A disaster-recovery payload that
|
|
// carries only live rows is smaller and cheaper and wrong: the restore
|
|
// wipes the account first, so a deletion that is not in the file is a
|
|
// deletion the account no longer knows it made, and the next device sync
|
|
// re-inserts every one of those samples as fresh readings.
|
|
const exportedMeasurements = payload.measurements as Array<{
|
|
externalId: string | null;
|
|
deletedAt: string | null;
|
|
syncVersion: number;
|
|
}>;
|
|
expect(
|
|
exportedMeasurements.find(
|
|
(row) => row.externalId === "round-trip-deleted-sample",
|
|
),
|
|
"a soft-deleted reading is missing from the disaster-recovery payload",
|
|
).toMatchObject({
|
|
deletedAt: "2026-07-20T09:15:00.000Z",
|
|
syncVersion: 4,
|
|
});
|
|
|
|
// Cascade rather than a hand-listed sweep — see the file comment.
|
|
await prisma.user.delete({ where: { id: OWNER_ID } });
|
|
await createOwner(prisma);
|
|
const emptied = await Promise.all(
|
|
TWO_ENDED_MODELS.map((model) => COUNT_BACK[model](prisma, OWNER_ID)),
|
|
);
|
|
expect(
|
|
emptied.reduce((total, count) => total + count, 0),
|
|
"the account still owns rows after being deleted, so the restore is " +
|
|
"not what put them there",
|
|
).toBe(0);
|
|
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "TWO_ENDED_ROUND_TRIP",
|
|
// The envelope the weekly worker actually writes: stream the JSON
|
|
// through gzip and the cipher without ever holding a whole copy. The
|
|
// three cases further down deliberately keep the plain `encrypt(json)`
|
|
// form, which is what every row written before any of this existed
|
|
// looks like — an operator's newest usable copy may well be one of
|
|
// those, so every arm has to reach the restore route.
|
|
data: await packBackupBlobStreaming(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 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 },
|
|
});
|
|
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: 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,
|
|
})),
|
|
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?",
|
|
documentScoped: false,
|
|
summary: COACH_SUMMARY,
|
|
summaryTurnCount: 4,
|
|
turns: [
|
|
{
|
|
role: "user",
|
|
content: COACH_USER_TURN,
|
|
model: null,
|
|
tokensUsed: null,
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: COACH_ASSISTANT_TURN,
|
|
model: "claude-opus-5",
|
|
tokensUsed: 812,
|
|
},
|
|
],
|
|
attachments: [],
|
|
},
|
|
{
|
|
title: "About my June labs",
|
|
documentScoped: true,
|
|
summary: null,
|
|
summaryTurnCount: 0,
|
|
turns: [
|
|
{
|
|
role: "user",
|
|
content: "What does the ferritin mean?",
|
|
model: null,
|
|
tokensUsed: null,
|
|
},
|
|
],
|
|
attachments: [vaultDocument.id],
|
|
},
|
|
]);
|
|
|
|
// The Coach's memory, and both of its references still pointing at
|
|
// something. Neither is a foreign key, so a restore that wrote them back
|
|
// as null would break nothing, satisfy every count, and leave the account
|
|
// with a Coach that remembers a fact and no longer knows where it came
|
|
// from.
|
|
const restoredThread = await prisma.coachConversation.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, documentScoped: false },
|
|
});
|
|
const restoredPlan = await prisma.coachPlan.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
const fact = await prisma.coachFact.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect({
|
|
fact: decryptFromBytes(fact.factEncrypted),
|
|
category: fact.category,
|
|
confidence: fact.confidence,
|
|
source: fact.sourceConversationId,
|
|
deletedAt: fact.deletedAt,
|
|
}).toEqual({
|
|
fact: COACH_FACT,
|
|
category: "constraint",
|
|
confidence: 80,
|
|
source: restoredThread.id,
|
|
deletedAt: null,
|
|
});
|
|
expect({
|
|
metric: restoredPlan.metric,
|
|
ifCue: decryptFromBytes(restoredPlan.ifCueEncrypted),
|
|
thenAction: decryptFromBytes(restoredPlan.thenActionEncrypted),
|
|
target: restoredPlan.targetEncrypted
|
|
? decryptFromBytes(restoredPlan.targetEncrypted)
|
|
: null,
|
|
status: restoredPlan.status,
|
|
source: restoredPlan.sourceConversationId,
|
|
}).toEqual({
|
|
metric: "BLOOD_PRESSURE",
|
|
ifCue: COACH_PLAN_CUE,
|
|
thenAction: COACH_PLAN_ACTION,
|
|
target: "under 130 by October",
|
|
status: "active",
|
|
source: restoredThread.id,
|
|
});
|
|
const reminder = await prisma.coachReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect(
|
|
{
|
|
note: decryptFromBytes(reminder.noteEncrypted),
|
|
relatedPlanId: reminder.relatedPlanId,
|
|
source: reminder.sourceConversationId,
|
|
surfaceCount: reminder.surfaceCount,
|
|
status: reminder.status,
|
|
},
|
|
"the reminder still knows which plan it belongs to",
|
|
).toEqual({
|
|
note: COACH_REMINDER_NOTE,
|
|
relatedPlanId: restoredPlan.id,
|
|
source: restoredThread.id,
|
|
surfaceCount: 2,
|
|
status: "active",
|
|
});
|
|
|
|
// The screener came back readable, and the flag came back as recorded.
|
|
const screener = await prisma.mentalHealthAssessment.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect({
|
|
instrument: screener.instrument,
|
|
totalScore: screener.totalScore,
|
|
severityBand: screener.severityBand,
|
|
item9Flagged: screener.item9Flagged,
|
|
crisisShown: screener.crisisShownAt?.toISOString() ?? null,
|
|
responses: decryptFromBytes(screener.responsesEncrypted),
|
|
}).toEqual({
|
|
instrument: "PHQ9",
|
|
totalScore: 14,
|
|
severityBand: "moderate",
|
|
item9Flagged: true,
|
|
crisisShown: "2026-07-05T18:02:00.000Z",
|
|
responses: PHQ9_RESPONSES,
|
|
});
|
|
|
|
// Both consents, and the revocation still standing. A restore that lost
|
|
// `revokedAt` would return the same two rows and turn a withdrawn
|
|
// agreement back into an active one.
|
|
const consents = await prisma.consentReceipt.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { signedAt: "asc" },
|
|
});
|
|
expect(
|
|
consents.map((c) => ({
|
|
kind: c.kind,
|
|
artefact: c.artefact,
|
|
revoked: c.revokedAt?.toISOString() ?? null,
|
|
})),
|
|
"a revoked consent must not come back as an active one",
|
|
).toEqual([
|
|
{ kind: "ai-processing", artefact: CONSENT_ARTEFACT, revoked: null },
|
|
{
|
|
kind: "research-sharing",
|
|
artefact: "eyJhbGciOiJub25lIn0.e30.",
|
|
revoked: "2026-07-10T11:00:00.000Z",
|
|
},
|
|
]);
|
|
|
|
// The account's own grouping came back, and the hidden tag is hidden
|
|
// again — resolved by key, so it points at whatever id this instance uses.
|
|
const ownCategories = await prisma.moodTagCategory.findMany({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect(
|
|
ownCategories.map((c) => ({ key: c.key, labelKey: c.labelKey })),
|
|
).toEqual([
|
|
{
|
|
key: "round_trip_own_category",
|
|
labelKey: "mood.category.roundTripOwn",
|
|
},
|
|
]);
|
|
const restoredTag = await prisma.moodTag.findFirstOrThrow({
|
|
where: { key: "round_trip_factor" },
|
|
});
|
|
expect(
|
|
restoredTag.categoryId,
|
|
"the custom tag is grouped under the account's own category",
|
|
).toBe(ownCategories[0].id);
|
|
|
|
const hidden = await prisma.moodTagHidden.findMany({
|
|
where: { userId: OWNER_ID },
|
|
include: { moodTag: { select: { key: true, userId: true } } },
|
|
});
|
|
// Exactly one, and it points at a SEEDED tag — which is only possible if
|
|
// the key was resolved against this instance's catalogue rather than an id
|
|
// carried in the file.
|
|
expect(hidden).toHaveLength(1);
|
|
expect(hidden[0].moodTag.userId).toBeNull();
|
|
|
|
// Every threshold as tuned, including the one that is not in minutes.
|
|
const phase = await prisma.reminderPhaseConfig.findFirstOrThrow({
|
|
where: { medication: { userId: OWNER_ID } },
|
|
});
|
|
expect({
|
|
green: [phase.greenValue, phase.greenMode],
|
|
yellow: [phase.yellowValue, phase.yellowMode],
|
|
orange: [phase.orangeValue, phase.orangeMode],
|
|
red: [phase.redValue, phase.redMode],
|
|
}).toEqual({
|
|
green: [45, "MINUTES"],
|
|
yellow: [20, "PERCENT"],
|
|
orange: [5, "MINUTES"],
|
|
red: [180, "MINUTES"],
|
|
});
|
|
|
|
// The shelf, and the count that must not be recomputed.
|
|
const packs = await prisma.medicationInventoryItem.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
expect(
|
|
packs.map((pack) => ({
|
|
state: pack.state,
|
|
containerType: pack.containerType,
|
|
total: pack.unitsTotal.toString(),
|
|
remaining: pack.unitsRemaining.toString(),
|
|
manufacturer: pack.manufacturer,
|
|
note: readNote(pack.notesEncrypted, pack.notes),
|
|
plaintextColumn: pack.notes,
|
|
})),
|
|
"the remaining count comes back as recorded, not as recalculated",
|
|
).toEqual([
|
|
{
|
|
state: "ACTIVE",
|
|
containerType: "BLISTER",
|
|
total: "30",
|
|
remaining: "21",
|
|
manufacturer: "Fixture Pharma",
|
|
note: INVENTORY_NOTE,
|
|
plaintextColumn: null,
|
|
},
|
|
{
|
|
state: "USED_UP",
|
|
containerType: "BOTTLE",
|
|
total: "60",
|
|
remaining: "0",
|
|
manufacturer: null,
|
|
note: null,
|
|
plaintextColumn: null,
|
|
},
|
|
]);
|
|
const stockLedger = await prisma.medicationInventoryEvent.findMany({
|
|
where: { medication: { userId: OWNER_ID } },
|
|
orderBy: { occurredAt: "asc" },
|
|
});
|
|
expect(
|
|
stockLedger.map((e) => ({ delta: e.delta, reason: e.reason })),
|
|
).toEqual([
|
|
{ delta: 30, reason: "purchased" },
|
|
{ delta: -8, reason: "consumed" },
|
|
]);
|
|
|
|
// The ramp, in order, with the note back in its column. Counting says two
|
|
// rows returned; only this says they came back as the same ramp, and that
|
|
// the encrypted note did not land in the plaintext column on the way.
|
|
const doses = await prisma.medicationDoseChange.findMany({
|
|
where: { medication: { userId: OWNER_ID } },
|
|
orderBy: { effectiveFrom: "asc" },
|
|
});
|
|
expect(
|
|
doses.map((d) => ({
|
|
effectiveFrom: d.effectiveFrom.toISOString(),
|
|
doseValue: d.doseValue,
|
|
doseUnit: d.doseUnit,
|
|
note: readNote(d.noteEncrypted, d.note),
|
|
plaintextColumn: d.note,
|
|
})),
|
|
"the titration must come back as the same ramp, in order",
|
|
).toEqual([
|
|
{
|
|
effectiveFrom: "2026-04-01T08:00:00.000Z",
|
|
doseValue: 5,
|
|
doseUnit: "mg",
|
|
note: DOSE_CHANGE_NOTE,
|
|
plaintextColumn: null,
|
|
},
|
|
{
|
|
effectiveFrom: "2026-06-01T08:00:00.000Z",
|
|
doseValue: 10,
|
|
doseUnit: "mg",
|
|
note: null,
|
|
plaintextColumn: null,
|
|
},
|
|
]);
|
|
|
|
// The dose, read back column by column. The count above says a row
|
|
// returned; a restore that wrote one row with every optional column
|
|
// defaulted would satisfy it and hand back an Impfpass line with no
|
|
// antigen, no batch code and no arm. The two remapped references are
|
|
// asserted as ids that exist rather than as the ids the file carried,
|
|
// because that is the property the remap owes.
|
|
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
include: {
|
|
practitioner: { select: { name: true } },
|
|
encounter: { select: { occurredAt: true } },
|
|
reminder: { select: { label: true } },
|
|
documentLinks: { select: { documentId: true } },
|
|
},
|
|
});
|
|
expect({
|
|
occurredAt: dose.occurredAt.toISOString(),
|
|
antigenSlug: dose.antigenSlug,
|
|
vaccineName: dose.vaccineName,
|
|
doseNumber: dose.doseNumber,
|
|
seriesDoses: dose.seriesDoses,
|
|
lotNumber: dose.lotNumber,
|
|
site: dose.site,
|
|
practitioner: dose.practitioner?.name ?? null,
|
|
encounterAt: dose.encounter?.occurredAt.toISOString() ?? null,
|
|
// The booster reference, resolved through the relation: non-null proves
|
|
// the id survived the round trip instead of dropping to NULL the way it
|
|
// had to before the reminders travelled.
|
|
reminder: dose.reminder?.label ?? null,
|
|
links: dose.documentLinks.length,
|
|
note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null,
|
|
}).toEqual({
|
|
occurredAt: "2026-05-14T00:00:00.000Z",
|
|
antigenSlug: "tdap",
|
|
vaccineName: "Tetanus, diphtheria and pertussis",
|
|
doseNumber: 3,
|
|
seriesDoses: 3,
|
|
lotNumber: "RT-4471",
|
|
site: "LEFT_ARM",
|
|
practitioner: "Round-trip practice",
|
|
encounterAt: "2026-06-30T08:00:00.000Z",
|
|
reminder: "Blutdruck messen",
|
|
links: 1,
|
|
note: "sore arm for a day, nothing else",
|
|
});
|
|
|
|
// The reminder, read back column by column — including the three
|
|
// v1.37.20 columns (snooze cursor, last skip, skip counter), because a
|
|
// restore that defaulted them would hand back a cadence that looks never
|
|
// snoozed and never skipped, which is a history the account does not have.
|
|
const restoredReminder = await prisma.measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect({
|
|
label: restoredReminder.label,
|
|
measurementType: restoredReminder.measurementType,
|
|
intervalDays: restoredReminder.intervalDays,
|
|
rrule: restoredReminder.rrule,
|
|
anchorDate: restoredReminder.anchorDate,
|
|
endsOn: restoredReminder.endsOn,
|
|
origin: restoredReminder.origin,
|
|
notifyHour: restoredReminder.notifyHour,
|
|
location: restoredReminder.location,
|
|
nextDueAt: restoredReminder.nextDueAt?.toISOString() ?? null,
|
|
lastSatisfiedAt: restoredReminder.lastSatisfiedAt?.toISOString() ?? null,
|
|
enabled: restoredReminder.enabled,
|
|
vaccinationAntigen: restoredReminder.vaccinationAntigen,
|
|
snoozedUntil: restoredReminder.snoozedUntil?.toISOString() ?? null,
|
|
lastSkippedAt: restoredReminder.lastSkippedAt?.toISOString() ?? null,
|
|
skipCount: restoredReminder.skipCount,
|
|
deletedAt: restoredReminder.deletedAt,
|
|
}).toEqual({
|
|
label: "Blutdruck messen",
|
|
measurementType: "BLOOD_PRESSURE_SYS",
|
|
intervalDays: 7,
|
|
rrule: null,
|
|
anchorDate: null,
|
|
endsOn: null,
|
|
origin: "VORSORGE",
|
|
notifyHour: 8,
|
|
location: "Zuhause",
|
|
nextDueAt: "2026-07-08T06:00:00.000Z",
|
|
lastSatisfiedAt: "2026-07-01T06:05:00.000Z",
|
|
enabled: true,
|
|
vaccinationAntigen: "tetanus",
|
|
snoozedUntil: "2026-07-08T06:00:00.000Z",
|
|
lastSkippedAt: "2026-06-24T06:00:00.000Z",
|
|
skipCount: 2,
|
|
deletedAt: null,
|
|
});
|
|
|
|
// Both ledger rows, whole: the satisfy and the skip, with the resolved
|
|
// `onTime` verdicts. A restore cannot re-derive `onTime` — the pre-event
|
|
// `nextDueAt` is gone — so a defaulted value here would be an invented
|
|
// punctuality record.
|
|
const ledger = await prisma.measurementReminderEvent.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { occurredAt: "asc" },
|
|
});
|
|
expect(
|
|
ledger.map((event) => ({
|
|
reminderId: event.reminderId,
|
|
kind: event.kind,
|
|
occurredAt: event.occurredAt.toISOString(),
|
|
onTime: event.onTime,
|
|
source: event.source,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
reminderId: restoredReminder.id,
|
|
kind: "SKIPPED",
|
|
occurredAt: "2026-06-24T06:00:00.000Z",
|
|
onTime: false,
|
|
source: "skip",
|
|
},
|
|
{
|
|
reminderId: restoredReminder.id,
|
|
kind: "SATISFIED",
|
|
occurredAt: "2026-07-01T06:05:00.000Z",
|
|
onTime: true,
|
|
source: "manual",
|
|
},
|
|
]);
|
|
|
|
// The vault came back SORTED, not merely populated.
|
|
//
|
|
// The count says one filing returned. It cannot say which page was filed
|
|
// under which condition, and the account has two conditions — so a restore
|
|
// that paired the document with the first episode it found would satisfy
|
|
// every count above and hand back a lab report filed under a head cold.
|
|
// The pair is resolved through the relations rather than compared against
|
|
// the ids the fixture used, because both ends were re-created by the
|
|
// restore and it is the pairing that has to survive, not the identifiers.
|
|
const filing = await prisma.documentConditionLink.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
include: {
|
|
document: { select: { title: true } },
|
|
episode: { select: { label: true } },
|
|
},
|
|
});
|
|
expect(
|
|
{
|
|
document: filing.document.title,
|
|
condition: filing.episode.label,
|
|
createdAt: filing.createdAt.toISOString(),
|
|
},
|
|
"the page must come back filed under the condition it was filed under",
|
|
).toEqual({
|
|
document: "June labs",
|
|
condition: "Iron deficiency",
|
|
createdAt: "2026-07-02T09:00:00.000Z",
|
|
});
|
|
|
|
// The staged fact, and the decision on it.
|
|
//
|
|
// This is the assertion the count cannot make. `status`, `needsReview`,
|
|
// `committedRecordId` and `committedRecordType` all default to "nobody has
|
|
// looked at this yet", so a restore that wrote the row and ignored them
|
|
// returns exactly one fact, exactly as the plan promises, and hands the
|
|
// account a reviewed and committed reading back in its review queue. The
|
|
// confirm endpoint acts only on a PENDING fact and commits it through the
|
|
// normal create, so approving it a second time writes a SECOND ferritin
|
|
// result. The commitment is checked against the lab row the restore itself
|
|
// wrote, because a pointer that survives as a string but no longer names a
|
|
// live row is the same loss wearing a value.
|
|
const restoredLab = await prisma.labResult.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
const stagedFact = await prisma.extractedFact.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
include: { document: { select: { title: true } } },
|
|
});
|
|
expect(
|
|
{
|
|
document: stagedFact.document.title,
|
|
factType: stagedFact.factType,
|
|
status: stagedFact.status,
|
|
confidence: stagedFact.confidence,
|
|
needsReview: stagedFact.needsReview,
|
|
committedRecordId: stagedFact.committedRecordId,
|
|
committedRecordType: stagedFact.committedRecordType,
|
|
data: decryptFactData(stagedFact.dataEncrypted),
|
|
provenance: decryptFactProvenance(stagedFact.provenanceEncrypted),
|
|
},
|
|
"a reviewed and committed fact must not come back up for review",
|
|
).toEqual({
|
|
document: "June labs",
|
|
factType: "OBSERVATION",
|
|
status: "APPROVED",
|
|
confidence: 0.94,
|
|
needsReview: false,
|
|
committedRecordId: restoredLab.id,
|
|
committedRecordType: "labResult",
|
|
data: {
|
|
label: "Ferritin",
|
|
code: null,
|
|
codeSystem: null,
|
|
value: 91,
|
|
valueText: null,
|
|
unit: "ng/mL",
|
|
referenceLow: 30,
|
|
referenceHigh: 400,
|
|
effectiveDate: "2026-06-30",
|
|
},
|
|
provenance: {
|
|
sourceText: EXTRACTED_FACT_SPAN,
|
|
anchored: true,
|
|
sourceOffset: 412,
|
|
page: 2,
|
|
confidence: 0.94,
|
|
},
|
|
});
|
|
// The archived eras, and the CHAIN through them.
|
|
//
|
|
// Counting says three eras returned, and three eras with every link null
|
|
// would satisfy it exactly while having thrown away the only thing the
|
|
// pointer states. That is not a cosmetic loss: every era consumer skips a
|
|
// superseded row, so a corrected era whose link came back null goes live
|
|
// again beside the correction that replaced it, and the account gets two
|
|
// overlapping plans for one window with its past compliance minted against
|
|
// whichever the query happened to read.
|
|
//
|
|
// Asserted as the shape of the chain rather than as ids: the ids are fresh
|
|
// on a portable restore, and what the two-pass write owes is that era two
|
|
// still points at era three and that nothing else points anywhere.
|
|
const scheduleEras = await prisma.medicationScheduleRevision.findMany({
|
|
where: { medication: { userId: OWNER_ID } },
|
|
orderBy: [{ validFrom: "asc" }, { createdAt: "asc" }, { id: "asc" }],
|
|
});
|
|
const eraPositionById = new Map(
|
|
scheduleEras.map((era, index) => [era.id, index]),
|
|
);
|
|
expect(
|
|
scheduleEras.map((era) => ({
|
|
validFrom: era.validFrom.toISOString(),
|
|
validUntil: era.validUntil.toISOString(),
|
|
source: era.source,
|
|
payload: era.payload,
|
|
supersedes: era.supersededByRevisionId
|
|
? (eraPositionById.get(era.supersededByRevisionId) ?? "dangling")
|
|
: null,
|
|
})),
|
|
"the corrected era must come back still pointing at its correction",
|
|
).toEqual([
|
|
{
|
|
validFrom: "2026-01-01T00:00:00.000Z",
|
|
validUntil: "2026-03-01T00:00:00.000Z",
|
|
source: "ARCHIVED",
|
|
payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }],
|
|
supersedes: null,
|
|
},
|
|
{
|
|
validFrom: "2026-02-01T00:00:00.000Z",
|
|
validUntil: "2026-03-01T00:00:00.000Z",
|
|
source: "ARCHIVED",
|
|
payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }],
|
|
supersedes: 2,
|
|
},
|
|
{
|
|
validFrom: "2026-03-01T00:00:00.000Z",
|
|
validUntil: "2026-05-01T00:00:00.000Z",
|
|
source: "MANUAL",
|
|
payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }],
|
|
supersedes: null,
|
|
},
|
|
]);
|
|
// What the pointer actually buys, stated as the thing the app reads: the
|
|
// timeline query drops superseded rows, so exactly two eras are live.
|
|
expect(
|
|
await prisma.medicationScheduleRevision.count({
|
|
where: {
|
|
medication: { userId: OWNER_ID },
|
|
supersededByRevisionId: null,
|
|
},
|
|
}),
|
|
"a lost pointer puts a corrected era back on the timeline",
|
|
).toBe(2);
|
|
|
|
// The pinned target, and the analyte it points at.
|
|
//
|
|
// Counting says one override returned. It cannot say the override still
|
|
// MEANS anything: a restore that wrote the row with `biomarkerId` nulled
|
|
// returns a drug with no statement of what it was for, which is precisely
|
|
// what the register said the alternative cost, and nothing anywhere would
|
|
// complain — the resolver silently falls back to deriving a target and the
|
|
// page still renders.
|
|
//
|
|
// Both ends are resolved from the DATABASE rather than remembered from the
|
|
// fixture. The property the second pass owes is that the target points at
|
|
// the biomarker THIS restore wrote, which an id carried over from the
|
|
// seeding would not distinguish from a restore that copied a stale value.
|
|
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
const restoredMedication = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
const target = await prisma.medicationEfficacyTarget.findFirstOrThrow({
|
|
where: { medication: { userId: OWNER_ID } },
|
|
include: { biomarker: { select: { name: true, userId: true } } },
|
|
});
|
|
expect({
|
|
medicationId: target.medicationId,
|
|
biomarkerId: target.biomarkerId,
|
|
biomarkerName: target.biomarker?.name ?? null,
|
|
biomarkerOwner: target.biomarker?.userId ?? null,
|
|
measurementType: target.measurementType,
|
|
primary: target.primary,
|
|
}).toEqual({
|
|
medicationId: restoredMedication.id,
|
|
biomarkerId: restoredBiomarker.id,
|
|
biomarkerName: "Ferritin",
|
|
biomarkerOwner: OWNER_ID,
|
|
measurementType: null,
|
|
primary: true,
|
|
});
|
|
|
|
// The strip, read back column by column.
|
|
//
|
|
// Counting proves a recording returned. It cannot tell that recording from
|
|
// one whose trace is an empty array, whose verdict defaulted to NULL and
|
|
// whose instant is the restore time — and each of those three is the whole
|
|
// value of the row on its own. A strip with no rhythm classification is a
|
|
// squiggle nobody has read; a strip with no `recordedAt` belongs to no day
|
|
// and cannot be put beside the event it explains; a strip with no samples
|
|
// is a claim that an ECG exists which nothing can draw.
|
|
//
|
|
// `measurementId` is resolved from the DATABASE rather than remembered
|
|
// from the fixture, because the property the remap owes is that the strip
|
|
// points at the event row THIS restore wrote.
|
|
const restoredEvent = await prisma.measurement.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, type: "IRREGULAR_RHYTHM_NOTIFICATION" },
|
|
});
|
|
const strip = await prisma.ecgRecording.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect({
|
|
source: strip.source,
|
|
externalRecordingId: strip.externalRecordingId,
|
|
recordedAt: strip.recordedAt.toISOString(),
|
|
samplingFrequency: strip.samplingFrequency,
|
|
sampleCount: strip.sampleCount,
|
|
durationSeconds: strip.durationSeconds,
|
|
lead: strip.lead,
|
|
averageHeartRate: strip.averageHeartRate,
|
|
rhythmClassification: strip.rhythmClassification,
|
|
measurementId: strip.measurementId,
|
|
waveform: decryptWaveformFromBytes(strip.waveformEncrypted),
|
|
}).toEqual({
|
|
source: "WITHINGS",
|
|
externalRecordingId: "round-trip-signal-1",
|
|
recordedAt: "2026-06-15T14:22:00.000Z",
|
|
samplingFrequency: 300,
|
|
sampleCount: ECG_SAMPLES.length,
|
|
durationSeconds: ECG_SAMPLES.length / 300,
|
|
lead: "I",
|
|
averageHeartRate: 88,
|
|
rhythmClassification: "IRREGULAR",
|
|
measurementId: restoredEvent.id,
|
|
waveform: ECG_SAMPLES,
|
|
});
|
|
|
|
// The appointment's reminder reference survives too — same remap, other
|
|
// referrer.
|
|
const restoredEncounter = await prisma.encounter.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
select: { reminderId: true },
|
|
});
|
|
expect(
|
|
restoredEncounter.reminderId,
|
|
"the encounter's reminder reference must survive now that the reminder travels",
|
|
).toBe(restoredReminder.id);
|
|
|
|
// The bests, read back column by column.
|
|
//
|
|
// Three rows came back, which the count above already said. What it could
|
|
// not say is that they still describe three different records: the two
|
|
// running bests share a metric type and an instant, so the slot is the
|
|
// only thing separating "best 5 km" from "best 10 km", and `direction` is
|
|
// the only thing that stops a best time being read as a worst one. The
|
|
// measurement pointer is asserted against the row the RESTORE wrote rather
|
|
// than against the id the fixture used, because resolving to something
|
|
// that exists is the property the reference owes.
|
|
const restoredTombstone = await prisma.measurement.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, externalId: "round-trip-deleted-sample" },
|
|
});
|
|
expect(
|
|
{
|
|
deletedAt: restoredTombstone.deletedAt?.toISOString() ?? null,
|
|
syncVersion: restoredTombstone.syncVersion,
|
|
},
|
|
"the deleted reading came back, but not as a deletion — a tombstone " +
|
|
"restored without its `deletedAt` is a resurrected measurement",
|
|
).toEqual({ deletedAt: "2026-07-20T09:15:00.000Z", syncVersion: 4 });
|
|
|
|
const restoredMeasurement = await prisma.measurement.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, type: "WEIGHT" },
|
|
});
|
|
const bests = await prisma.personalRecord.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: [{ achievedAt: "asc" }, { metricSlot: "asc" }],
|
|
});
|
|
expect(
|
|
bests.map((best) => ({
|
|
metricType: best.metricType,
|
|
metricSlot: best.metricSlot,
|
|
direction: best.direction,
|
|
value: best.value,
|
|
unit: best.unit,
|
|
achievedAt: best.achievedAt.toISOString(),
|
|
sourceMeasurementId: best.sourceMeasurementId,
|
|
source: best.source,
|
|
externalId: best.externalId,
|
|
})),
|
|
"each best must come back as its own record, with the direction and the sport slot that make it one",
|
|
).toEqual([
|
|
{
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "running_10km_time",
|
|
direction: "MIN",
|
|
value: 3184,
|
|
unit: "s",
|
|
achievedAt: "2026-05-18T09:20:00.000Z",
|
|
sourceMeasurementId: null,
|
|
source: "APPLE_HEALTH",
|
|
externalId: "workout-pr-10km",
|
|
},
|
|
{
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "running_5km_time",
|
|
direction: "MIN",
|
|
value: 1512,
|
|
unit: "s",
|
|
achievedAt: "2026-05-18T09:20:00.000Z",
|
|
sourceMeasurementId: null,
|
|
source: "APPLE_HEALTH",
|
|
externalId: "workout-pr-5km",
|
|
},
|
|
{
|
|
metricType: "WEIGHT",
|
|
metricSlot: null,
|
|
direction: "MIN",
|
|
value: 74.2,
|
|
unit: "kg",
|
|
achievedAt: "2026-07-01T07:00:00.000Z",
|
|
// Not `null`: the pointer resolved against a measurement this restore
|
|
// actually wrote. A restore that nulled it would report the drop, and
|
|
// one that wrote it before the measurements existed would fail the
|
|
// foreign key rather than reach this line.
|
|
sourceMeasurementId: restoredMeasurement.id,
|
|
source: "MANUAL",
|
|
externalId: null,
|
|
},
|
|
]);
|
|
|
|
// The badges, and the only field on them that matters.
|
|
//
|
|
// Both rows would come back from a restore that stamped `unlockedAt` with
|
|
// the moment of the restore, and every count in this file would still be
|
|
// green, while the account was handed a wall of badges it had apparently
|
|
// earned all at once, today. Several of these dates cannot be re-derived
|
|
// at all: the evaluator prefers a persisted date, and the counters behind
|
|
// the login and Easter-egg badges are not in any backup.
|
|
const badges = await prisma.userAchievement.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { unlockedAt: "asc" },
|
|
});
|
|
expect(
|
|
badges.map((badge) => ({
|
|
achievementId: badge.achievementId,
|
|
unlockedAt: badge.unlockedAt.toISOString(),
|
|
})),
|
|
"a badge comes back with the day it was earned, and with the id it was earned under",
|
|
).toEqual([
|
|
{
|
|
// A definition this build does not ship, kept verbatim rather than
|
|
// judged against the catalogue, the same answer the restore gives a
|
|
// retired antigen slug.
|
|
achievementId: "retired-badge-from-an-older-release",
|
|
unlockedAt: "2025-11-05T08:00:00.000Z",
|
|
},
|
|
{
|
|
achievementId: "intake-total-10",
|
|
unlockedAt: "2026-02-21T10:15:00.000Z",
|
|
},
|
|
]);
|
|
|
|
// The environmental history, read back as a PAIR.
|
|
//
|
|
// Counting says two readings and one location period returned. It cannot
|
|
// say that the trip day still knows it was a trip day, which is the whole
|
|
// content of these rows: the coordinates, the label and `source` are what
|
|
// separate a fortnight in Barcelona from a fortnight at home, and the
|
|
// period is what keeps the next refresh from re-resolving the day and
|
|
// upserting Berlin's weather over it.
|
|
const readings = await prisma.environmentContext.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { date: "asc" },
|
|
});
|
|
expect(
|
|
readings.map((reading) => ({
|
|
date: reading.date,
|
|
lat: reading.lat,
|
|
lon: reading.lon,
|
|
locationLabel: reading.locationLabel,
|
|
source: reading.source,
|
|
tempMean: reading.tempMean,
|
|
pressureDelta: reading.pressureDelta,
|
|
daylightSec: reading.daylightSec,
|
|
weatherCode: reading.weatherCode,
|
|
// Verbatim, not re-stamped: this says when the feed was read, and a
|
|
// restore that wrote "now" would claim a two-year-old provisional
|
|
// reading had just been confirmed.
|
|
fetchedAt: reading.fetchedAt.toISOString(),
|
|
})),
|
|
"each day comes back at the place it was actually read for",
|
|
).toEqual([
|
|
{
|
|
date: "2026-06-15",
|
|
lat: 41.3874,
|
|
lon: 2.1686,
|
|
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.52,
|
|
lon: 13.405,
|
|
locationLabel: "Berlin",
|
|
source: "HOME",
|
|
tempMean: 17.9,
|
|
pressureDelta: 7.8,
|
|
daylightSec: 59_400,
|
|
weatherCode: 61,
|
|
fetchedAt: "2026-07-02T03:15:00.000Z",
|
|
},
|
|
]);
|
|
|
|
// And the join between them, computed from what the restore wrote rather
|
|
// than from the fixture: the trip day has to fall inside the restored
|
|
// period and carry its coordinates. Both halves can be individually
|
|
// non-empty and still disagree: a period whose bounds shifted by a day no
|
|
// longer covers the reading it explains, and the reading goes back to
|
|
// being weather from nowhere.
|
|
const trip = await prisma.environmentTravelLocation.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
|
|
expect(
|
|
{
|
|
startDate: trip.startDate,
|
|
endDate: trip.endDate,
|
|
label: trip.label,
|
|
covers:
|
|
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
|
|
sameLat: trip.lat === travelDay.lat,
|
|
sameLon: trip.lon === travelDay.lon,
|
|
},
|
|
"the period must still explain the reading it was exported beside",
|
|
).toEqual({
|
|
startDate: "2026-06-10",
|
|
endDate: "2026-06-20",
|
|
label: "Barcelona",
|
|
covers: true,
|
|
sameLat: true,
|
|
sameLon: true,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A supersede pointer that names a position the file does not have.
|
|
*
|
|
* The builder computes the index from the same list it writes, so no file
|
|
* this release produces can trip this. A hand-edited or truncated one can,
|
|
* and the question it asks has to be answered somewhere: the era it names
|
|
* does not exist, the column is a bare id with no constraint to stop a
|
|
* wrong value, and inventing a neighbouring era to point at would be worse
|
|
* than either dropping or refusing.
|
|
*
|
|
* The answer is that the ERA restores and the POINTER does not, and the
|
|
* position is reported. Refusing the file would cost an operator every era
|
|
* of every drug over one bad integer; writing the link would put a
|
|
* correction pointer at whatever row happened to be there.
|
|
*/
|
|
it("keeps an archived era whose supersede pointer names a position out of range, and reports the position", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
|
|
const medication = await prisma.medication.create({
|
|
data: { userId: OWNER_ID, name: "Out-of-range tablet", dose: "5 mg" },
|
|
});
|
|
await prisma.medicationScheduleRevision.createMany({
|
|
data: [
|
|
{
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-01-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-02-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "07:00", windowEnd: "09:00" }],
|
|
source: "ARCHIVED",
|
|
},
|
|
{
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-02-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-03-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "08:00", windowEnd: "10:00" }],
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
});
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
// Hand-edit the file the way a person with a text editor would: point the
|
|
// first era at an era that is not there.
|
|
const medications = payload.medications as Array<{
|
|
scheduleRevisions: Array<{ supersededByIndex: number | null }>;
|
|
}>;
|
|
medications[0].scheduleRevisions[0].supersededByIndex = 7;
|
|
|
|
await prisma.user.delete({ where: { id: OWNER_ID } });
|
|
await createOwner(prisma);
|
|
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "TWO_ENDED_ROUND_TRIP",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
const response = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
const body = await response.json();
|
|
expect(response.status, JSON.stringify(body)).toBe(200);
|
|
|
|
const reported = body.data.skipped.catalogueKeys as Array<{
|
|
catalogue: string;
|
|
key: string;
|
|
links: number;
|
|
}>;
|
|
expect(
|
|
reported.filter((entry) => entry.catalogue === "scheduleRevisionLink"),
|
|
"the position the file named is not in the file, so dropping the link " +
|
|
"is correct and saying nothing about it is not",
|
|
).toEqual([
|
|
{
|
|
catalogue: "scheduleRevisionLink",
|
|
key: "medications[0].scheduleRevisions[0].supersededByIndex=7",
|
|
links: 1,
|
|
},
|
|
]);
|
|
|
|
const restoredEras = await prisma.medicationScheduleRevision.findMany({
|
|
where: { medication: { userId: OWNER_ID } },
|
|
orderBy: { validFrom: "asc" },
|
|
});
|
|
expect(
|
|
restoredEras.map((era) => ({
|
|
validFrom: era.validFrom.toISOString(),
|
|
source: era.source,
|
|
supersededByRevisionId: era.supersededByRevisionId,
|
|
})),
|
|
"both eras come back; only the pointer is lost",
|
|
).toEqual([
|
|
{
|
|
validFrom: "2026-01-01T00:00:00.000Z",
|
|
source: "ARCHIVED",
|
|
supersededByRevisionId: null,
|
|
},
|
|
{
|
|
validFrom: "2026-02-01T00:00:00.000Z",
|
|
source: "MANUAL",
|
|
supersededByRevisionId: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
/**
|
|
* The reference a portable file genuinely lacks, and the slug the restore
|
|
* must not judge.
|
|
*
|
|
* The reminders travel since v1.37.20, so a reference to one is no longer a
|
|
* guaranteed drop — the round trip above proves it surviving. What can still
|
|
* genuinely be missing is a TOMBSTONED reminder in a portable export: the
|
|
* builder omits it (a restore must not resurrect a deletion) and omits its
|
|
* completion ledger with it, while a live dose that recorded satisfying it
|
|
* keeps its reference in the database. That reference then points at a row
|
|
* the file does not carry, which is exactly the case the restore reports.
|
|
*
|
|
* Separated from the round trip above because this is correct behaviour
|
|
* that shows up as a reported skip, and that test asserts nothing was
|
|
* skipped. Reported is the whole point: a reference dropped in silence is
|
|
* the same defect as a row dropped in silence, one field smaller.
|
|
*/
|
|
it("reports the tombstoned reminder a portable file omits and keeps a slug it cannot resolve", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
|
|
const reminder = await prisma.measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Tetanus booster",
|
|
intervalDays: 3650,
|
|
vaccinationAntigen: "tetanus",
|
|
deletedAt: AT("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
// A ledger row on the tombstoned reminder. The builder must drop it from
|
|
// the file along with its reminder — a ledger row whose reminder is not
|
|
// carried could only ever restore as a reported loss.
|
|
await prisma.measurementReminderEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
reminderId: reminder.id,
|
|
kind: "SATISFIED",
|
|
occurredAt: AT("2026-06-01T09:00:00.000Z"),
|
|
onTime: true,
|
|
source: "vaccination",
|
|
},
|
|
});
|
|
await prisma.vaccinationRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: AT("1987-09-02T00:00:00.000Z"),
|
|
// A slug no catalogue this release ships resolves. It must come back
|
|
// exactly as written: the renderer degrades to `vaccineName`, and a
|
|
// restore that dropped or rewrote it would lose what a person was
|
|
// actually given.
|
|
antigenSlug: "retired-antigen-from-an-older-release",
|
|
vaccineName: "Whatever the Pass called it in 1987",
|
|
reminderId: reminder.id,
|
|
},
|
|
});
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "portable-export",
|
|
});
|
|
// The file must be silent about the tombstoned reminder AND its ledger —
|
|
// carrying the events of a reminder it refuses to restore would make
|
|
// every portable file this release writes restore with a reported loss.
|
|
expect(payload.measurementReminders).toEqual([]);
|
|
expect(payload.measurementReminderEvents).toEqual([]);
|
|
|
|
await prisma.user.delete({ where: { id: OWNER_ID } });
|
|
await createOwner(prisma);
|
|
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "TWO_ENDED_ROUND_TRIP",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
const response = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
const body = await response.json();
|
|
expect(response.status, JSON.stringify(body)).toBe(200);
|
|
|
|
const reported = body.data.skipped.catalogueKeys as Array<{
|
|
catalogue: string;
|
|
key: string;
|
|
}>;
|
|
expect(
|
|
reported.filter((entry) => entry.catalogue === "vaccinationReference"),
|
|
"the reminder the dose pointed at is not in the backup at all, so " +
|
|
"dropping the reference is correct and saying nothing about it is not",
|
|
).toEqual([
|
|
{ catalogue: "vaccinationReference", key: reminder.id, links: 1 },
|
|
]);
|
|
|
|
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect({
|
|
antigenSlug: dose.antigenSlug,
|
|
vaccineName: dose.vaccineName,
|
|
reminderId: dose.reminderId,
|
|
}).toEqual({
|
|
antigenSlug: "retired-antigen-from-an-older-release",
|
|
vaccineName: "Whatever the Pass called it in 1987",
|
|
reminderId: null,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The hand-edited file, which is the only way the vault filing can carry a
|
|
* reference the restore cannot place.
|
|
*
|
|
* The builder carries a filing or a staged fact only when both of its ends
|
|
* are carried, so no file this release writes reaches the drop path. That is
|
|
* exactly why the path is worth a test of its own: an arm nothing exercises
|
|
* is an arm nobody notices is wrong, and here being wrong has two very
|
|
* different prices. `documentConditionLink.episodeId` is a real foreign key,
|
|
* so writing it unchecked does not lose one edge — it violates a constraint
|
|
* and rolls the WHOLE account back, which is what the mood categories did
|
|
* before they travelled. `ExtractedFact.committedRecordId` is a bare id
|
|
* column with no relation, so writing it unchecked costs no error at all and
|
|
* simply leaves a fact pointing at a lab result that is not there.
|
|
*
|
|
* Both references are broken in the same file so the two answers can be seen
|
|
* side by side: a dropped row and a nulled pointer, each named in the report,
|
|
* and a restore that still answers 200.
|
|
*/
|
|
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,
|
|
);
|
|
});
|
|
});
|