Files
HealthLog/tests/integration/backup-round-trip.test.ts
T
Marc-André Bombeck 64fa65b460 test(backup): pin the file and the restored account against golden copies
Seed one account as far as the schema allows (a row of every two-ended
model, every column of those rows and of the account row, the instance
settings, a sharing grant), export it both ways and restore each file
into a fresh account, over the live account, and from a version-1 file
in the old shapes. The files, counts, parsed payload, preview, restore
outcome, reported phases, audit rows, wide-event fields and every row the
account holds afterwards are compared with golden copies after ids,
ciphertext and run-time timestamps are normalised. Refusals, skip reports
and the schema's issue list for malformed files are pinned the same way.

The seed moves out of the round-trip test into a shared fixture module
so both files fill the same account.
2026-10-22 06:22:30 +02:00

3050 lines
113 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 { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { openLocation } from "@/lib/environment/location-cipher";
import { readNote } from "@/lib/crypto/note-cipher";
import {
decryptFactData,
decryptFactProvenance,
encryptFactData,
encryptFactProvenance,
} from "@/lib/documents/store";
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import {
resolveMedicationCategories,
setMedicationCategory,
} from "@/lib/medication-category";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import { decryptNoteFromBytes } from "@/lib/labs/store";
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
import { parseBackupPayload } from "@/lib/validations/backup";
import { summarizeBackup } from "@/lib/validations/backup-summary";
import {
TWO_ENDED_MODELS,
USER_COLUMN_BACKUP_CLASS,
type TwoEndedModel,
} from "@/lib/export/backup-plan";
import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup";
import { parseOnboardingSteps } from "@/lib/onboarding/needs";
import { POST } from "./restore-job-driver";
import {
type AuditColumn,
type AuditModel,
enumValuesFromSchema,
isDefaultValue,
normalise,
ownerWhere,
readAuditModels,
syntheticValue,
} from "./backup-column-audit";
import {
OWNER_ID,
AT,
TOMBSTONE_DELETED_AT,
PHQ9_RESPONSES,
CONSENT_ARTEFACT,
INVENTORY_NOTE,
COACH_FACT,
COACH_TRAIL_JSON,
COACH_PLAN_CUE,
COACH_PLAN_ACTION,
COACH_REMINDER_NOTE,
COACH_SUMMARY,
METRIC_LEGACY_NOTE,
METRIC_SEALED_NOTE,
COACH_USER_TURN,
COACH_ASSISTANT_TURN,
COACH_RESULTS_JSON,
ROUND_TRIP_CATEGORY_KEY,
DOSE_CHANGE_NOTE,
EXTRACTED_FACT_SPAN,
SIDE_EFFECT_NOTE,
ECG_SAMPLES,
seedEveryTwoEndedModel,
createOwner,
seedAdminSession,
COLUMN_EXCLUSIONS,
COLUMN_PRESENCE_ONLY,
FILL_OVERRIDES,
seedColumnShapes,
delegateOf,
primaryKeyWhere,
comparison,
USER_FILL_OVERRIDES,
} from "./backup-fixture";
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(),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
/** Counts what belongs to `userId`, following the relation where there is no own column. */
const COUNT_BACK: Record<
TwoEndedModel,
(prisma: PrismaClient, userId: string) => Promise<number>
> = {
Measurement: (p, userId) => p.measurement.count({ where: { userId } }),
IntradayCumulativeProfile: (p, userId) =>
p.intradayCumulativeProfile.count({ where: { userId } }),
Medication: (p, userId) => p.medication.count({ where: { userId } }),
MedicationSchedule: (p, userId) =>
p.medicationSchedule.count({ where: { medication: { userId } } }),
MedicationIntakeEvent: (p, userId) =>
p.medicationIntakeEvent.count({ where: { userId } }),
MedicationSideEffect: (p, userId) =>
p.medicationSideEffect.count({ where: { userId } }),
MedicationPauseEra: (p, userId) =>
p.medicationPauseEra.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationDoseChange: (p, userId) =>
p.medicationDoseChange.count({ where: { medication: { userId } } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationCategoryAssignment: (p, userId) =>
p.medicationCategoryAssignment.count({ where: { medication: { userId } } }),
MedicationCategoryLabel: (p, userId) =>
p.medicationCategoryLabel.count({ where: { userId } }),
MedicationIngredient: (p, userId) =>
p.medicationIngredient.count({ where: { userId } }),
MedicationCourse: (p, userId) =>
p.medicationCourse.count({ where: { userId } }),
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }),
MoodEntryTagLink: (p, userId) =>
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
BiomarkerAlias: (p, userId) => p.biomarkerAlias.count({ where: { userId } }),
BiomarkerMerge: (p, userId) => p.biomarkerMerge.count({ where: { userId } }),
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
FamilyHistoryEntry: (p, userId) =>
p.familyHistoryEntry.count({ where: { userId } }),
IllnessEpisode: (p, userId) => p.illnessEpisode.count({ where: { userId } }),
IllnessDayLog: (p, userId) => p.illnessDayLog.count({ where: { userId } }),
SymptomDefinition: (p, userId) =>
p.symptomDefinition.count({ where: { userId } }),
SymptomEvent: (p, userId) => p.symptomEvent.count({ where: { userId } }),
LifeEvent: (p, userId) => p.lifeEvent.count({ where: { userId } }),
IllnessSymptomLink: (p, userId) =>
p.illnessSymptomLink.count({ where: { dayLog: { userId } } }),
UserHealthProfile: (p, userId) =>
p.userHealthProfile.count({ where: { userId } }),
HealthProfileFactRevision: (p, userId) =>
p.healthProfileFactRevision.count({ where: { userId } }),
CycleProfile: (p, userId) => p.cycleProfile.count({ where: { userId } }),
MenstrualCycle: (p, userId) => p.menstrualCycle.count({ where: { userId } }),
CycleDayLog: (p, userId) => p.cycleDayLog.count({ where: { userId } }),
CycleSymptom: (p, userId) => p.cycleSymptom.count({ where: { userId } }),
CycleSymptomLink: (p, userId) =>
p.cycleSymptomLink.count({ where: { dayLog: { userId } } }),
CustomMetric: (p, userId) => p.customMetric.count({ where: { userId } }),
CustomMetricEntry: (p, userId) =>
p.customMetricEntry.count({ where: { userId } }),
CorrelationPattern: (p, userId) =>
p.correlationPattern.count({ where: { userId } }),
HealthScoreRecord: (p, userId) =>
p.healthScoreRecord.count({ where: { userId } }),
Workout: (p, userId) => p.workout.count({ where: { userId } }),
NutrientIntakeDay: (p, userId) =>
p.nutrientIntakeDay.count({ where: { userId } }),
InboundDocument: (p, userId) =>
p.inboundDocument.count({ where: { userId } }),
Practitioner: (p, userId) => p.practitioner.count({ where: { userId } }),
Encounter: (p, userId) => p.encounter.count({ where: { userId } }),
EncounterDocumentLink: (p, userId) =>
p.encounterDocumentLink.count({ where: { userId } }),
EncounterLabLink: (p, userId) =>
p.encounterLabLink.count({ where: { userId } }),
EncounterConditionLink: (p, userId) =>
p.encounterConditionLink.count({ where: { userId } }),
VaccinationRecord: (p, userId) =>
p.vaccinationRecord.count({ where: { userId } }),
VaccinationDocumentLink: (p, userId) =>
p.vaccinationDocumentLink.count({ where: { userId } }),
LabResultDocumentLink: (p, userId) =>
p.labResultDocumentLink.count({ where: { userId } }),
EncounterMedicationLink: (p, userId) =>
p.encounterMedicationLink.count({ where: { userId } }),
EncounterVaccinationLink: (p, userId) =>
p.encounterVaccinationLink.count({ where: { userId } }),
MedicationDocumentLink: (p, userId) =>
p.medicationDocumentLink.count({ where: { userId } }),
MedicationConditionLink: (p, userId) =>
p.medicationConditionLink.count({ where: { userId } }),
CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }),
MeasurementReminder: (p, userId) =>
p.measurementReminder.count({ where: { userId } }),
MeasurementReminderEvent: (p, userId) =>
p.measurementReminderEvent.count({ where: { userId } }),
CoachConversation: (p, userId) =>
p.coachConversation.count({ where: { userId } }),
// Neither the turn nor the attachment carries its own `userId` — both are
// reached through the thread that holds them.
CoachMessage: (p, userId) =>
p.coachMessage.count({ where: { conversation: { userId } } }),
CoachConversationDocument: (p, userId) =>
p.coachConversationDocument.count({ where: { conversation: { userId } } }),
CoachFact: (p, userId) => p.coachFact.count({ where: { userId } }),
CoachPlan: (p, userId) => p.coachPlan.count({ where: { userId } }),
CoachReminder: (p, userId) => p.coachReminder.count({ where: { userId } }),
MedicationInventoryItem: (p, userId) =>
p.medicationInventoryItem.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationInventoryEvent: (p, userId) =>
p.medicationInventoryEvent.count({ where: { medication: { userId } } }),
MoodTagCategory: (p, userId) =>
p.moodTagCategory.count({ where: { userId } }),
MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }),
ReminderPhaseConfig: (p, userId) =>
p.reminderPhaseConfig.count({ where: { medication: { userId } } }),
MentalHealthAssessment: (p, userId) =>
p.mentalHealthAssessment.count({ where: { userId } }),
ConsentReceipt: (p, userId) => p.consentReceipt.count({ where: { userId } }),
DocumentConditionLink: (p, userId) =>
p.documentConditionLink.count({ where: { userId } }),
ExtractedFact: (p, userId) => p.extractedFact.count({ where: { userId } }),
PersonalRecord: (p, userId) => p.personalRecord.count({ where: { userId } }),
UserAchievement: (p, userId) =>
p.userAchievement.count({ where: { userId } }),
EnvironmentContext: (p, userId) =>
p.environmentContext.count({ where: { userId } }),
EnvironmentTravelLocation: (p, userId) =>
p.environmentTravelLocation.count({ where: { userId } }),
DawarichDay: (p, userId) => p.dawarichDay.count({ where: { userId } }),
EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationEfficacyTarget: (p, userId) =>
p.medicationEfficacyTarget.count({ where: { medication: { userId } } }),
MedicationScheduleRevision: (p, userId) =>
p.medicationScheduleRevision.count({ where: { medication: { userId } } }),
OnboardingRecord: (p, userId) =>
p.onboardingRecord.count({ where: { userId } }),
};
describe("every model the plan claims two-ended survives a real restore", () => {
it("carries a seeded row of each model out of the account and back in", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
await seedEveryTwoEndedModel(prisma);
// The fixture has to be complete before the account is deleted, or a model
// seeded with zero rows would come back with zero and read as restored.
const seeded = await Promise.all(
TWO_ENDED_MODELS.map(async (model) => [
model,
await COUNT_BACK[model](prisma, OWNER_ID),
]),
);
expect(
seeded.filter(([, count]) => count === 0).map(([model]) => model),
"seeded no row for these, so the assertion after the restore would " +
"compare nothing against nothing and pass",
).toEqual([]);
// Pinned so the two writers below describe the same instant and can be
// compared byte for byte; `exportedAt` otherwise defaults to `new Date()`.
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
exportedAt,
});
// The writer the weekly job actually uses never builds this payload: it
// pulls the unbounded tables through page by page and serialises each row
// straight into the cipher, because materialising them is what used to
// exhaust the heap. Two writers is two chances to drift, so the file it
// produces is pinned against the one above — every per-table assertion
// below then covers BOTH, since the row that gets restored comes from the
// streamed bytes.
let streamedJson = "";
const previewCollector = await createBackupPreviewCollector();
const streamedCounts = await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
streamedJson += chunk;
},
{
purpose: "disaster-recovery",
exportedAt,
observe: previewCollector.observe,
},
);
expect(
streamedJson,
"the streaming writer and the materialising builder disagree; the " +
"weekly backup no longer contains what this test proves restorable",
).toBe(JSON.stringify(payload));
// The weekly copy's restore preview is taken from the writer's counts and
// the sections it was shown, never by reading the copy. On an account
// with a row in every model, it has to say what a full read of the copy
// says, count for count and key use for key use.
const fromWriter = previewCollector.finish(streamedCounts);
expect(fromWriter).not.toBeNull();
expect(
fromWriter!.summary,
"the preview taken from the writer's counts disagrees with a full read",
).toEqual(summarizeBackup(parseBackupPayload(streamedJson)));
const readBack = new BackupKeyIdCollector();
readBack.visit(JSON.parse(streamedJson));
expect(fromWriter!.keys.toStored()).toEqual(readBack.toStored());
// The tombstone has to be IN the file. A disaster-recovery payload that
// carries only live rows is smaller and cheaper and wrong: the restore
// wipes the account first, so a deletion that is not in the file is a
// deletion the account no longer knows it made, and the next device sync
// re-inserts every one of those samples as fresh readings.
const exportedMeasurements = payload.measurements as Array<{
externalId: string | null;
deletedAt: string | null;
syncVersion: number;
}>;
expect(
exportedMeasurements.find(
(row) => row.externalId === "round-trip-deleted-sample",
),
"a soft-deleted reading is missing from the disaster-recovery payload",
).toMatchObject({
deletedAt: TOMBSTONE_DELETED_AT,
syncVersion: 4,
});
// Cascade rather than a hand-listed sweep — see the file comment.
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const emptied = await Promise.all(
TWO_ENDED_MODELS.map((model) => COUNT_BACK[model](prisma, OWNER_ID)),
);
expect(
emptied.reduce((total, count) => total + count, 0),
"the account still owns rows after being deleted, so the restore is " +
"not what put them there",
).toBe(0);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
// The envelope the weekly worker actually writes: stream the JSON
// through gzip and the cipher without ever holding a whole copy. The
// three cases further down deliberately keep the plain `encrypt(json)`
// form, which is what every row written before any of this existed
// looks like — an operator's newest usable copy may well be one of
// those, so every arm has to reach the restore route.
data: await legacyStreamedBlobFrom(async (write) => {
await write(streamedJson);
}),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
expect(
body.data.skipped.links,
"a dropped link is a row that did not " +
"come back, reported rather than silent",
).toBe(0);
const restored = await Promise.all(
TWO_ENDED_MODELS.map(async (model) => [
model,
await COUNT_BACK[model](prisma, OWNER_ID),
]),
);
expect(
restored.filter(([, count]) => count === 0).map(([model]) => model),
"the plan claims these travel both ways; the account was restored " +
"without them, which is a backup that reports success and hands back " +
"less than it was given",
).toEqual([]);
const sideEffect = await prisma.medicationSideEffect.findFirstOrThrow({
where: { userId: OWNER_ID },
include: { medication: { select: { name: true, userId: true } } },
});
expect({
category: sideEffect.category,
entry: sideEffect.entry,
severity: sideEffect.severity,
occurredAt: sideEffect.occurredAt.toISOString(),
medication: sideEffect.medication.name,
medicationOwner: sideEffect.medication.userId,
note: readNote(sideEffect.notesEncrypted, sideEffect.notes),
}).toEqual({
category: "GI",
entry: "NAUSEA",
severity: 3,
occurredAt: "2026-07-01T21:00:00.000Z",
medication: "Round-trip tablet",
medicationOwner: OWNER_ID,
note: SIDE_EFFECT_NOTE,
});
// The note must be BACK IN THE COLUMN it came from, not carried as
// plaintext: a restore that writes a portable export's decrypted note into
// `notes` reads identically through `readNote` above and has quietly
// un-encrypted the account's free text.
expect(sideEffect.notes, "plaintext must not come back in the column").toBe(
null,
);
// Both pause eras, and the open one specifically. The count above only
// says two rows returned; a restore that closed the running pause at
// restore time would satisfy it exactly, and the account would come back
// saying a medication it is still off had been resumed — which the
// compliance recomputation then reads as doses missed on purpose-taken
// days. `null` is the assertion that matters here.
const eras = await prisma.medicationPauseEra.findMany({
where: { userId: OWNER_ID },
orderBy: { pausedAt: "asc" },
select: { pausedAt: true, resumedAt: true },
});
expect(
eras.map((e) => ({
pausedAt: e.pausedAt.toISOString(),
resumedAt: e.resumedAt ? e.resumedAt.toISOString() : null,
})),
"an open pause era must come back open",
).toEqual([
{
pausedAt: "2026-05-02T08:00:00.000Z",
resumedAt: "2026-05-20T08:00:00.000Z",
},
{ pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null },
]);
// The setup answers came back as answers, not as an empty questionnaire.
//
// Asserted column by column rather than by count: the row returning proves
// nothing about `modulesDerivedAt`, and that field is the once-only latch
// on the module derivation — a restore that let it default to null would
// hand the next confirm permission to re-apply the questionnaire over
// every module decision the account has taken since, silently.
const restoredOnboarding = await prisma.onboardingRecord.findUniqueOrThrow({
where: { userId: OWNER_ID },
});
expect({
needs: restoredOnboarding.needsJson,
steps: restoredOnboarding.stepsJson,
firstResult: restoredOnboarding.firstResultJson,
modulesDerivedAt: restoredOnboarding.modulesDerivedAt?.toISOString(),
completedAt: restoredOnboarding.completedAt?.toISOString(),
}).toEqual({
needs: {
recordTarget: "me",
areas: ["blood-pressure", "sleep"],
medication: "yes",
sources: ["withings"],
visit: "within-a-month",
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
},
// The stored blob is a status LOOKUP, and the reader answers with the
// full ordered nine — a step the fixture never mentioned comes back
// `pending` rather than missing, which is what lets the flow grow a
// screen without a migration.
steps: [
{ id: "who", status: "done" },
{ id: "areas", status: "done" },
{ id: "medication", status: "pending" },
{ id: "sources", status: "skipped" },
{ id: "visit", status: "pending" },
{ id: "units", status: "pending" },
{ id: "confirm", status: "pending" },
{ id: "first-result", status: "pending" },
{ id: "done", status: "pending" },
],
firstResult: {
task: "connect-source",
target: "withings",
completedAt: "2026-07-01T08:30:00.000Z",
},
modulesDerivedAt: "2026-07-01T08:00:00.000Z",
completedAt: "2026-07-01T08:00:00.000Z",
});
// The Coach came back able to speak, and the fence held.
//
// `documentScoped` is asserted per thread rather than in aggregate: a
// restore that wrote `false` everywhere would still return two threads,
// two counts and every turn, and the only visible difference would be a
// fenced conversation quietly holding a tool loop again.
// Resolved from the database rather than remembered from the fixture: the
// attachment has to point at the document the RESTORE put back, not at an
// id that happens to match one the seeding used.
const vaultDocument = await prisma.inboundDocument.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
sourceSystem: vaultDocument.sourceSystem,
sourceId: vaultDocument.sourceId,
aiReadDeferred: vaultDocument.aiReadDeferred,
}).toEqual({
sourceSystem: "PAPERLESS",
sourceId: "4711",
aiReadDeferred: true,
});
const metricEntries = await prisma.customMetricEntry.findMany({
where: { userId: OWNER_ID },
orderBy: { measuredAt: "asc" },
});
expect(
metricEntries.map((entry) => ({
note: readNote(entry.noteEncrypted, entry.note),
readable: entry.note,
})),
"both reading notes come back, sealed, with nothing readable",
).toEqual([
{ note: METRIC_LEGACY_NOTE, readable: null },
{ note: METRIC_SEALED_NOTE, readable: null },
]);
const threads = await prisma.coachConversation.findMany({
where: { userId: OWNER_ID },
orderBy: { createdAt: "asc" },
include: {
messages: { orderBy: { createdAt: "asc" } },
attachments: true,
},
});
expect(
threads.map((thread) => ({
title: readNote(thread.titleEncrypted, thread.title),
// A restore seals every title and never writes the readable column.
readableTitle: thread.title,
documentScoped: thread.documentScoped,
summary: thread.summaryEncrypted
? decryptFromBytes(thread.summaryEncrypted)
: null,
summaryTurnCount: thread.summaryTurnCount,
turns: thread.messages.map((message) => ({
role: message.role,
content: decryptFromBytes(message.encryptedContent),
model: message.model,
tokensUsed: message.tokensUsed,
results: message.resultsEncrypted
? decryptFromBytes(message.resultsEncrypted)
: null,
trail: message.trailEncrypted
? decryptFromBytes(message.trailEncrypted)
: null,
})),
attachments: thread.attachments.map((a) => a.documentId),
})),
"the transcript, the fence and the provenance all come back",
).toEqual([
{
title: "How is my blood pressure trending?",
readableTitle: null,
documentScoped: false,
summary: COACH_SUMMARY,
summaryTurnCount: 4,
turns: [
{
role: "user",
content: COACH_USER_TURN,
model: null,
tokensUsed: null,
results: null,
trail: null,
},
{
role: "assistant",
content: COACH_ASSISTANT_TURN,
model: "claude-opus-5",
tokensUsed: 812,
results: COACH_RESULTS_JSON,
trail: COACH_TRAIL_JSON,
},
],
attachments: [],
},
{
title: "About my June labs",
readableTitle: null,
documentScoped: true,
summary: null,
summaryTurnCount: 0,
turns: [
{
role: "user",
content: "What does the ferritin mean?",
model: null,
tokensUsed: null,
results: null,
trail: null,
},
],
attachments: [vaultDocument.id],
},
]);
// The Coach's memory, and both of its references still pointing at
// something. Neither is a foreign key, so a restore that wrote them back
// as null would break nothing, satisfy every count, and leave the account
// with a Coach that remembers a fact and no longer knows where it came
// from.
const restoredThread = await prisma.coachConversation.findFirstOrThrow({
where: { userId: OWNER_ID, documentScoped: false },
});
const restoredPlan = await prisma.coachPlan.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const fact = await prisma.coachFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const restoredAnswer = await prisma.coachMessage.findFirstOrThrow({
where: { conversationId: restoredThread.id, role: "assistant" },
});
expect({
fact: decryptFromBytes(fact.factEncrypted),
category: fact.category,
confidence: fact.confidence,
source: fact.sourceConversationId,
origin: fact.source,
lastUsedAt: fact.lastUsedAt?.toISOString(),
sourceMessageId: fact.sourceMessageId,
deletedAt: fact.deletedAt,
}).toEqual({
fact: COACH_FACT,
category: "constraint",
confidence: 80,
source: restoredThread.id,
origin: "user",
lastUsedAt: "2026-07-20T10:00:00.000Z",
sourceMessageId: restoredAnswer.id,
deletedAt: null,
});
expect({
metric: restoredPlan.metric,
ifCue: decryptFromBytes(restoredPlan.ifCueEncrypted),
thenAction: decryptFromBytes(restoredPlan.thenActionEncrypted),
target: restoredPlan.targetEncrypted
? decryptFromBytes(restoredPlan.targetEncrypted)
: null,
status: restoredPlan.status,
source: restoredPlan.sourceConversationId,
}).toEqual({
metric: "BLOOD_PRESSURE",
ifCue: COACH_PLAN_CUE,
thenAction: COACH_PLAN_ACTION,
target: "under 130 by October",
status: "active",
source: restoredThread.id,
});
const reminder = await prisma.coachReminder.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(
{
note: decryptFromBytes(reminder.noteEncrypted),
relatedPlanId: reminder.relatedPlanId,
source: reminder.sourceConversationId,
surfaceCount: reminder.surfaceCount,
status: reminder.status,
},
"the reminder still knows which plan it belongs to",
).toEqual({
note: COACH_REMINDER_NOTE,
relatedPlanId: restoredPlan.id,
source: restoredThread.id,
surfaceCount: 2,
status: "active",
});
// The screener came back readable, and the flag came back as recorded.
const screener = await prisma.mentalHealthAssessment.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
instrument: screener.instrument,
totalScore: screener.totalScore,
severityBand: screener.severityBand,
item9Flagged: screener.item9Flagged,
crisisShown: screener.crisisShownAt?.toISOString() ?? null,
responses: decryptFromBytes(screener.responsesEncrypted),
}).toEqual({
instrument: "PHQ9",
totalScore: 14,
severityBand: "moderate",
item9Flagged: true,
crisisShown: "2026-07-05T18:02:00.000Z",
responses: PHQ9_RESPONSES,
});
// Both consents, and the revocation still standing. A restore that lost
// `revokedAt` would return the same two rows and turn a withdrawn
// agreement back into an active one.
const consents = await prisma.consentReceipt.findMany({
where: { userId: OWNER_ID },
orderBy: { signedAt: "asc" },
});
expect(
consents.map((c) => ({
kind: c.kind,
artefact: c.artefact,
revoked: c.revokedAt?.toISOString() ?? null,
})),
"a revoked consent must not come back as an active one",
).toEqual([
{ kind: "ai-processing", artefact: CONSENT_ARTEFACT, revoked: null },
{
kind: "research-sharing",
artefact: "eyJhbGciOiJub25lIn0.e30.",
revoked: "2026-07-10T11:00:00.000Z",
},
]);
// The account's own grouping came back, and the hidden tag is hidden
// again — resolved by key, so it points at whatever id this instance uses.
const ownCategories = await prisma.moodTagCategory.findMany({
where: { userId: OWNER_ID },
});
expect(
ownCategories.map((c) => ({ key: c.key, labelKey: c.labelKey })),
).toEqual([
{
key: "round_trip_own_category",
labelKey: "mood.category.roundTripOwn",
},
]);
const restoredTag = await prisma.moodTag.findFirstOrThrow({
where: { key: "round_trip_factor" },
});
expect(
restoredTag.categoryId,
"the custom tag is grouped under the account's own category",
).toBe(ownCategories[0].id);
const hidden = await prisma.moodTagHidden.findMany({
where: { userId: OWNER_ID },
include: { moodTag: { select: { key: true, userId: true } } },
});
// Exactly one, and it points at a SEEDED tag — which is only possible if
// the key was resolved against this instance's catalogue rather than an id
// carried in the file.
expect(hidden).toHaveLength(1);
expect(hidden[0].moodTag.userId).toBeNull();
// Every threshold as tuned, including the one that is not in minutes.
const phase = await prisma.reminderPhaseConfig.findFirstOrThrow({
where: { medication: { userId: OWNER_ID } },
});
expect({
green: [phase.greenValue, phase.greenMode],
yellow: [phase.yellowValue, phase.yellowMode],
orange: [phase.orangeValue, phase.orangeMode],
red: [phase.redValue, phase.redMode],
}).toEqual({
green: [45, "MINUTES"],
yellow: [20, "PERCENT"],
orange: [5, "MINUTES"],
red: [180, "MINUTES"],
});
// The shelf, and the count that must not be recomputed.
const packs = await prisma.medicationInventoryItem.findMany({
where: { userId: OWNER_ID },
orderBy: { createdAt: "asc" },
});
expect(
packs.map((pack) => ({
state: pack.state,
containerType: pack.containerType,
total: pack.unitsTotal.toString(),
remaining: pack.unitsRemaining.toString(),
manufacturer: pack.manufacturer,
note: readNote(pack.notesEncrypted, pack.notes),
plaintextColumn: pack.notes,
})),
"the remaining count comes back as recorded, not as recalculated",
).toEqual([
{
state: "ACTIVE",
containerType: "BLISTER",
total: "30",
remaining: "21",
manufacturer: "Fixture Pharma",
note: INVENTORY_NOTE,
plaintextColumn: null,
},
{
state: "USED_UP",
containerType: "BOTTLE",
total: "60",
remaining: "0",
manufacturer: null,
note: null,
plaintextColumn: null,
},
]);
const stockLedger = await prisma.medicationInventoryEvent.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { occurredAt: "asc" },
});
expect(
stockLedger.map((e) => ({ delta: e.delta, reason: e.reason })),
).toEqual([
{ delta: 30, reason: "purchased" },
{ delta: -8, reason: "consumed" },
]);
// The ramp, in order, with the note back in its column. Counting says two
// rows returned; only this says they came back as the same ramp, and that
// the encrypted note did not land in the plaintext column on the way.
const doses = await prisma.medicationDoseChange.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { effectiveFrom: "asc" },
});
expect(
doses.map((d) => ({
effectiveFrom: d.effectiveFrom.toISOString(),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
note: readNote(d.noteEncrypted, d.note),
plaintextColumn: d.note,
})),
"the titration must come back as the same ramp, in order",
).toEqual([
{
effectiveFrom: "2026-04-01T08:00:00.000Z",
doseValue: 5,
doseUnit: "mg",
note: DOSE_CHANGE_NOTE,
plaintextColumn: null,
},
{
effectiveFrom: "2026-06-01T08:00:00.000Z",
doseValue: 10,
doseUnit: "mg",
note: null,
plaintextColumn: null,
},
]);
// The dose, read back column by column. The count above says a row
// returned; a restore that wrote one row with every optional column
// defaulted would satisfy it and hand back an Impfpass line with no
// antigen, no batch code and no arm. The two remapped references are
// asserted as ids that exist rather than as the ids the file carried,
// because that is the property the remap owes.
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
where: { userId: OWNER_ID },
include: {
practitioner: { select: { name: true } },
encounter: { select: { occurredAt: true } },
reminder: { select: { label: true } },
customVaccine: true,
documentLinks: { select: { documentId: true } },
},
});
expect({
occurredAt: dose.occurredAt.toISOString(),
antigenSlug: dose.antigenSlug,
vaccineName: dose.vaccineName,
doseNumber: dose.doseNumber,
seriesDoses: dose.seriesDoses,
lotNumber: dose.lotNumber,
site: dose.site,
practitioner: dose.practitioner?.name ?? null,
encounterAt: dose.encounter?.occurredAt.toISOString() ?? null,
// The booster reference, resolved through the relation: non-null proves
// the id survived the round trip instead of dropping to NULL the way it
// had to before the reminders travelled.
reminder: dose.reminder?.label ?? null,
customVaccine: dose.customVaccine
? {
name: dose.customVaccine.name,
components: dose.customVaccine.components,
typicalSeriesDoses: dose.customVaccine.typicalSeriesDoses,
boosterIntervalMonths: dose.customVaccine.boosterIntervalMonths,
}
: null,
links: dose.documentLinks.length,
note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null,
}).toEqual({
occurredAt: "2026-05-14T00:00:00.000Z",
antigenSlug: "tdap",
vaccineName: "Tetanus, diphtheria and pertussis",
doseNumber: 3,
seriesDoses: 3,
lotNumber: "RT-4471",
site: "LEFT_ARM",
practitioner: "Round-trip practice",
encounterAt: "2026-06-30T08:00:00.000Z",
reminder: "Blutdruck messen",
customVaccine: {
name: "Round-trip travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 36,
},
links: 1,
note: "sore arm for a day, nothing else",
});
// The reminder, read back column by column — including the three
// v1.37.20 columns (snooze cursor, last skip, skip counter), because a
// restore that defaulted them would hand back a cadence that looks never
// snoozed and never skipped, which is a history the account does not have.
const restoredReminder = await prisma.measurementReminder.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
label: restoredReminder.label,
measurementType: restoredReminder.measurementType,
intervalDays: restoredReminder.intervalDays,
rrule: restoredReminder.rrule,
anchorDate: restoredReminder.anchorDate,
endsOn: restoredReminder.endsOn,
origin: restoredReminder.origin,
notifyHour: restoredReminder.notifyHour,
location: restoredReminder.location,
nextDueAt: restoredReminder.nextDueAt?.toISOString() ?? null,
lastSatisfiedAt: restoredReminder.lastSatisfiedAt?.toISOString() ?? null,
enabled: restoredReminder.enabled,
vaccinationAntigen: restoredReminder.vaccinationAntigen,
snoozedUntil: restoredReminder.snoozedUntil?.toISOString() ?? null,
lastSkippedAt: restoredReminder.lastSkippedAt?.toISOString() ?? null,
skipCount: restoredReminder.skipCount,
deletedAt: restoredReminder.deletedAt,
}).toEqual({
label: "Blutdruck messen",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 8,
location: "Zuhause",
nextDueAt: "2026-07-08T06:00:00.000Z",
lastSatisfiedAt: "2026-07-01T06:05:00.000Z",
enabled: true,
vaccinationAntigen: "tetanus",
snoozedUntil: "2026-07-08T06:00:00.000Z",
lastSkippedAt: "2026-06-24T06:00:00.000Z",
skipCount: 2,
deletedAt: null,
});
// Both ledger rows, whole: the satisfy and the skip, with the resolved
// `onTime` verdicts. A restore cannot re-derive `onTime` — the pre-event
// `nextDueAt` is gone — so a defaulted value here would be an invented
// punctuality record.
const ledger = await prisma.measurementReminderEvent.findMany({
where: { userId: OWNER_ID },
orderBy: { occurredAt: "asc" },
});
expect(
ledger.map((event) => ({
reminderId: event.reminderId,
kind: event.kind,
occurredAt: event.occurredAt.toISOString(),
onTime: event.onTime,
source: event.source,
})),
).toEqual([
{
reminderId: restoredReminder.id,
kind: "SKIPPED",
occurredAt: "2026-06-24T06:00:00.000Z",
onTime: false,
source: "skip",
},
{
reminderId: restoredReminder.id,
kind: "SATISFIED",
occurredAt: "2026-07-01T06:05:00.000Z",
onTime: true,
source: "manual",
},
]);
// The vault came back SORTED, not merely populated.
//
// The count says one filing returned. It cannot say which page was filed
// under which condition, and the account has two conditions — so a restore
// that paired the document with the first episode it found would satisfy
// every count above and hand back a lab report filed under a head cold.
// The pair is resolved through the relations rather than compared against
// the ids the fixture used, because both ends were re-created by the
// restore and it is the pairing that has to survive, not the identifiers.
const filing = await prisma.documentConditionLink.findFirstOrThrow({
where: { userId: OWNER_ID },
include: {
document: { select: { title: true } },
episode: { select: { label: true } },
},
});
expect(
{
document: filing.document.title,
condition: filing.episode.label,
createdAt: filing.createdAt.toISOString(),
},
"the page must come back filed under the condition it was filed under",
).toEqual({
document: "June labs",
condition: "Iron deficiency",
createdAt: "2026-07-02T09:00:00.000Z",
});
// The condition's body site and side (v1.39.2). A restore that dropped
// either would still return the right number of episodes; the site must
// come back as ciphertext that reads as what was typed, and the side as
// it was.
const sitedEpisode = await prisma.illnessEpisode.findFirstOrThrow({
where: { userId: OWNER_ID, label: "Iron deficiency" },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(
{
bodySite: sitedEpisode.bodySiteEncrypted
? decryptFromBytes(sitedEpisode.bodySiteEncrypted)
: null,
laterality: sitedEpisode.laterality,
},
"a condition's body site and side must survive the round trip",
).toEqual({ bodySite: "Stomach", laterality: "BOTH" });
// v1.40 — the person's own symptoms: the names decrypt to what was typed,
// each occurrence keeps its time, intensity and note, and the one filed
// against the cold still points at the cold.
const restoredSymptoms = await prisma.symptomDefinition.findMany({
where: { userId: OWNER_ID },
orderBy: { sortOrder: "asc" },
include: {
events: {
orderBy: { occurredAt: "asc" },
include: { episode: { select: { label: true } } },
},
},
});
expect(
restoredSymptoms.map((definition) => ({
label: decryptFromBytes(definition.labelEncrypted),
icon: definition.icon,
isActive: definition.isActive,
events: definition.events.map((event) => ({
at: event.occurredAt.toISOString(),
intensity: event.intensity,
note: event.noteEncrypted
? decryptFromBytes(event.noteEncrypted)
: null,
episode: event.episode?.label ?? null,
})),
})),
"symptom definitions and their occurrences must survive the round trip",
).toEqual([
{
label: "Aura",
icon: "Zap",
isActive: true,
events: [
{
at: "2026-06-21T09:00:00.000Z",
intensity: 4,
note: null,
episode: "Cold",
},
{
at: "2026-07-10T18:30:00.000Z",
intensity: 2,
note: null,
episode: null,
},
],
},
{
label: "Headache",
icon: "Brain",
isActive: false,
events: [
{
at: "2026-06-21T11:00:00.000Z",
intensity: 8,
note: "left side, behind the eye",
episode: null,
},
],
},
]);
// v1.42 — the life events: the titles and the note decrypt to what was
// typed, and the dates keep their precision.
const restoredLifeEvents = await prisma.lifeEvent.findMany({
where: { userId: OWNER_ID },
orderBy: { startDate: "asc" },
});
expect(
restoredLifeEvents.map((event) => ({
category: event.category,
startDate: event.startDate,
endDate: event.endDate,
precision: event.precision,
title: decryptFromBytes(event.titleEncrypted),
note: event.noteEncrypted
? decryptFromBytes(event.noteEncrypted)
: null,
})),
"life events must survive the round trip",
).toEqual([
{
category: "HOME",
startDate: "2023-09-01",
endDate: null,
precision: "MONTH",
title: "Moved to the coast",
note: "the flat with the long hallway",
},
{
category: "WORK",
startDate: "2026-03-02",
endDate: "2026-05-29",
precision: "DAY",
title: "Parental leave",
note: null,
},
]);
// The staged fact, and the decision on it.
//
// This is the assertion the count cannot make. `status`, `needsReview`,
// `committedRecordId` and `committedRecordType` all default to "nobody has
// looked at this yet", so a restore that wrote the row and ignored them
// returns exactly one fact, exactly as the plan promises, and hands the
// account a reviewed and committed reading back in its review queue. The
// confirm endpoint acts only on a PENDING fact and commits it through the
// normal create, so approving it a second time writes a SECOND ferritin
// result. The commitment is checked against the lab row the restore itself
// wrote, because a pointer that survives as a string but no longer names a
// live row is the same loss wearing a value.
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const stagedFact = await prisma.extractedFact.findFirstOrThrow({
where: { userId: OWNER_ID },
include: { document: { select: { title: true } } },
});
expect(
{
document: stagedFact.document.title,
factType: stagedFact.factType,
status: stagedFact.status,
confidence: stagedFact.confidence,
needsReview: stagedFact.needsReview,
committedRecordId: stagedFact.committedRecordId,
committedRecordType: stagedFact.committedRecordType,
data: decryptFactData(stagedFact.dataEncrypted),
provenance: decryptFactProvenance(stagedFact.provenanceEncrypted),
},
"a reviewed and committed fact must not come back up for review",
).toEqual({
document: "June labs",
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.94,
needsReview: false,
committedRecordId: restoredLab.id,
committedRecordType: "labResult",
data: {
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
effectiveDate: "2026-06-30",
},
provenance: {
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.94,
},
});
// The archived eras, and the CHAIN through them.
//
// Counting says three eras returned, and three eras with every link null
// would satisfy it exactly while having thrown away the only thing the
// pointer states. That is not a cosmetic loss: every era consumer skips a
// superseded row, so a corrected era whose link came back null goes live
// again beside the correction that replaced it, and the account gets two
// overlapping plans for one window with its past compliance minted against
// whichever the query happened to read.
//
// Asserted as the shape of the chain rather than as ids: the ids are fresh
// on a portable restore, and what the two-pass write owes is that era two
// still points at era three and that nothing else points anywhere.
const scheduleEras = await prisma.medicationScheduleRevision.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: [{ validFrom: "asc" }, { createdAt: "asc" }, { id: "asc" }],
});
const eraPositionById = new Map(
scheduleEras.map((era, index) => [era.id, index]),
);
expect(
scheduleEras.map((era) => ({
validFrom: era.validFrom.toISOString(),
validUntil: era.validUntil.toISOString(),
source: era.source,
payload: era.payload,
supersedes: era.supersededByRevisionId
? (eraPositionById.get(era.supersededByRevisionId) ?? "dangling")
: null,
})),
"the corrected era must come back still pointing at its correction",
).toEqual([
{
validFrom: "2026-01-01T00:00:00.000Z",
validUntil: "2026-03-01T00:00:00.000Z",
source: "ARCHIVED",
payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }],
supersedes: null,
},
{
validFrom: "2026-02-01T00:00:00.000Z",
validUntil: "2026-03-01T00:00:00.000Z",
source: "ARCHIVED",
payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }],
supersedes: 2,
},
{
validFrom: "2026-03-01T00:00:00.000Z",
validUntil: "2026-05-01T00:00:00.000Z",
source: "MANUAL",
payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }],
supersedes: null,
},
]);
// What the pointer actually buys, stated as the thing the app reads: the
// timeline query drops superseded rows, so exactly two eras are live.
expect(
await prisma.medicationScheduleRevision.count({
where: {
medication: { userId: OWNER_ID },
supersededByRevisionId: null,
},
}),
"a lost pointer puts a corrected era back on the timeline",
).toBe(2);
// The pinned target, and the analyte it points at.
//
// Counting says one override returned. It cannot say the override still
// MEANS anything: a restore that wrote the row with `biomarkerId` nulled
// returns a drug with no statement of what it was for, which is precisely
// what the register said the alternative cost, and nothing anywhere would
// complain — the resolver silently falls back to deriving a target and the
// page still renders.
//
// Both ends are resolved from the DATABASE rather than remembered from the
// fixture. The property the second pass owes is that the target points at
// the biomarker THIS restore wrote, which an id carried over from the
// seeding would not distinguish from a restore that copied a stale value.
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const restoredMedication = await prisma.medication.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const target = await prisma.medicationEfficacyTarget.findFirstOrThrow({
where: { medication: { userId: OWNER_ID } },
include: { biomarker: { select: { name: true, userId: true } } },
});
expect({
medicationId: target.medicationId,
biomarkerId: target.biomarkerId,
biomarkerName: target.biomarker?.name ?? null,
biomarkerOwner: target.biomarker?.userId ?? null,
measurementType: target.measurementType,
primary: target.primary,
}).toEqual({
medicationId: restoredMedication.id,
biomarkerId: restoredBiomarker.id,
biomarkerName: "Ferritin",
biomarkerOwner: OWNER_ID,
measurementType: null,
primary: true,
});
// The strip, read back column by column.
//
// Counting proves a recording returned. It cannot tell that recording from
// one whose trace is an empty array, whose verdict defaulted to NULL and
// whose instant is the restore time — and each of those three is the whole
// value of the row on its own. A strip with no rhythm classification is a
// squiggle nobody has read; a strip with no `recordedAt` belongs to no day
// and cannot be put beside the event it explains; a strip with no samples
// is a claim that an ECG exists which nothing can draw.
//
// `measurementId` is resolved from the DATABASE rather than remembered
// from the fixture, because the property the remap owes is that the strip
// points at the event row THIS restore wrote.
const restoredEvent = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, type: "IRREGULAR_RHYTHM_NOTIFICATION" },
});
const strip = await prisma.ecgRecording.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
source: strip.source,
externalRecordingId: strip.externalRecordingId,
recordedAt: strip.recordedAt.toISOString(),
samplingFrequency: strip.samplingFrequency,
sampleCount: strip.sampleCount,
durationSeconds: strip.durationSeconds,
lead: strip.lead,
averageHeartRate: strip.averageHeartRate,
rhythmClassification: strip.rhythmClassification,
measurementId: strip.measurementId,
waveform: decryptWaveformFromBytes(strip.waveformEncrypted),
}).toEqual({
source: "WITHINGS",
externalRecordingId: "round-trip-signal-1",
recordedAt: "2026-06-15T14:22:00.000Z",
samplingFrequency: 300,
sampleCount: ECG_SAMPLES.length,
durationSeconds: ECG_SAMPLES.length / 300,
lead: "I",
averageHeartRate: 88,
rhythmClassification: "IRREGULAR",
measurementId: restoredEvent.id,
waveform: ECG_SAMPLES,
});
// The appointment's reminder reference survives too — same remap, other
// referrer.
const restoredEncounter = await prisma.encounter.findFirstOrThrow({
where: { userId: OWNER_ID },
select: { reminderId: true },
});
expect(
restoredEncounter.reminderId,
"the encounter's reminder reference must survive now that the reminder travels",
).toBe(restoredReminder.id);
// The bests, read back column by column.
//
// Three rows came back, which the count above already said. What it could
// not say is that they still describe three different records: the two
// running bests share a metric type and an instant, so the slot is the
// only thing separating "best 5 km" from "best 10 km", and `direction` is
// the only thing that stops a best time being read as a worst one. The
// measurement pointer is asserted against the row the RESTORE wrote rather
// than against the id the fixture used, because resolving to something
// that exists is the property the reference owes.
const restoredTombstone = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, externalId: "round-trip-deleted-sample" },
});
expect(
{
deletedAt: restoredTombstone.deletedAt?.toISOString() ?? null,
syncVersion: restoredTombstone.syncVersion,
},
"the deleted reading came back, but not as a deletion — a tombstone " +
"restored without its `deletedAt` is a resurrected measurement",
).toEqual({ deletedAt: TOMBSTONE_DELETED_AT, syncVersion: 4 });
const restoredMeasurement = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, type: "WEIGHT" },
});
const bests = await prisma.personalRecord.findMany({
where: { userId: OWNER_ID },
orderBy: [{ achievedAt: "asc" }, { metricSlot: "asc" }],
});
expect(
bests.map((best) => ({
metricType: best.metricType,
metricSlot: best.metricSlot,
direction: best.direction,
value: best.value,
unit: best.unit,
achievedAt: best.achievedAt.toISOString(),
sourceMeasurementId: best.sourceMeasurementId,
source: best.source,
externalId: best.externalId,
})),
"each best must come back as its own record, with the direction and the sport slot that make it one",
).toEqual([
{
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_10km_time",
direction: "MIN",
value: 3184,
unit: "s",
achievedAt: "2026-05-18T09:20:00.000Z",
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: "workout-pr-10km",
},
{
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_5km_time",
direction: "MIN",
value: 1512,
unit: "s",
achievedAt: "2026-05-18T09:20:00.000Z",
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: "workout-pr-5km",
},
{
metricType: "WEIGHT",
metricSlot: null,
direction: "MIN",
value: 74.2,
unit: "kg",
achievedAt: "2026-07-01T07:00:00.000Z",
// Not `null`: the pointer resolved against a measurement this restore
// actually wrote. A restore that nulled it would report the drop, and
// one that wrote it before the measurements existed would fail the
// foreign key rather than reach this line.
sourceMeasurementId: restoredMeasurement.id,
source: "MANUAL",
externalId: null,
},
]);
// The badges, and the only field on them that matters.
//
// Both rows would come back from a restore that stamped `unlockedAt` with
// the moment of the restore, and every count in this file would still be
// green, while the account was handed a wall of badges it had apparently
// earned all at once, today. Several of these dates cannot be re-derived
// at all: the evaluator prefers a persisted date, and the counters behind
// the login and Easter-egg badges are not in any backup.
const badges = await prisma.userAchievement.findMany({
where: { userId: OWNER_ID },
orderBy: { unlockedAt: "asc" },
});
expect(
badges.map((badge) => ({
achievementId: badge.achievementId,
unlockedAt: badge.unlockedAt.toISOString(),
})),
"a badge comes back with the day it was earned, and with the id it was earned under",
).toEqual([
{
// A definition this build does not ship, kept verbatim rather than
// judged against the catalogue, the same answer the restore gives a
// retired antigen slug.
achievementId: "retired-badge-from-an-older-release",
unlockedAt: "2025-11-05T08:00:00.000Z",
},
{
achievementId: "intake-total-10",
unlockedAt: "2026-02-21T10:15:00.000Z",
},
]);
// The environmental history, read back as a PAIR.
//
// Counting says two readings and one location period returned. It cannot
// say that the trip day still knows it was a trip day, which is the whole
// content of these rows: the coordinates, the label and `source` are what
// separate a fortnight in Barcelona from a fortnight at home, and the
// period is what keeps the next refresh from re-resolving the day and
// upserting Berlin's weather over it.
const readings = await prisma.environmentContext.findMany({
where: { userId: OWNER_ID },
orderBy: { date: "asc" },
});
expect(
readings.map((reading) => ({
date: reading.date,
// The sealed location, opened (v1.42).
...(({ lat, lon, label }) => ({ lat, lon, locationLabel: label }))(
openLocation(reading.locationEncrypted!),
),
source: reading.source,
tempMean: reading.tempMean,
pressureDelta: reading.pressureDelta,
daylightSec: reading.daylightSec,
weatherCode: reading.weatherCode,
// Verbatim, not re-stamped: this says when the feed was read, and a
// restore that wrote "now" would claim a two-year-old provisional
// reading had just been confirmed.
fetchedAt: reading.fetchedAt.toISOString(),
})),
"each day comes back at the place it was actually read for",
).toEqual([
{
date: "2026-06-15",
lat: 41.4,
lon: 2.2,
locationLabel: "Barcelona",
source: "TRAVEL",
tempMean: 23.6,
pressureDelta: 3.2,
daylightSec: 52_800,
weatherCode: 1,
fetchedAt: "2026-06-16T03:15:00.000Z",
},
{
date: "2026-07-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Berlin",
source: "HOME",
tempMean: 17.9,
pressureDelta: 7.8,
daylightSec: 59_400,
weatherCode: 61,
fetchedAt: "2026-07-02T03:15:00.000Z",
},
]);
// And the join between them, computed from what the restore wrote rather
// than from the fixture: the trip day has to fall inside the restored
// period and carry its coordinates. Both halves can be individually
// non-empty and still disagree: a period whose bounds shifted by a day no
// longer covers the reading it explains, and the reading goes back to
// being weather from nowhere.
const trip = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
const tripPlace = openLocation(trip.locationEncrypted!);
const dayPlace = openLocation(travelDay.locationEncrypted!);
expect(
{
startDate: trip.startDate,
endDate: trip.endDate,
label: tripPlace.label,
covers:
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
sameLat: tripPlace.lat === dayPlace.lat,
sameLon: tripPlace.lon === dayPlace.lon,
},
"the period must still explain the reading it was exported beside",
).toEqual({
startDate: "2026-06-10",
endDate: "2026-06-20",
label: "Barcelona",
covers: true,
sameLat: true,
sameLon: true,
});
});
/**
* A supersede pointer that names a position the file does not have.
*
* The builder computes the index from the same list it writes, so no file
* this release produces can trip this. A hand-edited or truncated one can,
* and the question it asks has to be answered somewhere: the era it names
* does not exist, the column is a bare id with no constraint to stop a
* wrong value, and inventing a neighbouring era to point at would be worse
* than either dropping or refusing.
*
* The answer is that the ERA restores and the POINTER does not, and the
* position is reported. Refusing the file would cost an operator every era
* of every drug over one bad integer; writing the link would put a
* correction pointer at whatever row happened to be there.
*/
it("keeps an archived era whose supersede pointer names a position out of range, and reports the position", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const medication = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Out-of-range tablet", dose: "5 mg" },
});
await prisma.medicationScheduleRevision.createMany({
data: [
{
medicationId: medication.id,
validFrom: AT("2026-01-01T00:00:00.000Z"),
validUntil: AT("2026-02-01T00:00:00.000Z"),
payload: [{ windowStart: "07:00", windowEnd: "09:00" }],
source: "ARCHIVED",
},
{
medicationId: medication.id,
validFrom: AT("2026-02-01T00:00:00.000Z"),
validUntil: AT("2026-03-01T00:00:00.000Z"),
payload: [{ windowStart: "08:00", windowEnd: "10:00" }],
source: "MANUAL",
},
],
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
// Hand-edit the file the way a person with a text editor would: point the
// first era at an era that is not there.
const medications = payload.medications as Array<{
scheduleRevisions: Array<{ supersededByIndex: number | null }>;
}>;
medications[0].scheduleRevisions[0].supersededByIndex = 7;
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
links: number;
}>;
expect(
reported.filter((entry) => entry.catalogue === "scheduleRevisionLink"),
"the position the file named is not in the file, so dropping the link " +
"is correct and saying nothing about it is not",
).toEqual([
{
catalogue: "scheduleRevisionLink",
key: "medications[0].scheduleRevisions[0].supersededByIndex=7",
links: 1,
},
]);
const restoredEras = await prisma.medicationScheduleRevision.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { validFrom: "asc" },
});
expect(
restoredEras.map((era) => ({
validFrom: era.validFrom.toISOString(),
source: era.source,
supersededByRevisionId: era.supersededByRevisionId,
})),
"both eras come back; only the pointer is lost",
).toEqual([
{
validFrom: "2026-01-01T00:00:00.000Z",
source: "ARCHIVED",
supersededByRevisionId: null,
},
{
validFrom: "2026-02-01T00:00:00.000Z",
source: "MANUAL",
supersededByRevisionId: null,
},
]);
});
/**
* The reference a portable file genuinely lacks, and the slug the restore
* must not judge.
*
* The reminders travel since v1.37.20, so a reference to one is no longer a
* guaranteed drop — the round trip above proves it surviving. What can still
* genuinely be missing is a TOMBSTONED reminder in a portable export: the
* builder omits it (a restore must not resurrect a deletion) and omits its
* completion ledger with it, while a live dose that recorded satisfying it
* keeps its reference in the database. That reference then points at a row
* the file does not carry, which is exactly the case the restore reports.
*
* Separated from the round trip above because this is correct behaviour
* that shows up as a reported skip, and that test asserts nothing was
* skipped. Reported is the whole point: a reference dropped in silence is
* the same defect as a row dropped in silence, one field smaller.
*/
it("reports the tombstoned reminder a portable file omits and keeps a slug it cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const reminder = await prisma.measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Tetanus booster",
intervalDays: 3650,
vaccinationAntigen: "tetanus",
deletedAt: AT("2026-07-01T00:00:00.000Z"),
},
});
// A ledger row on the tombstoned reminder. The builder must drop it from
// the file along with its reminder — a ledger row whose reminder is not
// carried could only ever restore as a reported loss.
await prisma.measurementReminderEvent.create({
data: {
userId: OWNER_ID,
reminderId: reminder.id,
kind: "SATISFIED",
occurredAt: AT("2026-06-01T09:00:00.000Z"),
onTime: true,
source: "vaccination",
},
});
await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: AT("1987-09-02T00:00:00.000Z"),
// A slug no catalogue this release ships resolves. It must come back
// exactly as written: the renderer degrades to `vaccineName`, and a
// restore that dropped or rewrote it would lose what a person was
// actually given.
antigenSlug: "retired-antigen-from-an-older-release",
vaccineName: "Whatever the Pass called it in 1987",
reminderId: reminder.id,
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
// The file must be silent about the tombstoned reminder AND its ledger —
// carrying the events of a reminder it refuses to restore would make
// every portable file this release writes restore with a reported loss.
expect(payload.measurementReminders).toEqual([]);
expect(payload.measurementReminderEvents).toEqual([]);
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
}>;
expect(
reported.filter((entry) => entry.catalogue === "vaccinationReference"),
"the reminder the dose pointed at is not in the backup at all, so " +
"dropping the reference is correct and saying nothing about it is not",
).toEqual([
{ catalogue: "vaccinationReference", key: reminder.id, links: 1 },
]);
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
antigenSlug: dose.antigenSlug,
vaccineName: dose.vaccineName,
reminderId: dose.reminderId,
}).toEqual({
antigenSlug: "retired-antigen-from-an-older-release",
vaccineName: "Whatever the Pass called it in 1987",
reminderId: null,
});
});
/**
* The hand-edited file, which is the only way the vault filing can carry a
* reference the restore cannot place.
*
* The builder carries a filing or a staged fact only when both of its ends
* are carried, so no file this release writes reaches the drop path. That is
* exactly why the path is worth a test of its own: an arm nothing exercises
* is an arm nobody notices is wrong, and here being wrong has two very
* different prices. `documentConditionLink.episodeId` is a real foreign key,
* so writing it unchecked does not lose one edge — it violates a constraint
* and rolls the WHOLE account back, which is what the mood categories did
* before they travelled. `ExtractedFact.committedRecordId` is a bare id
* column with no relation, so writing it unchecked costs no error at all and
* simply leaves a fact pointing at a lab result that is not there.
*
* Both references are broken in the same file so the two answers can be seen
* side by side: a dropped row and a nulled pointer, each named in the report,
* and a restore that still answers 200.
*/
/**
* The Coach's stored tables through a portable file.
*
* A disaster-recovery file carries them as ciphertext (the round trip at
* the top of this file). A portable file has to carry them readable, like
* the prose they sit beside, and the restore has to seal them again under
* the target's key. A restore that dropped the readable JSON would bring
* the thread back with every answer's table missing and nothing to say so.
*/
it.each(["disaster-recovery", "portable-export"] as const)(
"carries each medication's category through a %s round trip",
async (purpose) => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const thyroid = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Levothyroxine", dose: "50 µg" },
});
const mood = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Sertraline", dose: "50 mg" },
});
await prisma.medication.create({
data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" },
});
const travel = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Tamiflu", dose: "75 mg" },
});
await setMedicationCategory(thyroid.id, "THYROID");
await setMedicationCategory(mood.id, "MENTAL_HEALTH");
// v1.40 — a category the person named. Hidden, because a hidden
// category still files the medication and must travel with it.
await prisma.medicationCategoryLabel.create({
data: {
userId: OWNER_ID,
key: ROUND_TRIP_CATEGORY_KEY,
labelEncrypted: encryptToBytes("Travel kit"),
isActive: false,
},
});
await setMedicationCategory(travel.id, ROUND_TRIP_CATEGORY_KEY);
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose,
});
const exported = Object.fromEntries(
(payload.medications as Array<{ name: string; category?: string }>).map(
(m) => [m.name, m.category],
),
);
expect(exported).toEqual({
Levothyroxine: "THYROID",
Sertraline: "MENTAL_HEALTH",
Unfiled: "OTHER",
Tamiflu: ROUND_TRIP_CATEGORY_KEY,
});
expect(payload.customMedicationCategories).toEqual([
{
key: ROUND_TRIP_CATEGORY_KEY,
label: "Travel kit",
sortOrder: 0,
isActive: false,
createdAt: expect.any(String),
},
]);
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.medication.findMany({
where: { userId: OWNER_ID },
select: { id: true, name: true },
});
const categories = await resolveMedicationCategories(
restored.map((m) => m.id),
);
expect(
Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])),
).toEqual({
Levothyroxine: { category: "THYROID", categoryLabel: null },
Sertraline: { category: "MENTAL_HEALTH", categoryLabel: null },
Unfiled: { category: "OTHER", categoryLabel: null },
Tamiflu: {
category: ROUND_TRIP_CATEGORY_KEY,
categoryLabel: "Travel kit",
},
});
const label = await prisma.medicationCategoryLabel.findUniqueOrThrow({
where: { key: ROUND_TRIP_CATEGORY_KEY },
});
expect(label.isActive).toBe(false);
},
);
it("carries a turn's tables readable in a portable file and seals them again on restore", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
await prisma.coachConversation.create({
data: {
userId: OWNER_ID,
titleEncrypted: encryptToBytes("How is my blood pressure trending?"),
documentScoped: false,
messages: {
create: [
{
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
trailEncrypted: encryptToBytes(COACH_TRAIL_JSON),
createdAt: AT("2026-07-20T09:59:00.000Z"),
},
],
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
const { coachConversations } = payload as {
coachConversations: Array<{ messages: Array<Record<string, unknown>> }>;
};
const [turn] = coachConversations[0].messages;
expect(turn).toMatchObject({
resultsJson: COACH_RESULTS_JSON,
trailJson: COACH_TRAIL_JSON,
});
expect(turn).not.toHaveProperty("resultsEncrypted");
expect(turn).not.toHaveProperty("trailEncrypted");
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.coachMessage.findFirstOrThrow({
where: { conversation: { userId: OWNER_ID } },
});
expect(restored.resultsEncrypted).not.toBeNull();
expect(decryptFromBytes(restored.resultsEncrypted!)).toBe(
COACH_RESULTS_JSON,
);
expect(restored.trailEncrypted).not.toBeNull();
expect(decryptFromBytes(restored.trailEncrypted!)).toBe(COACH_TRAIL_JSON);
});
it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const episode = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Iron deficiency",
type: "CHRONIC",
onsetAt: AT("2026-03-01T00:00:00.000Z"),
},
});
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,
analyte: "Ferritin",
value: 91,
unit: "ng/mL",
takenAt: AT("2026-06-30T09:00:00.000Z"),
},
});
const documentBytes = encryptBytes(Buffer.from("truncated-file fixture"));
const contentEncrypted = new Uint8Array(
new ArrayBuffer(documentBytes.byteLength),
);
contentEncrypted.set(documentBytes);
const document = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "LAB_RESULT",
title: "June labs",
mimeType: "application/pdf",
byteSize: documentBytes.byteLength,
contentEncrypted,
contentCodec: "binary2",
},
});
await prisma.documentConditionLink.create({
data: {
userId: OWNER_ID,
documentId: document.id,
episodeId: episode.id,
},
});
await prisma.extractedFact.create({
data: {
userId: OWNER_ID,
documentId: document.id,
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.9,
needsReview: false,
committedRecordId: labResult.id,
committedRecordType: "labResult",
dataEncrypted: encryptFactData({
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-30",
}),
provenanceEncrypted: encryptFactProvenance({
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.9,
}),
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
// The edit a truncated or hand-repaired file makes: both references now
// name rows the file no longer carries.
const edited = payload as typeof payload & {
documentConditionLinks: Array<{ episodeId: string }>;
extractedFacts: Array<{ committedRecordId: string | null }>;
};
edited.documentConditionLinks[0].episodeId = "condition-not-in-this-file";
edited.extractedFacts[0].committedRecordId = "lab-not-in-this-file";
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(
response.status,
"a filing naming a condition the file does not carry must cost that " +
"filing, not the account",
).toBe(200);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
links: number;
}>;
expect(
reported.filter((entry) =>
["documentConditionLink", "factCommitment"].includes(entry.catalogue),
),
).toEqual([
{
catalogue: "documentConditionLink",
key: "condition-not-in-this-file",
links: 1,
},
{ catalogue: "factCommitment", key: "lab-not-in-this-file", links: 1 },
]);
// The filing is gone because it had nowhere to hang. The fact is NOT: what
// it lost is a pointer that was already going nowhere, and the transcribed
// reading is still the account's.
expect(
await prisma.documentConditionLink.count({ where: { userId: OWNER_ID } }),
).toBe(0);
const survivor = await prisma.extractedFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
status: survivor.status,
needsReview: survivor.needsReview,
committedRecordId: survivor.committedRecordId,
committedRecordType: survivor.committedRecordType,
data: decryptFactData(survivor.dataEncrypted),
}).toEqual({
status: "APPROVED",
needsReview: false,
// Nulled as a pair: a type with no id would claim a commitment the row
// cannot name.
committedRecordId: null,
committedRecordType: null,
data: {
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-30",
},
});
});
/**
* A note this instance can no longer decrypt leaves the export as a marker,
* not as nothing.
*
* A fail-soft decrypt answers `null` on a wrong or dropped key, and in a
* portable file `null` is byte-for-byte what a note that was never written
* looks like. The restore importer then writes no note, so a note the person
* did write is gone and no one is told — the whole loss fits inside a
* fail-soft `catch` written for a list view, where returning null is right.
*
* The Coach transcript and the doctor report both refuse that trade, one
* with a placeholder sentence and one with a rendered flag. This asserts the
* lab note now does the same, and that the marker is still there after the
* file has been through a real restore: the ciphertext is unreadable either
* way, but a person opening the restored account can see that something was
* there rather than believing they never wrote it.
*/
it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const biomarker = await prisma.biomarker.create({
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
});
// Ciphertext under a key id this instance does not hold — the shape a row
// written before a key rotation that dropped its key has. `decrypt` is
// fail-closed on an unknown key id, so this throws rather than returning
// garbage, which is the case the soft helper swallowed.
const unreadable = new Uint8Array(
Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"),
);
await prisma.labResult.create({
data: {
userId: OWNER_ID,
biomarkerId: biomarker.id,
analyte: "Ferritin",
value: 91,
unit: "ng/mL",
takenAt: AT("2026-06-30T09:00:00.000Z"),
noteEncrypted: unreadable,
},
});
await prisma.biomarker.update({
where: { id: biomarker.id },
data: { contextEncrypted: unreadable },
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
const exported = payload.labResults as Array<{ note: string | null }>;
expect(exported).toHaveLength(1);
expect(exported[0].note).toContain("unreadable");
const exportedBiomarkers = payload.biomarkers as Array<{
context: string | null;
}>;
expect(exportedBiomarkers[0].context).toContain("unreadable");
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId: OWNER_ID },
});
// Back in the column it came from, readable again because the restore
// re-encrypted it under the CURRENT key — and saying what happened.
expect(restoredLab.noteEncrypted).not.toBeNull();
expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe(
UNREADABLE_EXPORT_MARKER,
);
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
UNREADABLE_EXPORT_MARKER,
);
});
/**
* #1028 — a medication's creation instant is the floor of its expected
* slots: every adherence surface counts a slot before it only when a
* recorded dose claims it. A restore that stamped the restore time as the
* creation turned the whole restored history into "before the medication
* existed", so its misses dropped out of the rate. Both file purposes must
* bring the creation instant back.
*/
for (const purpose of ["portable-export", "disaster-recovery"] as const) {
it(`restores a medication's creation instant from a ${purpose} file`, async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const createdAt = AT("2025-11-03T16:05:00.000Z");
await prisma.medication.create({
data: {
userId: OWNER_ID,
name: "Metformin",
dose: "500mg",
createdAt,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
},
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose,
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.medication.findFirstOrThrow({
where: { userId: OWNER_ID, name: "Metformin" },
});
expect(restored.createdAt.toISOString()).toBe(createdAt.toISOString());
});
}
});
/**
* Columns a release adds ahead of the backup ends that carry them, so the
* packages built in parallel on its branch each own their own section of the
* export and the restore. NOT an exclusion: each entry names the package that
* carries it, and the companion assertion fails as soon as an entry's column
* starts coming back, so the entry has to go in the change that carries it.
* The list is empty again before the release is tagged.
*/
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {};
/**
* Columns every reader parses through one function before using, compared
* through that same function: the restore writes the parsed form.
*/
const READ_THROUGH: Readonly<Record<string, (value: unknown) => unknown>> = {
"OnboardingRecord.stepsJson": parseOnboardingSteps,
};
/** Plaintext behind a sealed value, or the value itself when it is not one. */
function opened(value: unknown): string {
if (value === null || value === undefined) return "null";
if (typeof value === "string") {
try {
return `text:${decrypt(value)}`;
} catch {
return `text:${value}`;
}
}
if (value instanceof Uint8Array) {
for (const open of BYTE_OPENERS) {
try {
return `text:${open(value)}`;
} catch {
continue;
}
}
}
return normalise(value);
}
/** Every codec a sealed byte column is written with. */
const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
decryptFromBytes,
decryptNoteFromBytes,
decryptContextFromBytes,
// v1.42 — the sealed environment locations, under their own label.
(value) => JSON.stringify(openLocation(value)),
(value) => decryptBytes(Buffer.from(value)).toString("base64"),
];
/**
* A legacy plaintext column beside its sealed twin (`note` / `noteEncrypted`)
* is one value in two places: the restore seals what it finds in either, and
* clears the plaintext. So the pair is compared as the value a reader sees.
*/
function readerValue(
row: Record<string, unknown>,
column: AuditColumn,
): string {
const parse = READ_THROUGH[`${column.model}.${column.name}`];
if (parse) return normalise(parse(row[column.name]));
const twin = `${column.name}Encrypted`;
if (twin in row) {
const sealed = opened(row[twin]);
return sealed !== "null" ? sealed : opened(row[column.name]);
}
return opened(row[column.name]);
}
function columnValues(
rows: Record<string, unknown>[],
column: AuditColumn,
how: "value" | "presence",
): string[] {
return rows
.map((row) =>
how === "presence"
? row[column.name] === null || row[column.name] === undefined
? "null"
: "set"
: readerValue(row, column),
)
.sort();
}
async function snapshot(
prisma: PrismaClient,
models: AuditModel[],
wheres: Map<string, Record<string, unknown>>,
): Promise<Map<string, Record<string, unknown>[]>> {
const out = new Map<string, Record<string, unknown>[]>();
for (const model of models) {
out.set(
model.name,
await delegateOf(prisma, model).findMany({
where: wheres.get(model.name)!,
}),
);
}
return out;
}
/**
* Columns whose value differs between two snapshots, by `Model.column`. Pure,
* so the test can show it catches a column it was never told about.
*/
function lostColumns(
models: AuditModel[],
before: Map<string, Record<string, unknown>[]>,
after: Map<string, Record<string, unknown>[]>,
): { lost: string[]; compared: number } {
const lost: string[] = [];
let compared = 0;
for (const model of models) {
for (const column of model.columns) {
const how = comparison(column);
if (typeof how === "object") continue;
const plain = column.name.replace(/Encrypted$/, "");
if (
plain !== column.name &&
model.columns.some((c) => c.name === plain)
) {
continue; // compared through its plaintext twin, see readerValue
}
compared += 1;
const was = columnValues(before.get(model.name) ?? [], column, how);
const is = columnValues(after.get(model.name) ?? [], column, how);
if (normalise(was) !== normalise(is)) {
lost.push(`${model.name}.${column.name}`);
}
}
}
return { lost: lost.sort(), compared };
}
/**
* Sections a portable file may carry stored ciphertext in, each with the
* reason. Empty: every sealed column travels readable in a portable file.
*/
const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap<string, string> = new Map();
describe("every column of every two-ended model survives a real restore", () => {
it("brings each column back with the value it left with", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
// A catalogue row the fixture adds, which survives both the account delete
// and the per-test truncation; left behind by the first test in this file.
await prisma.illnessSymptom.deleteMany({
where: { key: "round_trip_cough" },
});
await seedEveryTwoEndedModel(prisma);
await seedColumnShapes(prisma);
const models = readAuditModels(prisma, TWO_ENDED_MODELS);
const byName = new Map(models.map((m) => [m.name, m]));
const wheres = new Map(
models.map((m) => [m.name, ownerWhere(byName, prisma, m.name, OWNER_ID)]),
);
const enumValues = enumValuesFromSchema();
// Fill what the fixture left empty, so an empty column cannot come back
// empty and read as carried. One column at a time on one row, so a value
// the database refuses names its column instead of failing the batch.
const unfillable: string[] = [];
const seededRows = await snapshot(prisma, models, wheres);
for (const model of models) {
const rows = seededRows.get(model.name)!;
expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0);
for (const column of model.columns) {
if (
comparison(column) !== "value" &&
!(`${model.name}.${column.name}` in COLUMN_PRESENCE_ONLY)
) {
continue;
}
// Filling it would turn the fixture's row into a tombstone, and which
// models carry tombstones is a row decision, not a column one.
if (column.name === "deletedAt") continue;
if (rows.some((row) => !isDefaultValue(column, row[column.name]))) {
continue;
}
try {
await delegateOf(prisma, model).update({
where: primaryKeyWhere(model, rows[0]),
data: {
[column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES),
},
});
} catch (error) {
unfillable.push(
`${model.name}.${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`,
);
}
}
}
expect(unfillable, "columns the fill could not set").toEqual([]);
const before = await snapshot(prisma, models, wheres);
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
let json = "";
const previewCollector = await createBackupPreviewCollector();
const counts = await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
json += chunk;
},
{
purpose: "disaster-recovery",
exportedAt,
observe: previewCollector.observe,
},
);
// Every sealed column is filled here: the key uses the preview takes from
// the sections as they are written are the ones a read of the file finds.
const keysReadBack = new BackupKeyIdCollector();
keysReadBack.visit(JSON.parse(json));
expect(keysReadBack.keyIds().length).toBeGreaterThan(0);
expect(previewCollector.finish(counts)!.keys.toStored()).toEqual(
keysReadBack.toStored(),
);
// ── No ciphertext in a portable file ──────────────────────────────────
//
// Every sealed column is filled at this point, so the portable file of
// this account is the one place a column still exported as stored
// ciphertext shows up. A portable file is read and restored on hosts
// with other keys; ciphertext in it is noise there, and a restore either
// refuses the file or writes values nobody can open. That class was
// fixed one section at a time (cycle, mood labels, visits, vaccinations)
// and kept coming back, because each builder decides its own encoding.
// This reads what the builders actually wrote, rather than how their
// source is spelt. Limit: it sees members named `…Encrypted` only, the
// naming the key preflight relies on too.
const sealedIn = (text: string) => {
const collector = new BackupKeyIdCollector();
collector.visit(JSON.parse(text));
const sections = new Map<string, number>();
for (const [, use] of collector.entries()) {
for (const section of use.sections) {
sections.set(section, (sections.get(section) ?? 0) + use.count);
}
}
return sections;
};
// The floor: the same matcher over the disaster-recovery file of the same
// account, which carries every sealed column as ciphertext. A matcher
// that finds nothing there would make the portable check pass vacuously.
const drSealed = sealedIn(json);
expect(
[...drSealed.values()].reduce((a, b) => a + b, 0),
).toBeGreaterThanOrEqual(40);
expect(drSealed.size).toBeGreaterThanOrEqual(15);
let portableJson = "";
await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
portableJson += chunk;
},
{ purpose: "portable-export", exportedAt },
);
const portableSealed = [...sealedIn(portableJson).keys()].filter(
(section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section),
);
expect(
portableSealed,
"a portable file carries these sections' sealed columns as stored " +
"ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " +
"and seal them again on restore, or name the section in " +
"PORTABLE_CIPHERTEXT_ALLOWED with the reason",
).toEqual([]);
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "COLUMN_ROUND_TRIP",
data: await legacyStreamedBlobFrom(async (write) => {
await write(json);
}),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
const after = await snapshot(prisma, models, wheres);
const { lost, compared } = lostColumns(models, before, after);
// A comparison over no columns agrees with everything. 632 at the time of
// writing; the headroom is for a column dropped by a migration, not for a
// data model that stopped being read.
expect(compared).toBeGreaterThanOrEqual(620);
expect(
lost.filter((key) => !(key in RELEASE_PENDING_COLUMNS)),
"these columns left with a value and came back without it. Carry each " +
"through the export and the restore, or name it in COLUMN_EXCLUSIONS " +
"with the reason the restore is right to drop it",
).toEqual([]);
expect(
Object.keys(RELEASE_PENDING_COLUMNS).filter((key) => !lost.includes(key)),
"these RELEASE_PENDING_COLUMNS entries come back now — drop them",
).toEqual([]);
});
it("notices a column nobody told it about", () => {
const model: AuditModel = {
name: "Probe",
delegate: "probe",
primaryKey: ["id"],
parents: {},
columns: [
{
model: "Probe",
name: "addedTomorrow",
kind: "scalar",
type: "String",
isList: false,
isOptional: true,
isId: false,
isUpdatedAt: false,
isForeignKey: false,
defaultText: null,
},
],
};
const before = new Map([["Probe", [{ id: "a", addedTomorrow: "kept" }]]]);
const after = new Map([["Probe", [{ id: "b", addedTomorrow: null }]]]);
expect(lostColumns([model], before, after)).toEqual({
lost: ["Probe.addedTomorrow"],
compared: 1,
});
});
it("names no exclusion for a column that no longer exists", () => {
const models = readAuditModels(getPrismaClient(), TWO_ENDED_MODELS);
const known = new Set(
models.flatMap((m) => m.columns.map((c) => `${m.name}.${c.name}`)),
);
const stale = [
...Object.keys(COLUMN_EXCLUSIONS),
...Object.keys(COLUMN_PRESENCE_ONLY),
...Object.keys(RELEASE_PENDING_COLUMNS),
...Object.keys(READ_THROUGH),
...Object.keys(FILL_OVERRIDES),
].filter((key) => !known.has(key));
expect(stale).toEqual([]);
});
});
/**
* Columns of the account row the restore writes for reasons of its own, so
* "unchanged on the target" does not apply to them.
*/
const USER_ROW_STAMPED: Readonly<Record<string, string>> = {
updatedAt: "@updatedAt; Prisma stamps it on the restore's write",
syncResetAt:
"the restore stamps it so paired clients' delta cursors are refused",
};
describe("the account's own settings survive a real restore", () => {
it("brings every setting column back and leaves the rest of the target's row alone", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const [userModel] = readAuditModels(prisma, ["User"]);
const enumValues = enumValuesFromSchema();
const settings = new Set<string>(ACCOUNT_SETTING_COLUMNS);
const classes = USER_COLUMN_BACKUP_CLASS as Readonly<
Record<string, string>
>;
// Every column the source account can be given a non-default value in,
// settings and everything else alike: a credential that came back on the
// target is as much a failure as a setting that did not.
const fillable = userModel.columns.filter(
(column) =>
!column.isId &&
!column.isUpdatedAt &&
column.name !== "username" &&
column.name !== "role",
);
const unfillable: string[] = [];
for (const column of fillable) {
try {
await prisma.user.update({
where: { id: OWNER_ID },
data: {
[column.name]: syntheticValue(
column,
enumValues,
USER_FILL_OVERRIDES,
),
},
});
} catch (error) {
unfillable.push(
`${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`,
);
}
}
expect(unfillable, "columns the fill could not set").toEqual([]);
const source = (await prisma.user.findUniqueOrThrow({
where: { id: OWNER_ID },
})) as unknown as Record<string, unknown>;
const defaulted = [...settings].filter((name) =>
isDefaultValue(
userModel.columns.find((c) => c.name === name)!,
source[name],
),
);
expect(
defaulted,
"these settings still hold their default, so coming back with the " +
"default would read as carried",
).toEqual([]);
let json = "";
await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
json += chunk;
},
{
purpose: "disaster-recovery",
exportedAt: new Date("2026-08-01T00:00:00.000Z"),
},
);
// Nothing but setting columns reaches the file, and no secret at all.
const section = (JSON.parse(json) as { accountSettings: object })
.accountSettings;
expect(
Object.keys(section).filter(
(key) => !settings.has(key) && key !== "insuranceNumber",
),
).toEqual([]);
// A fresh account under the same id, the way an operator recreates one
// on a new host, given credentials of its own that must survive.
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
await prisma.user.update({
where: { id: OWNER_ID },
data: {
passwordHash: "target-account-password-hash",
aiOpenaiKeyEncrypted: encrypt("target account key"),
totpSecretEncrypted: encrypt("target account totp"),
},
});
const target = (await prisma.user.findUniqueOrThrow({
where: { id: OWNER_ID },
})) as unknown as Record<string, unknown>;
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "ACCOUNT_SETTINGS_ROUND_TRIP",
data: await legacyStreamedBlobFrom(async (write) => {
await write(json);
}),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
const refused = (
body.data.skipped.catalogueKeys as Array<{ catalogue: string }>
).filter((entry) => entry.catalogue === "accountSetting");
expect(refused, "the restore refused a setting the fixture set").toEqual(
[],
);
const after = (await prisma.user.findUniqueOrThrow({
where: { id: OWNER_ID },
})) as unknown as Record<string, unknown>;
const lost = [...settings].filter(
(name) => opened(after[name]) !== opened(source[name]),
);
expect(
lost,
"these settings left with a value and came back without it",
).toEqual([]);
expect(settings.size).toBeGreaterThanOrEqual(55);
const overwritten = userModel.columns
.map((column) => column.name)
.filter(
(name) =>
classes[name] !== "SETTING" && !Object.hasOwn(USER_ROW_STAMPED, name),
)
.filter((name) => opened(after[name]) !== opened(target[name]));
expect(
overwritten,
"the restore changed these identity, credential or bookkeeping columns " +
"on the account it restored into",
).toEqual([]);
});
});
describe("automatic dose recording after a restore", () => {
it("starts its floor at the restore, so a dose removed before the export stays removed", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
await prisma.user.update({
where: { id: OWNER_ID },
data: { timezone: "UTC" },
});
const now = Date.now();
const dayAt12 = (offset: number) => {
const d = new Date(now + offset * 86_400_000);
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12),
);
};
const since = dayAt12(-10);
const medication = await prisma.medication.create({
data: {
userId: OWNER_ID,
name: "Evening supplement",
dose: "1",
createdAt: dayAt12(-20),
autoMarkTaken: true,
autoMarkTakenSince: since,
schedules: {
create: {
windowStart: "12:00",
windowEnd: "12:00",
timesOfDay: ["12:00"],
rrule: "FREQ=DAILY",
scheduleType: "SCHEDULED",
},
},
},
select: { id: true },
});
// The pass filled the last days; the person removed the dose two days
// ago (they did not take it). A portable file carries no removal.
const removed = dayAt12(-2);
for (let offset = -6; offset <= -1; offset++) {
const at = dayAt12(offset);
await prisma.medicationIntakeEvent.create({
data: {
userId: OWNER_ID,
medicationId: medication.id,
scheduledFor: at,
takenAt: at,
source: "REMINDER",
autoRecorded: true,
deletedAt:
at.getTime() === removed.getTime()
? new Date(at.getTime() + 3_600_000)
: null,
},
});
}
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const restoredFrom = Date.now();
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
const restored = await prisma.medication.findFirstOrThrow({
where: { userId: OWNER_ID },
select: { id: true, autoMarkTaken: true, autoMarkTakenSince: true },
});
expect(restored.autoMarkTaken).toBe(true);
expect(restored.autoMarkTakenSince!.getTime()).toBeGreaterThanOrEqual(
restoredFrom,
);
const { runIntakeAutoTakePass } =
await import("@/lib/jobs/intake-auto-take");
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
const onRemovedSlot = await prisma.medicationIntakeEvent.count({
where: {
medicationId: restored.id,
deletedAt: null,
scheduledFor: removed,
},
});
expect(onRemovedSlot).toBe(0);
});
});
describe("a document kind added after the backup format", () => {
it("restores a sick note as a sick note", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const documentBytes = encryptBytes(Buffer.from("sick note fixture"));
const contentEncrypted = new Uint8Array(
new ArrayBuffer(documentBytes.byteLength),
);
contentEncrypted.set(documentBytes);
await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "SICK_NOTE",
title: "Certificate of incapacity",
mimeType: "application/pdf",
byteSize: documentBytes.byteLength,
contentEncrypted,
contentCodec: "binary2",
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.inboundDocument.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(restored.kind).toBe("SICK_NOTE");
expect(restored.title).toBe("Certificate of incapacity");
});
});