Files
HealthLog/tests/integration/cycle-import-export.test.ts
T
Marc-André BombeckandClaude Opus 4.8 e984ada5f1 feat(cycle): import Apple Health reproductive data into cycle, add cycle export
Promote the reproductive HealthKit category types out of the
Apple-Health-import privacy-hold list and route them into cycle day-logs
instead of measurements, and surface cycle data on the three export
surfaces.

Import promotion:
- A new HealthKit -> cycle mapping module is the single source of truth for
  how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality,
  OvulationTestResult, SexualActivity, PregnancyTestResult,
  ProgesteroneTestResult, Contraceptive and the symptom category types fold
  into CycleDayLog fields / seeded symptom keys. The XML importer
  accumulates same-day reproductive samples and writes one canonical
  day-log per day with source APPLE_HEALTH and a synthetic per-day
  externalId for idempotent re-import. The fold is gated on cycle tracking
  being enabled, so a non-cycle export never provisions cycle rows. A
  contraceptive method nudges the profile goal toward avoiding pregnancy
  only when the goal is still on its default. Basal body temperature stays
  a measurement and mirrors onto the day-log. Pregnancy/Lactation status
  and the pregnancy-bleeding types stay deferred; the cycle awareness types
  remain server-derived.

Export:
- FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period
  length (64698-4) and the current phase as opt-in reproductive-health
  observations grouped into their own document section.
- The doctor PDF gains an opt-in cycle section (recent cycles, average
  length and variability, current phase), privacy-default off like mood.
- The full-backup export, the weekly backup worker and the off-host DR
  snapshot include the cycle tables, and the admin restore re-creates them;
  the day-log note round-trips as ciphertext and is never decrypted onto a
  report surface.

Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit
+ integration coverage for the mapping, the import routing, the FHIR
observations and the backup round-trip. OpenAPI regenerated. Six-locale
strings added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:54 +02:00

231 lines
8.1 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 { 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,
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 },
});
// 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).
await prisma.$transaction(async (tx) => {
await restoreCycleData(tx, USER_ID, payload);
});
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].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");
});
});