mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Three of the restore's lookups resolve a key against a seeded catalogue: cycle symptoms, illness symptoms, mood factors. Each threw on a key it could not resolve, inside the one transaction that wipes and rebuilds the account. So a single renamed symptom rolled everything back and answered 500. No measurements, no doses, no notes, nothing. A year-old backup read by a newer instance is the ordinary case for a backup, and the catalogue drifts between those two dates by design. The unresolvable link is dropped and named now. What it costs is one association between a row and a symptom; the day-log, the mood entry, the flow, the temperature and the encrypted note all come back. The count and the exact keys ride the response, the audit row and the wide event, and the admin surface shows them in a panel that stays put instead of a toast that leaves. A quiet drop would be the older failure of this same seam wearing a success message. A dangling `cycleId` or a dose recorded against a medication the file never carried still throws. Those name rows the file was supposed to hold, so a missing one means the file contradicts itself. A catalogue key names a row the instance owns, which is why it gets the other answer. `IllnessSymptom` carries no owner column at all, so a key that will not resolve there can only ever be drift; the file was never able to supply it. The restore does not mint the missing catalogue row back. That would preserve the link at the price of reference data colliding with whatever the next migration seeds under that key, and a label nothing can translate.
253 lines
8.8 KiB
TypeScript
253 lines
8.8 KiB
TypeScript
/**
|
|
* Integration suite for the v1.15.0 cycle import-promotion (wave 4) and the
|
|
* cycle export inclusion (wave 9) against a real Postgres testcontainer.
|
|
*
|
|
* - the Apple Health `export.xml` importer routes reproductive HK samples
|
|
* into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled,
|
|
* merging same-day samples into one row with first-write-wins re-import
|
|
* - the full-backup cycle section round-trips through
|
|
* `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted`
|
|
* preserved verbatim and symptom links re-resolved by key
|
|
*/
|
|
import { writeFileSync, mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
|
|
import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup";
|
|
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
const USER_ID = "user-cycle-import";
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "cycle-import",
|
|
email: "cycle-import@example.test",
|
|
gender: "FEMALE",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
// Ensure a catalogue symptom exists for the link re-resolution (the
|
|
// TRUNCATE CASCADE may wipe the migration-seeded catalogue).
|
|
await prisma.cycleSymptomCategory.upsert({
|
|
where: { key: "physical" },
|
|
update: {},
|
|
create: { key: "physical", labelKey: "cycle.symptomCategory.physical" },
|
|
});
|
|
const category = await prisma.cycleSymptomCategory.findUniqueOrThrow({
|
|
where: { key: "physical" },
|
|
});
|
|
await prisma.cycleSymptom.upsert({
|
|
where: { key: "cramps" },
|
|
update: {},
|
|
create: {
|
|
key: "cramps",
|
|
labelKey: "cycle.symptom.cramps",
|
|
categoryId: category.id,
|
|
},
|
|
});
|
|
});
|
|
|
|
function writeXml(records: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "hl-cycle-xml-"));
|
|
const path = join(dir, "export.xml");
|
|
writeFileSync(
|
|
path,
|
|
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData>\n${records}\n</HealthData>`,
|
|
);
|
|
return path;
|
|
}
|
|
|
|
describe("cycle import — reproductive HK samples route to CycleDayLog", () => {
|
|
it("folds same-day flow + mucus + cramps into one day-log, not Measurement", async () => {
|
|
const prisma = getPrismaClient();
|
|
const xml = writeXml(
|
|
[
|
|
`<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowMedium" startDate="2026-03-02 08:00:00 +0000" endDate="2026-03-02 08:00:00 +0000"/>`,
|
|
`<Record type="HKCategoryTypeIdentifierCervicalMucusQuality" value="HKCategoryValueCervicalMucusQualityEggWhite" startDate="2026-03-02 09:00:00 +0000" endDate="2026-03-02 09:00:00 +0000"/>`,
|
|
`<Record type="HKCategoryTypeIdentifierAbdominalCramps" value="3" startDate="2026-03-02 10:00:00 +0000" endDate="2026-03-02 10:00:00 +0000"/>`,
|
|
].join("\n"),
|
|
);
|
|
|
|
const result = await streamParseExportXml({
|
|
xmlPath: xml,
|
|
userId: USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
|
|
expect(result.cycle.samplesConsumed).toBe(3);
|
|
expect(result.cycle.daysUpserted).toBe(1);
|
|
expect(result.cycle.daysInserted).toBe(1);
|
|
|
|
const dayLogs = await prisma.cycleDayLog.findMany({
|
|
where: { userId: USER_ID },
|
|
include: { symptomLinks: { include: { symptom: true } } },
|
|
});
|
|
expect(dayLogs).toHaveLength(1);
|
|
expect(dayLogs[0].date).toBe("2026-03-02");
|
|
expect(dayLogs[0].flow).toBe("MEDIUM");
|
|
expect(dayLogs[0].cervicalMucus).toBe("EGG_WHITE");
|
|
expect(dayLogs[0].source).toBe("APPLE_HEALTH");
|
|
expect(dayLogs[0].symptomLinks.map((l) => l.symptom.key)).toContain(
|
|
"cramps",
|
|
);
|
|
|
|
// No reproductive Measurement rows were created.
|
|
const measurements = await prisma.measurement.count({
|
|
where: { userId: USER_ID },
|
|
});
|
|
expect(measurements).toBe(0);
|
|
});
|
|
|
|
it("is idempotent on re-import (first-write-wins on the synthetic day key)", async () => {
|
|
const prisma = getPrismaClient();
|
|
const records = `<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowHeavy" startDate="2026-03-05 08:00:00 +0000" endDate="2026-03-05 08:00:00 +0000"/>`;
|
|
const xml = writeXml(records);
|
|
|
|
await streamParseExportXml({
|
|
xmlPath: xml,
|
|
userId: USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
const second = await streamParseExportXml({
|
|
xmlPath: writeXml(records),
|
|
userId: USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
|
|
expect(second.cycle.daysUpserted).toBe(1);
|
|
expect(second.cycle.daysInserted).toBe(0); // already existed
|
|
const count = await prisma.cycleDayLog.count({
|
|
where: { userId: USER_ID },
|
|
});
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
it("skips the cycle fold entirely for a non-cycle account", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: USER_ID },
|
|
data: { gender: "MALE" },
|
|
});
|
|
const xml = writeXml(
|
|
`<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowLight" startDate="2026-03-02 08:00:00 +0000" endDate="2026-03-02 08:00:00 +0000"/>`,
|
|
);
|
|
|
|
const result = await streamParseExportXml({
|
|
xmlPath: xml,
|
|
userId: USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
|
|
// Sample was bucketed but the gate suppressed the flush.
|
|
expect(result.cycle.daysUpserted).toBe(0);
|
|
const count = await prisma.cycleDayLog.count({
|
|
where: { userId: USER_ID },
|
|
});
|
|
expect(count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("cycle backup — round-trips through build + restore", () => {
|
|
it("preserves notesEncrypted ciphertext and symptom links by key", async () => {
|
|
const prisma = getPrismaClient();
|
|
|
|
const cycle = await prisma.menstrualCycle.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
startDate: "2026-03-01",
|
|
endDate: "2026-03-28",
|
|
periodEndDate: "2026-03-05",
|
|
lengthDays: 28,
|
|
},
|
|
});
|
|
const dayLog = await prisma.cycleDayLog.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
date: "2026-03-02",
|
|
flow: "MEDIUM",
|
|
cycleId: cycle.id,
|
|
cervixPosition: "HIGH",
|
|
cervixFirmness: "SOFT",
|
|
cervixOpening: "OPEN",
|
|
notesEncrypted: "v1:abc123:cipher-envelope",
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
|
|
where: { key: "cramps" },
|
|
});
|
|
await prisma.cycleSymptomLink.create({
|
|
data: { dayLogId: dayLog.id, symptomId: symptom.id },
|
|
});
|
|
await prisma.cycleProfile.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
goal: "TRYING_TO_CONCEIVE",
|
|
cycleTrackingEnabled: true,
|
|
secondarySymptom: "CERVIX",
|
|
},
|
|
});
|
|
|
|
// Build the backup section, then assert it round-trips through the schema.
|
|
const section = await buildCycleBackupSection(prisma, USER_ID);
|
|
expect(section.cycles).toHaveLength(1);
|
|
expect(section.cycleDayLogs).toHaveLength(1);
|
|
expect(section.cycleDayLogs[0].notesEncrypted).toBe(
|
|
"v1:abc123:cipher-envelope",
|
|
);
|
|
expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]);
|
|
|
|
const payload = parseBackupPayload({
|
|
schemaVersion: "1",
|
|
exportedAt: new Date().toISOString(),
|
|
userId: USER_ID,
|
|
...section,
|
|
});
|
|
|
|
// Restore into a clean transaction (wipes then recreates).
|
|
const skips: RestoreSkipLog = [];
|
|
await prisma.$transaction(async (tx) => {
|
|
await restoreCycleData(tx, USER_ID, payload, skips);
|
|
});
|
|
// A file whose every symptom resolves reports nothing skipped. Absence
|
|
// reading as absence cuts both ways: the report has to stay empty when
|
|
// nothing was lost, or an operator learns to ignore it.
|
|
expect(skips).toEqual([]);
|
|
|
|
const restored = await prisma.cycleDayLog.findMany({
|
|
where: { userId: USER_ID },
|
|
include: { symptomLinks: { include: { symptom: true } } },
|
|
});
|
|
expect(restored).toHaveLength(1);
|
|
expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope");
|
|
expect(restored[0].cervixPosition).toBe("HIGH");
|
|
expect(restored[0].cervixFirmness).toBe("SOFT");
|
|
expect(restored[0].cervixOpening).toBe("OPEN");
|
|
expect(restored[0].cycleId).not.toBeNull();
|
|
expect(restored[0].symptomLinks.map((l) => l.symptom.key)).toEqual([
|
|
"cramps",
|
|
]);
|
|
|
|
const profile = await prisma.cycleProfile.findUniqueOrThrow({
|
|
where: { userId: USER_ID },
|
|
});
|
|
expect(profile.goal).toBe("TRYING_TO_CONCEIVE");
|
|
expect(profile.secondarySymptom).toBe("CERVIX");
|
|
});
|
|
});
|