/** * 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 the note carried * readable, re-sealed on restore, 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 { decrypt, encrypt } from "@/lib/crypto"; 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, `\n\n${records}\n`, ); 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( [ ``, ``, ``, ].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 = ``; 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( ``, ); 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("carries the note readable and re-seals it, with 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: encrypt("Cramps in the evening"), 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); // A portable section carries the note readable, never the ciphertext. expect(section.cycleDayLogs[0].note).toBe("Cramps in the evening"); expect(section.cycleDayLogs[0]).not.toHaveProperty("notesEncrypted"); 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(decrypt(restored[0].notesEncrypted!)).toBe("Cramps in the evening"); 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"); }); });