mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
A restore now leaves out measurement tombstones deleted longer ago than the tombstone retention. The nightly purge removes exactly those rows, the delta feed answers cursorExpired for every cursor that could still be served one, and the consolidation repairs look only inside the same horizon. Younger tombstones keep their readers (a lagging paired client, the undo of a recent delete, the repairs, the duplicate guard on re-uploaded samples) and are written as before. The backup file is unchanged. On a seeded account of 1.89 million readings whose 1.68 million tombstones were past retention, the restore on a slowed database took 5.4 min instead of 15. The rollup fold after the commit rebuilt five years of buckets under the 60 s request statement limit and could be dropped on a slow disk, leaving the charts empty until the next boot. `recomputeUserRollups` takes a `statementTimeoutMs` that scopes each aggregate and persist transaction with SET LOCAL, and the restore passes its after-commit allowance. Fixture tombstones in two round-trip tests were dated in July 2026 and had aged past the retention; they are now relative to the run.
1387 lines
45 KiB
TypeScript
1387 lines
45 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import type { Readable } from "node:stream";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
const OFFHOST_ENCRYPTION_KEY =
|
|
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
|
process.env.BACKUP_S3_ENDPOINT = "https://s3.example.test";
|
|
process.env.BACKUP_S3_BUCKET = "healthlog-test";
|
|
process.env.BACKUP_S3_ACCESS_KEY = "test-access";
|
|
process.env.BACKUP_S3_SECRET_KEY = "test-secret";
|
|
process.env.BACKUP_ENCRYPTION_KEY = OFFHOST_ENCRYPTION_KEY;
|
|
|
|
import { encrypt, encryptBytes } from "@/lib/crypto";
|
|
import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import {
|
|
decryptBackup,
|
|
runOffhostBackup,
|
|
type S3Like,
|
|
} from "@/lib/jobs/offhost-backup";
|
|
import {
|
|
backupPayloadSchema,
|
|
parseBackupPayload,
|
|
} from "@/lib/validations/backup";
|
|
import { POST } from "./restore-job-driver";
|
|
import { invalidateUserData } from "@/lib/cache/invalidate";
|
|
|
|
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();
|
|
vi.mocked(invalidateUserData).mockClear();
|
|
});
|
|
|
|
async function seedAdminSession() {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "canonical-restore-admin",
|
|
email: "canonical-restore-admin@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: admin.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return admin;
|
|
}
|
|
|
|
function makeRequest(id: string) {
|
|
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
// The disaster-recovery case, so the instance-wide half of the payload is
|
|
// asked for explicitly. Without the flag the restore rebuilds the account
|
|
// and leaves the host's own settings alone; that arm is covered in
|
|
// `admin-backups-restore.test.ts`.
|
|
body: JSON.stringify({ confirm: "RESTORE", restoreInstanceSettings: true }),
|
|
});
|
|
}
|
|
|
|
function makeS3Store(): S3Like & { objects: Map<string, Buffer> } {
|
|
const objects = new Map<string, Buffer>();
|
|
return {
|
|
objects,
|
|
putStream: vi.fn(async (key: string, body: Readable) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const c of body) chunks.push(Buffer.from(c as Uint8Array));
|
|
objects.set(key, Buffer.concat(chunks));
|
|
}),
|
|
putObject: vi.fn(async (key, body) => {
|
|
objects.set(key, Buffer.from(body));
|
|
}),
|
|
getObject: vi.fn(async (key) => {
|
|
const body = objects.get(key);
|
|
if (!body) throw new Error(`Missing S3 object: ${key}`);
|
|
return body;
|
|
}),
|
|
headObject: vi.fn(async (key) => objects.has(key)),
|
|
listObjects: vi.fn(async (prefix) =>
|
|
[...objects.keys()]
|
|
.filter((key) => key.startsWith(prefix))
|
|
.map((key) => ({ key })),
|
|
),
|
|
deleteObject: vi.fn(async (key) => {
|
|
objects.delete(key);
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("canonical disaster-recovery backup round-trip", () => {
|
|
it("restores an off-host payload with tombstones and encrypted documents", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const ownerId = admin.id;
|
|
const settings = await prisma.appSettings.create({
|
|
data: {
|
|
id: "singleton",
|
|
registrationEnabled: false,
|
|
mfaRequired: true,
|
|
defaultLocale: "en",
|
|
telegramGlobal: false,
|
|
ntfyGlobal: false,
|
|
webPushGlobal: false,
|
|
webPushVapidPublicKey: "public-dr",
|
|
webPushVapidPrivateKeyEncrypted: "private-dr",
|
|
webPushVapidSubject: "mailto:dr@example.test",
|
|
apiGlobal: false,
|
|
umamiEnabled: true,
|
|
umamiScriptUrl: "https://analytics.example.test/script.js",
|
|
umamiWebsiteId: "website-dr",
|
|
glitchtipEnabled: true,
|
|
glitchtipDsn: "https://glitchtip.example.test/1",
|
|
glitchtipEnvironment: "dr",
|
|
reminderLateMinutes: 47,
|
|
reminderMissedMinutes: 93,
|
|
adminAiKeyEncrypted: "ai-key-dr",
|
|
adminAiModel: "model-dr",
|
|
adminAiBaseUrl: "https://ai.example.test/v1",
|
|
adminCodexAccessTokenEncrypted: "access-dr",
|
|
adminCodexRefreshTokenEncrypted: "refresh-dr",
|
|
adminCodexAccountIdEncrypted: "account-dr",
|
|
adminCodexTokenExpiresAt: new Date("2026-07-03T00:00:00.000Z"),
|
|
adminCodexConnectedAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
adminCodexConnectionStatus: "connected",
|
|
adminAiInsightsFeedbackSummary: { helpful: 8, total: 10 },
|
|
defaultUserTimezone: "Europe/London",
|
|
assistantEnabled: false,
|
|
assistantCoachEnabled: false,
|
|
assistantBriefingEnabled: false,
|
|
assistantInsightStatusEnabled: false,
|
|
assistantDocumentAiEnabled: false,
|
|
moduleAvailabilityJson: { insights: true, nutrients: false },
|
|
documentMaxFileBytes: 12_345_678,
|
|
documentQuotaBytes: BigInt("9876543210"),
|
|
},
|
|
});
|
|
|
|
const measurement = await prisma.measurement.create({
|
|
data: {
|
|
id: "measurement-dr",
|
|
userId: ownerId,
|
|
type: "WEIGHT",
|
|
value: 72.4,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-07-01T07:00:00.000Z"),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
// Recent, so the restore writes it back: a tombstone older than the
|
|
// tombstone retention is left out, as the nightly purge would drop it.
|
|
const tombstoneDeletedAt = new Date(
|
|
Math.floor(Date.now() / 1000) * 1000 - 86_400_000,
|
|
);
|
|
const deletedMeasurement = await prisma.measurement.create({
|
|
data: {
|
|
id: "measurement-deleted-dr",
|
|
userId: ownerId,
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-01T08:00:00.000Z"),
|
|
source: "MANUAL",
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
|
|
const factorCategory = await prisma.moodTagCategory.create({
|
|
data: {
|
|
id: "category-dr",
|
|
key: "dr_restore_factors",
|
|
labelKey: "mood.tagCategory.drRestore",
|
|
},
|
|
});
|
|
const factor = await prisma.moodTag.create({
|
|
data: {
|
|
id: "factor-dr",
|
|
categoryId: factorCategory.id,
|
|
key: "dr_sleep_quality",
|
|
labelKey: "mood.tag.drSleepQuality",
|
|
kind: "RATED",
|
|
scaleMin: 1,
|
|
scaleMax: 5,
|
|
},
|
|
});
|
|
const mood = await prisma.moodEntry.create({
|
|
data: {
|
|
id: "mood-dr",
|
|
userId: ownerId,
|
|
date: "2026-07-01",
|
|
mood: "GUT",
|
|
score: 4,
|
|
source: "MOODLOG",
|
|
moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"),
|
|
tagLinks: {
|
|
create: { moodTagId: factor.id, rating: 5 },
|
|
},
|
|
},
|
|
include: {
|
|
tagLinks: {
|
|
select: { rating: true, moodTag: { select: { key: true } } },
|
|
},
|
|
},
|
|
});
|
|
const deletedMood = await prisma.moodEntry.create({
|
|
data: {
|
|
id: "mood-deleted-dr",
|
|
userId: ownerId,
|
|
date: "2026-06-30",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
source: "MOODLOG",
|
|
moodLoggedAt: new Date("2026-06-30T20:00:00.000Z"),
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
id: "medication-dr",
|
|
userId: ownerId,
|
|
name: "Canonical medicine",
|
|
dose: "2 mg",
|
|
treatmentClass: "GENERIC",
|
|
dosesPerUnit: 4,
|
|
unitsPerDose: "0.5000",
|
|
active: false,
|
|
notificationsEnabled: false,
|
|
pausedAt: new Date("2026-06-25T08:00:00.000Z"),
|
|
snoozedUntil: new Date("2026-07-05T08:00:00.000Z"),
|
|
startsOn: new Date("2026-06-01T00:00:00.000Z"),
|
|
endsOn: new Date("2026-08-01T00:00:00.000Z"),
|
|
asNeeded: false,
|
|
// Not the default, so the round trip proves it travels (#1033).
|
|
trackIntake: false,
|
|
deliveryForm: "INJECTION",
|
|
trackInjectionSites: true,
|
|
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
|
|
liveActivityEnabled: true,
|
|
criticalAlarmEnabled: true,
|
|
atcCode: "A10BX10",
|
|
rxNormCode: "12345",
|
|
lowStockNotifiedAt: new Date("2026-06-26T08:00:00.000Z"),
|
|
lowStockNotifiedThresholdDays: 7,
|
|
reorderLeadDays: 3,
|
|
externalSource: "APPLE_HEALTH",
|
|
externalId: "medication-external-dr",
|
|
schedules: {
|
|
create: {
|
|
id: "schedule-dr",
|
|
windowStart: "08:00",
|
|
windowEnd: "10:00",
|
|
label: "Morning",
|
|
dose: "1 mg",
|
|
daysOfWeek: "i2;1,3,5",
|
|
timesOfDay: ["08:00", "20:00"],
|
|
reminderGraceMinutes: 90,
|
|
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
|
|
scheduleType: "CYCLIC",
|
|
cyclicOnWeeks: 3,
|
|
cyclicOffWeeks: 1,
|
|
doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:00" }],
|
|
},
|
|
},
|
|
},
|
|
include: { schedules: true },
|
|
});
|
|
const sideEffect = await prisma.medicationSideEffect.create({
|
|
data: {
|
|
id: "side-effect-dr",
|
|
userId: ownerId,
|
|
medicationId: medication.id,
|
|
occurredAt: new Date("2026-06-27T21:00:00.000Z"),
|
|
category: "INJECTION_SITE",
|
|
entry: "INJECTION_SWELLING",
|
|
severity: 4,
|
|
notesEncrypted: encryptToBytes("swollen for a day after the injection"),
|
|
createdAt: new Date("2026-06-27T21:10:00.000Z"),
|
|
},
|
|
});
|
|
const intake = await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
id: "intake-dr",
|
|
userId: ownerId,
|
|
medicationId: medication.id,
|
|
scheduledFor: new Date("2026-06-29T08:00:00.000Z"),
|
|
takenAt: new Date("2026-06-29T08:05:00.000Z"),
|
|
autoMissed: false,
|
|
attributionSource: "USER_PIN",
|
|
source: "APPLE_HEALTH",
|
|
idempotencyKey: "intake-idempotency-dr",
|
|
injectionSite: "THIGH_LEFT",
|
|
doseTaken: "1 mg",
|
|
inventoryConsumption: [{ itemId: "pen-dr", units: 0.5 }],
|
|
externalId: "intake-external-dr",
|
|
syncVersion: 5,
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
|
|
const cycleProfile = await prisma.cycleProfile.create({
|
|
data: {
|
|
id: "cycle-profile-dr",
|
|
userId: ownerId,
|
|
goal: "GENERAL_HEALTH",
|
|
cycleTrackingEnabled: true,
|
|
typicalCycleLength: 28,
|
|
typicalPeriodLength: 5,
|
|
lutealPhaseLength: 14,
|
|
secondarySymptom: "CERVIX",
|
|
predictionEnabled: false,
|
|
rawChartMode: true,
|
|
discreetNotifications: true,
|
|
sensitiveCategoryEncryption: true,
|
|
},
|
|
});
|
|
const cycle = await prisma.menstrualCycle.create({
|
|
data: {
|
|
id: "cycle-dr",
|
|
userId: ownerId,
|
|
startDate: "2026-06-01",
|
|
endDate: "2026-06-28",
|
|
periodEndDate: "2026-06-05",
|
|
lengthDays: 28,
|
|
ovulationDate: "2026-06-14",
|
|
ovulationConfirmed: true,
|
|
isPredicted: false,
|
|
tz: "Europe/London",
|
|
syncVersion: 6,
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
|
const cycleSymptom = await prisma.cycleSymptom.create({
|
|
data: {
|
|
id: "cycle-symptom-dr",
|
|
userId: ownerId,
|
|
categoryId: cycleCategory.id,
|
|
key: "cycle_cramps_dr",
|
|
labelKey: "cycle.symptom.crampsDr",
|
|
},
|
|
});
|
|
const cycleDay = await prisma.cycleDayLog.create({
|
|
data: {
|
|
id: "cycle-day-dr",
|
|
userId: ownerId,
|
|
cycleId: cycle.id,
|
|
date: "2026-06-02",
|
|
flow: "HEAVY",
|
|
intermenstrualBleeding: true,
|
|
basalBodyTempC: 36.7,
|
|
temperatureExcluded: true,
|
|
ovulationTest: "NEGATIVE",
|
|
cervicalMucus: "CREAMY",
|
|
cervixPosition: "LOW",
|
|
cervixFirmness: "FIRM",
|
|
cervixOpening: "CLOSED",
|
|
sexualActivity: false,
|
|
sensitiveEncrypted: "sensitive-cycle-dr",
|
|
notesEncrypted: "notes-cycle-dr",
|
|
source: "APPLE_HEALTH",
|
|
externalId: "cycle-day-external-dr",
|
|
tz: "Europe/London",
|
|
syncVersion: 7,
|
|
deletedAt: tombstoneDeletedAt,
|
|
symptomLinks: { create: { symptomId: cycleSymptom.id } },
|
|
},
|
|
});
|
|
|
|
const biomarker = await prisma.biomarker.create({
|
|
data: {
|
|
id: "biomarker-dr",
|
|
userId: ownerId,
|
|
name: "Ferritin",
|
|
unit: "ng/mL",
|
|
lowerBound: 30,
|
|
upperBound: 300,
|
|
panel: "Iron",
|
|
contextEncrypted: encryptToBytes("Iron storage context"),
|
|
},
|
|
});
|
|
const lab = await prisma.labResult.create({
|
|
data: {
|
|
id: "lab-dr",
|
|
userId: ownerId,
|
|
biomarkerId: biomarker.id,
|
|
panel: "Iron",
|
|
analyte: "Ferritin",
|
|
value: 88,
|
|
unit: "ng/mL",
|
|
referenceLow: 30,
|
|
referenceHigh: 300,
|
|
takenAt: new Date("2026-06-30T09:00:00.000Z"),
|
|
source: "MANUAL",
|
|
noteEncrypted: encryptToBytes("Fasted"),
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
|
|
const illness = await prisma.illnessEpisode.create({
|
|
data: {
|
|
id: "illness-dr",
|
|
userId: ownerId,
|
|
label: "Cold",
|
|
type: "INFECTION",
|
|
lifecycle: "ACUTE",
|
|
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
|
|
resolvedAt: new Date("2026-06-24T00:00:00.000Z"),
|
|
noteEncrypted: encryptToBytes("Recovered fully"),
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
const symptom = await prisma.illnessSymptom.create({
|
|
data: {
|
|
id: "symptom-dr",
|
|
key: "dr_cough",
|
|
labelKey: "illness.symptom.drCough",
|
|
},
|
|
});
|
|
const illnessDay = await prisma.illnessDayLog.create({
|
|
data: {
|
|
id: "illness-day-dr",
|
|
userId: ownerId,
|
|
episodeId: illness.id,
|
|
date: "2026-06-21",
|
|
functionalImpact: 2,
|
|
feverC: 38.1,
|
|
noteEncrypted: encryptToBytes("Rest day"),
|
|
tz: "Europe/London",
|
|
deletedAt: tombstoneDeletedAt,
|
|
symptomLinks: {
|
|
create: { symptomId: symptom.id, severity: 2 },
|
|
},
|
|
},
|
|
});
|
|
|
|
const allergy = await prisma.allergy.create({
|
|
data: {
|
|
id: "allergy-dr",
|
|
userId: ownerId,
|
|
substance: "Penicillin",
|
|
category: "MEDICATION",
|
|
type: "ALLERGY",
|
|
severity: "SEVERE",
|
|
status: "ACTIVE",
|
|
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
|
|
reactionEncrypted: encryptToBytes("Hives"),
|
|
notesEncrypted: encryptToBytes("Avoid beta-lactams"),
|
|
deletedAt: tombstoneDeletedAt,
|
|
},
|
|
});
|
|
const family = await prisma.familyHistoryEntry.create({
|
|
data: {
|
|
id: "family-dr",
|
|
userId: ownerId,
|
|
relationship: "MOTHER",
|
|
condition: "Type 2 diabetes",
|
|
ageAtOnset: 55,
|
|
notesEncrypted: encryptToBytes("Diet controlled"),
|
|
},
|
|
});
|
|
const workout = await prisma.workout.create({
|
|
data: {
|
|
id: "workout-dr",
|
|
userId: ownerId,
|
|
sportType: "RUNNING",
|
|
startedAt: new Date("2026-06-29T06:00:00.000Z"),
|
|
endedAt: new Date("2026-06-29T06:30:00.000Z"),
|
|
durationSec: 1800,
|
|
totalEnergyKcal: 250,
|
|
totalDistanceM: 5000,
|
|
avgHeartRate: 145,
|
|
maxHeartRate: 170,
|
|
source: "IMPORT",
|
|
externalId: "run-external-dr",
|
|
},
|
|
});
|
|
|
|
const encryptedBuffer = encryptBytes(Buffer.from("private PDF bytes"));
|
|
const contentEncrypted = new Uint8Array(
|
|
new ArrayBuffer(encryptedBuffer.byteLength),
|
|
);
|
|
contentEncrypted.set(encryptedBuffer);
|
|
const summaryEncrypted = encryptToBytes("Encrypted summary");
|
|
const document = await prisma.inboundDocument.create({
|
|
data: {
|
|
id: "document-dr",
|
|
userId: ownerId,
|
|
kind: "LAB_RESULT",
|
|
title: "June labs",
|
|
filename: "june-labs.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: 17,
|
|
contentEncrypted,
|
|
contentSha256:
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
contentCodec: "binary2",
|
|
status: "EXTRACTED",
|
|
providerType: "local",
|
|
reportDate: new Date("2026-06-30T00:00:00.000Z"),
|
|
documentDate: new Date("2026-06-30T00:00:00.000Z"),
|
|
summaryEncrypted,
|
|
summaryGeneratedAt: new Date("2026-07-01T08:00:00.000Z"),
|
|
summaryState: "READY",
|
|
createdAt: new Date("2026-07-01T07:30:00.000Z"),
|
|
updatedAt: new Date("2026-07-01T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const s3 = makeS3Store();
|
|
const snapshotAt = new Date("2026-07-02T00:00:00.000Z");
|
|
const offhostReport = await runOffhostBackup(prisma, s3, snapshotAt);
|
|
expect(offhostReport).toMatchObject({
|
|
uploaded: 1,
|
|
failed: 0,
|
|
totalUsers: 1,
|
|
});
|
|
const offhostObject = s3.objects.get(`2026-07-02/user-${ownerId}.json.enc`);
|
|
expect(offhostObject).toBeDefined();
|
|
const payload = parseBackupPayload(
|
|
decryptBackup(
|
|
offhostObject!,
|
|
Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex"),
|
|
`2026-07-02/user-${ownerId}.json.enc`,
|
|
),
|
|
);
|
|
expect(payload).toMatchObject({
|
|
schemaVersion: "2",
|
|
userId: ownerId,
|
|
appSettings: { id: settings.id },
|
|
});
|
|
expect(payload.documents[0]).not.toHaveProperty("summary");
|
|
expect(JSON.stringify(payload.documents[0])).not.toContain(
|
|
"private PDF bytes",
|
|
);
|
|
|
|
await prisma.appSettings.deleteMany();
|
|
await prisma.medicationIntakeEvent.deleteMany({
|
|
where: { userId: ownerId },
|
|
});
|
|
await prisma.medication.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.cycleProfile.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.workout.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.allergy.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.illnessEpisode.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.labResult.deleteMany({ where: { userId: ownerId } });
|
|
await prisma.biomarker.deleteMany({ where: { userId: ownerId } });
|
|
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: ownerId,
|
|
type: "CANONICAL_DR_ROUND_TRIP",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const restore = async () =>
|
|
POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
|
params: Promise.resolve({ id: backup.id }),
|
|
});
|
|
|
|
expect((await restore()).status).toBe(200);
|
|
|
|
expect(
|
|
await prisma.appSettings.findUniqueOrThrow({
|
|
where: { id: settings.id },
|
|
}),
|
|
).toEqual(settings);
|
|
expect(
|
|
await prisma.medication.findUniqueOrThrow({
|
|
where: { id: medication.id },
|
|
include: { schedules: true },
|
|
}),
|
|
).toEqual(medication);
|
|
expect(
|
|
await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
|
where: { id: intake.id },
|
|
}),
|
|
).toEqual(intake);
|
|
// Byte-for-byte, ciphertext included: a canonical payload carries the
|
|
// encrypted note verbatim, so the same instance's key reads back exactly
|
|
// what it wrote. Re-encrypting here would still decrypt to the same words
|
|
// and would still be a different row than the one the file described.
|
|
expect(
|
|
await prisma.medicationSideEffect.findUniqueOrThrow({
|
|
where: { id: sideEffect.id },
|
|
}),
|
|
).toEqual(sideEffect);
|
|
expect(
|
|
await prisma.cycleProfile.findUniqueOrThrow({
|
|
where: { id: cycleProfile.id },
|
|
}),
|
|
).toEqual(cycleProfile);
|
|
expect(
|
|
await prisma.menstrualCycle.findUniqueOrThrow({
|
|
where: { id: cycle.id },
|
|
}),
|
|
).toEqual(cycle);
|
|
expect(
|
|
await prisma.cycleDayLog.findUniqueOrThrow({
|
|
where: { id: cycleDay.id },
|
|
}),
|
|
).toEqual(cycleDay);
|
|
expect(
|
|
await prisma.cycleSymptomLink.findMany({
|
|
where: { dayLogId: cycleDay.id },
|
|
select: { symptomId: true },
|
|
}),
|
|
).toEqual([{ symptomId: cycleSymptom.id }]);
|
|
|
|
expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(
|
|
1,
|
|
);
|
|
expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe(
|
|
1,
|
|
);
|
|
expect(
|
|
await prisma.illnessEpisode.count({ where: { userId: ownerId } }),
|
|
).toBe(1);
|
|
expect(
|
|
await prisma.illnessDayLog.count({ where: { userId: ownerId } }),
|
|
).toBe(1);
|
|
expect(await prisma.allergy.count({ where: { userId: ownerId } })).toBe(1);
|
|
expect(
|
|
await prisma.familyHistoryEntry.count({ where: { userId: ownerId } }),
|
|
).toBe(1);
|
|
expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1);
|
|
expect(
|
|
await prisma.inboundDocument.count({ where: { userId: ownerId } }),
|
|
).toBe(1);
|
|
expect(
|
|
await prisma.measurement.findUniqueOrThrow({
|
|
where: { id: measurement.id },
|
|
}),
|
|
).toEqual(measurement);
|
|
expect(
|
|
await prisma.measurement.findUniqueOrThrow({
|
|
where: { id: deletedMeasurement.id },
|
|
}),
|
|
).toEqual(deletedMeasurement);
|
|
|
|
const restoredLab = await prisma.labResult.findUniqueOrThrow({
|
|
where: { id: lab.id },
|
|
});
|
|
expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted");
|
|
expect(restoredLab.biomarkerId).toBe(biomarker.id);
|
|
expect(restoredLab).toEqual(lab);
|
|
const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({
|
|
where: { id: biomarker.id },
|
|
});
|
|
expect(restoredBiomarker).toEqual(
|
|
expect.objectContaining({
|
|
name: biomarker.name,
|
|
unit: biomarker.unit,
|
|
lowerBound: biomarker.lowerBound,
|
|
upperBound: biomarker.upperBound,
|
|
panel: biomarker.panel,
|
|
hidden: biomarker.hidden,
|
|
}),
|
|
);
|
|
expect(decryptFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
|
|
"Iron storage context",
|
|
);
|
|
|
|
const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({
|
|
where: { id: illness.id },
|
|
});
|
|
expect(restoredIllness).toEqual(illness);
|
|
expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe(
|
|
"Recovered fully",
|
|
);
|
|
const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({
|
|
where: { id: illnessDay.id },
|
|
});
|
|
expect(restoredIllnessDay).toEqual(illnessDay);
|
|
expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe(
|
|
"Rest day",
|
|
);
|
|
expect(
|
|
await prisma.illnessSymptomLink.findMany({
|
|
where: { dayLogId: illnessDay.id },
|
|
}),
|
|
).toEqual([
|
|
expect.objectContaining({ symptomId: symptom.id, severity: 2 }),
|
|
]);
|
|
|
|
const restoredAllergy = await prisma.allergy.findUniqueOrThrow({
|
|
where: { id: allergy.id },
|
|
});
|
|
expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives");
|
|
expect(restoredAllergy).toEqual(allergy);
|
|
const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({
|
|
where: { id: family.id },
|
|
});
|
|
expect(decryptFromBytes(restoredFamily.notesEncrypted!)).toBe(
|
|
"Diet controlled",
|
|
);
|
|
expect(restoredFamily).toEqual(
|
|
expect.objectContaining({
|
|
relationship: family.relationship,
|
|
condition: family.condition,
|
|
ageAtOnset: family.ageAtOnset,
|
|
}),
|
|
);
|
|
expect(
|
|
await prisma.workout.findUniqueOrThrow({ where: { id: workout.id } }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
sportType: workout.sportType,
|
|
startedAt: workout.startedAt,
|
|
endedAt: workout.endedAt,
|
|
durationSec: workout.durationSec,
|
|
totalEnergyKcal: workout.totalEnergyKcal,
|
|
totalDistanceM: workout.totalDistanceM,
|
|
avgHeartRate: workout.avgHeartRate,
|
|
maxHeartRate: workout.maxHeartRate,
|
|
source: workout.source,
|
|
externalId: workout.externalId,
|
|
}),
|
|
);
|
|
|
|
const restoredDocument = await prisma.inboundDocument.findUniqueOrThrow({
|
|
where: { id: document.id },
|
|
});
|
|
expect(Buffer.from(restoredDocument.contentEncrypted)).toEqual(
|
|
Buffer.from(document.contentEncrypted),
|
|
);
|
|
expect(Buffer.from(restoredDocument.summaryEncrypted!)).toEqual(
|
|
Buffer.from(document.summaryEncrypted!),
|
|
);
|
|
expect(restoredDocument).toEqual(
|
|
expect.objectContaining({
|
|
title: document.title,
|
|
filename: document.filename,
|
|
mimeType: document.mimeType,
|
|
byteSize: document.byteSize,
|
|
contentSha256: document.contentSha256,
|
|
contentCodec: document.contentCodec,
|
|
status: document.status,
|
|
providerType: document.providerType,
|
|
summaryState: document.summaryState,
|
|
reportDate: document.reportDate,
|
|
documentDate: document.documentDate,
|
|
errorReason: document.errorReason,
|
|
summaryGeneratedAt: document.summaryGeneratedAt,
|
|
createdAt: document.createdAt,
|
|
updatedAt: document.updatedAt,
|
|
}),
|
|
);
|
|
|
|
const restoredMood = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: mood.id },
|
|
include: {
|
|
tagLinks: {
|
|
include: { moodTag: { select: { key: true } } },
|
|
},
|
|
},
|
|
});
|
|
expect(restoredMood).toEqual(
|
|
expect.objectContaining({
|
|
date: mood.date,
|
|
mood: mood.mood,
|
|
score: mood.score,
|
|
source: mood.source,
|
|
moodLoggedAt: mood.moodLoggedAt,
|
|
}),
|
|
);
|
|
expect(restoredMood.tagLinks).toEqual([
|
|
expect.objectContaining({
|
|
rating: 5,
|
|
moodTag: { key: "dr_sleep_quality" },
|
|
}),
|
|
]);
|
|
expect(
|
|
await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: deletedMood.id },
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
userId: ownerId,
|
|
deletedAt: tombstoneDeletedAt,
|
|
}),
|
|
);
|
|
|
|
expect((await restore()).status).toBe(200);
|
|
expect(
|
|
await prisma.inboundDocument.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual([{ id: document.id }]);
|
|
expect(
|
|
await prisma.workout.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual([{ id: workout.id }]);
|
|
expect(
|
|
await prisma.labResult.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual([{ id: lab.id }]);
|
|
expect(
|
|
await prisma.illnessEpisode.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual([{ id: illness.id }]);
|
|
});
|
|
|
|
it("rejects a stable measurement id owned by another account without clearing either owner", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const other = await prisma.user.create({
|
|
data: { username: "stable-id-owner-other" },
|
|
});
|
|
const original = await prisma.measurement.create({
|
|
data: {
|
|
id: "owner-original-measurement",
|
|
userId: admin.id,
|
|
type: "PULSE",
|
|
value: 70,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-08T10:00:00.000Z"),
|
|
},
|
|
});
|
|
const conflicting = await prisma.measurement.create({
|
|
data: {
|
|
id: "cross-owner-stable-id",
|
|
userId: other.id,
|
|
type: "PULSE",
|
|
value: 65,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-08T11:00:00.000Z"),
|
|
},
|
|
});
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-12T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [
|
|
{
|
|
id: conflicting.id,
|
|
type: "PULSE",
|
|
value: 80,
|
|
unit: "bpm",
|
|
measuredAt: "2026-07-08T12:00:00.000Z",
|
|
source: "IMPORT",
|
|
},
|
|
],
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "STABLE_ID_OWNER_CONFLICT",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(
|
|
await prisma.measurement.findUnique({ where: { id: original.id } }),
|
|
).toEqual(original);
|
|
expect(
|
|
await prisma.measurement.findUnique({ where: { id: conflicting.id } }),
|
|
).toEqual(conflicting);
|
|
});
|
|
|
|
it("restores many stable-id measurements with a bounded number of writes", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const measurements = Array.from({ length: 250 }, (_, index) => ({
|
|
id: `stable-measurement-${index}`,
|
|
type: "PULSE" as const,
|
|
value: 60 + (index % 20),
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2026, 6, 10, 0, 0, 0, index)).toISOString(),
|
|
source: "IMPORT" as const,
|
|
externalId: `external-${index}`,
|
|
syncVersion: 3,
|
|
// Recent: a tombstone past the retention is not written back.
|
|
deletedAt:
|
|
index % 10 === 0
|
|
? new Date(Date.now() - 86_400_000).toISOString()
|
|
: null,
|
|
}));
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-12T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements,
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "BOUNDED_STABLE_ID_RESTORE",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
let stableWriteQueries = 0;
|
|
const originalTransaction = prisma.$transaction.bind(prisma) as unknown as (
|
|
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
|
|
options?: { maxWait?: number; timeout?: number },
|
|
) => Promise<unknown>;
|
|
const transactionSpy = vi.spyOn(prisma, "$transaction");
|
|
transactionSpy.mockImplementation((async (
|
|
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
|
|
options?: { maxWait?: number; timeout?: number },
|
|
) =>
|
|
originalTransaction(async (tx) => {
|
|
const originalUpsert = tx.measurement.upsert.bind(tx.measurement);
|
|
const originalCreateMany = tx.measurement.createMany.bind(
|
|
tx.measurement,
|
|
);
|
|
vi.spyOn(tx.measurement, "upsert").mockImplementation((args) => {
|
|
stableWriteQueries += 1;
|
|
return originalUpsert(args);
|
|
});
|
|
vi.spyOn(tx.measurement, "createMany").mockImplementation((args) => {
|
|
stableWriteQueries += 1;
|
|
return originalCreateMany(args);
|
|
});
|
|
return callback(tx);
|
|
}, options)) as never);
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
transactionSpy.mockRestore();
|
|
expect(stableWriteQueries).toBeLessThanOrEqual(2);
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: admin.id },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual(
|
|
measurements
|
|
.map(({ id }) => ({ id }))
|
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
);
|
|
});
|
|
it.each([
|
|
{
|
|
label: "invalid enum",
|
|
mutate: (payload: Record<string, unknown>) => {
|
|
payload.measurements = [
|
|
{
|
|
type: "INVALID_MEASUREMENT_TYPE",
|
|
value: 72,
|
|
unit: "kg",
|
|
measuredAt: "2026-07-03T07:00:00.000Z",
|
|
source: "MANUAL",
|
|
},
|
|
];
|
|
},
|
|
},
|
|
{
|
|
label: "unsupported schema version",
|
|
mutate: (payload: Record<string, unknown>) => {
|
|
payload.schemaVersion = "999";
|
|
},
|
|
},
|
|
])(
|
|
"rejects $label with 422 before writes or invalidation",
|
|
async ({ mutate }) => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const existing = await prisma.measurement.create({
|
|
data: {
|
|
id: "pre-validation-measurement",
|
|
userId: admin.id,
|
|
type: "WEIGHT",
|
|
value: 91,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-07-03T06:00:00.000Z"),
|
|
},
|
|
});
|
|
const payload: Record<string, unknown> = {
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-07-03T08:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [],
|
|
moodEntries: [],
|
|
};
|
|
mutate(payload);
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`,
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(await response.json()).toMatchObject({
|
|
data: null,
|
|
error: expect.any(String),
|
|
});
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: admin.id },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual([{ id: existing.id }]);
|
|
expect(invalidateUserData).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("rejects metadata-only documents before mutation or invalidation", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "WEIGHT",
|
|
value: 91,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-07-03T07:00:00.000Z"),
|
|
},
|
|
});
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-07-03T08:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [],
|
|
moodEntries: [],
|
|
documents: [
|
|
{
|
|
id: "metadata-only-document",
|
|
kind: "OTHER",
|
|
title: null,
|
|
filename: "metadata.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: 12,
|
|
status: "STORED",
|
|
reportDate: null,
|
|
documentDate: null,
|
|
summary: null,
|
|
createdAt: "2026-07-03T08:00:00.000Z",
|
|
},
|
|
],
|
|
manifest: {
|
|
documents: { included: "metadata-only", note: "Portable export" },
|
|
workouts: { included: "summary-only", note: "Summary only" },
|
|
},
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "PORTABLE_METADATA_ONLY",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(
|
|
await prisma.measurement.count({ where: { userId: admin.id } }),
|
|
).toBe(1);
|
|
expect(invalidateUserData).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* The contract here is the rollback, not the trigger. The trigger used to be
|
|
* a mood factor key the instance did not know, and that is no longer a
|
|
* failure at all: a seeded catalogue legitimately drifts between the day a
|
|
* backup is written and the day it is read, so an unresolvable catalogue key
|
|
* now drops one link and names it in the response rather than costing the
|
|
* whole file.
|
|
*
|
|
* An intake event naming a medication the file does not carry is a different
|
|
* animal and still throws. That reference points at a row THIS FILE was
|
|
* supposed to contain, so a dangling one means the file contradicts itself,
|
|
* and writing a dose history against a medication that does not exist is not
|
|
* a recoverable restore. It is the honest trigger for the rollback contract.
|
|
*/
|
|
it("rolls back a failed full restore before invalidating caches", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const measurement = await prisma.measurement.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "WEIGHT",
|
|
value: 91,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-07-04T07:00:00.000Z"),
|
|
},
|
|
});
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-07-04T08:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [
|
|
{
|
|
id: "failed-restore-intake",
|
|
medication: "a medication this file never carried",
|
|
scheduledFor: "2026-07-04T08:00:00.000Z",
|
|
takenAt: "2026-07-04T08:05:00.000Z",
|
|
},
|
|
],
|
|
moodEntries: [
|
|
{
|
|
id: "failed-restore-mood",
|
|
date: "2026-07-04",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
tags: null,
|
|
loggedAt: "2026-07-04T08:00:00.000Z",
|
|
factors: [],
|
|
},
|
|
],
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "FAILED_FULL_RESTORE",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: admin.id },
|
|
select: { id: true },
|
|
}),
|
|
).toEqual([{ id: measurement.id }]);
|
|
expect(await prisma.moodEntry.count({ where: { userId: admin.id } })).toBe(
|
|
0,
|
|
);
|
|
expect(invalidateUserData).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* `aggregationProvenance` is what keeps an export.xml source-day estimate
|
|
* from overwriting a native HealthKit statistic. A backup that drops it
|
|
* restores a flattened authority ladder, so it has to survive the round
|
|
* trip — and a backup written before it rode the payload has to land in the
|
|
* documented repairable state rather than reproduce the ambiguity migration
|
|
* 0263 already repaired once.
|
|
*
|
|
* Mutation check: drop `aggregationProvenance` from the backup select or
|
|
* from the restore mapping and the first case goes red; drop the
|
|
* Apple-Health `stats:` fallback and the second goes red.
|
|
*/
|
|
it("round-trips the aggregation provenance of a stats aggregate", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-25T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [
|
|
{
|
|
id: "provenance-native",
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_600,
|
|
unit: "steps",
|
|
measuredAt: "2026-07-20T10:00:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
},
|
|
{
|
|
id: "provenance-xml",
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_526,
|
|
unit: "steps",
|
|
measuredAt: "2026-07-21T10:00:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-21",
|
|
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
|
|
},
|
|
],
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "PROVENANCE_ROUNDTRIP",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: admin.id },
|
|
orderBy: { measuredAt: "asc" },
|
|
select: { id: true, aggregationProvenance: true },
|
|
}),
|
|
).toEqual([
|
|
{
|
|
id: "provenance-native",
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
},
|
|
{ id: "provenance-xml", aggregationProvenance: "EXPORT_XML_SOURCE_MAX" },
|
|
]);
|
|
});
|
|
|
|
it("marks an unlabelled Apple Health stats aggregate as legacy-unknown on restore", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
// A backup taken before the provenance rode the payload: no field at all.
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-25T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [
|
|
{
|
|
id: "legacy-apple-stats",
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_526,
|
|
unit: "steps",
|
|
measuredAt: "2026-07-20T10:00:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
{
|
|
// Another provider's cumulative daily. It mints a `stats:` id too but
|
|
// has never carried provenance, so inventing one would be a lie.
|
|
id: "legacy-fitbit-stats",
|
|
type: "ACTIVITY_STEPS",
|
|
value: 7_100,
|
|
unit: "steps",
|
|
measuredAt: "2026-07-21T10:00:00.000Z",
|
|
source: "FITBIT",
|
|
externalId: "stats:steps",
|
|
},
|
|
{
|
|
// An ordinary point reading never had provenance either.
|
|
id: "legacy-point-row",
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: "2026-07-22T10:00:00.000Z",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "PROVENANCE_LEGACY",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: admin.id },
|
|
orderBy: { measuredAt: "asc" },
|
|
select: { id: true, aggregationProvenance: true },
|
|
}),
|
|
).toEqual([
|
|
{ id: "legacy-apple-stats", aggregationProvenance: "LEGACY_UNKNOWN" },
|
|
{ id: "legacy-fitbit-stats", aggregationProvenance: null },
|
|
{ id: "legacy-point-row", aggregationProvenance: null },
|
|
]);
|
|
});
|
|
|
|
it("keeps a provenance the backup states as empty, rather than marking it legacy", async () => {
|
|
// The nightly daily-mean fold writes Apple Health `stats:` rows with no
|
|
// provenance (respiratory rate, walking speed, …). A current backup says
|
|
// so with an explicit null, which is not the same as a backup too old to
|
|
// carry the field. Restoring those rows as LEGACY_UNKNOWN changed 18 618
|
|
// rows of a 2.6 million-reading account on every restore (#1031).
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-25T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [
|
|
{
|
|
id: "daily-mean-stats",
|
|
type: "RESPIRATORY_RATE",
|
|
value: 14.2,
|
|
unit: "breaths/min",
|
|
measuredAt: "2026-07-20T10:00:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId:
|
|
"stats:HKQuantityTypeIdentifierRespiratoryRate:2026-07-20",
|
|
aggregationProvenance: null,
|
|
},
|
|
],
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "PROVENANCE_EXPLICIT_NULL",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: admin.id },
|
|
select: { id: true, aggregationProvenance: true },
|
|
}),
|
|
).toEqual([{ id: "daily-mean-stats", aggregationProvenance: null }]);
|
|
});
|
|
});
|