mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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>
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
/**
|
|
* v1.4.16 H2 — moodEntrySchema.tags must reject malformed tag blobs
|
|
* before the restore transaction inserts them. The DB column stores
|
|
* tags as a JSON-array-as-string ("[\"work\",\"sleep\"]"), so an
|
|
* arbitrary string slipping through Zod is a foot-gun:
|
|
* - downstream readers `JSON.parse` blindly and crash on a
|
|
* non-array
|
|
* - the iOS native client renders tags one-per-row and would
|
|
* either render garbage or throw
|
|
*
|
|
* We accept three shapes:
|
|
* - `null` (and omitted/undefined): "no tags on this entry"
|
|
* - empty string `""`: legacy moodLog wire format (handled as null)
|
|
* - a JSON-string that parses to an array of strings.
|
|
*
|
|
* Anything else (object, JSON-with-numbers, garbage) is rejected at
|
|
* schema time with a 422 response from the restore endpoint instead
|
|
* of bubbling out of `prisma.createMany` with a useless message.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { backupPayloadSchema } from "../backup";
|
|
|
|
const baseEntry = {
|
|
date: "2026-05-08",
|
|
mood: "GUT",
|
|
score: 4,
|
|
source: "MOODLOG",
|
|
loggedAt: "2026-05-08T20:00:00.000Z",
|
|
};
|
|
|
|
function parseWithMoodTags(tags: unknown) {
|
|
return backupPayloadSchema.safeParse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-05-08T07:00:00.000Z",
|
|
userId: "u1",
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [{ ...baseEntry, tags }],
|
|
});
|
|
}
|
|
|
|
describe("moodEntrySchema.tags strict validation", () => {
|
|
it("accepts null", () => {
|
|
const r = parseWithMoodTags(null);
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("accepts an empty string (legacy moodLog wire format)", () => {
|
|
const r = parseWithMoodTags("");
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a JSON-string that parses to an array of strings", () => {
|
|
const r = parseWithMoodTags('["work","sleep"]');
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a non-JSON garbage string", () => {
|
|
const r = parseWithMoodTags("not-json{");
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects a JSON object that is not an array", () => {
|
|
const r = parseWithMoodTags('{"a":1}');
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects a JSON array containing non-strings", () => {
|
|
const r = parseWithMoodTags("[1,2,3]");
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("accepts an omitted tags field (optional)", () => {
|
|
const r = backupPayloadSchema.safeParse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-05-08T07:00:00.000Z",
|
|
userId: "u1",
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [{ ...baseEntry }],
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("backupPayloadSchema — v1.15.0 cycle round-trip", () => {
|
|
it("defaults the cycle fields when a pre-v1.15 blob omits them", () => {
|
|
const r = backupPayloadSchema.safeParse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-05-08T07:00:00.000Z",
|
|
userId: "u1",
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) {
|
|
expect(r.data.cycleProfile).toBeNull();
|
|
expect(r.data.cycles).toEqual([]);
|
|
expect(r.data.cycleDayLogs).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("round-trips a cycle profile, observed span, and day-log with notes ciphertext", () => {
|
|
const r = backupPayloadSchema.safeParse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-05-08T07:00:00.000Z",
|
|
userId: "u1",
|
|
cycleProfile: {
|
|
goal: "TRYING_TO_CONCEIVE",
|
|
cycleTrackingEnabled: true,
|
|
typicalCycleLength: 28,
|
|
lutealPhaseLength: 14,
|
|
sensitiveCategoryEncryption: true,
|
|
},
|
|
cycles: [
|
|
{
|
|
startDate: "2026-04-20",
|
|
endDate: "2026-05-17",
|
|
periodEndDate: "2026-04-24",
|
|
lengthDays: 28,
|
|
ovulationDate: "2026-05-04",
|
|
ovulationConfirmed: true,
|
|
tz: "Europe/Berlin",
|
|
},
|
|
],
|
|
cycleDayLogs: [
|
|
{
|
|
date: "2026-04-20",
|
|
flow: "MEDIUM",
|
|
intermenstrualBleeding: false,
|
|
sexualActivity: true,
|
|
protectedSex: false,
|
|
contraceptive: "NONE",
|
|
// The note must round-trip as the opaque ciphertext envelope.
|
|
notesEncrypted: "v1:deadbeef:cipher",
|
|
source: "APPLE_HEALTH",
|
|
externalId: "hkcycle:2026-04-20",
|
|
symptomKeys: ["cramps", "fatigue"],
|
|
},
|
|
],
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) {
|
|
expect(r.data.cycleProfile?.goal).toBe("TRYING_TO_CONCEIVE");
|
|
expect(r.data.cycles[0].lengthDays).toBe(28);
|
|
expect(r.data.cycleDayLogs[0].notesEncrypted).toBe("v1:deadbeef:cipher");
|
|
expect(r.data.cycleDayLogs[0].symptomKeys).toEqual(["cramps", "fatigue"]);
|
|
}
|
|
});
|
|
});
|