mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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>
231 lines
8.1 KiB
TypeScript
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");
|
|
});
|
|
});
|