mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Supplement-style intake written to Apple Health by nutrition apps — vitamins, minerals, water, caffeine — can now sync as daily totals into a dedicated NutrientIntakeDay store (migration 0241): composite (user, day, nutrient) identity, re-post replaces, no soft delete, no rollup coupling. A code-side catalog carries the 26 nutrient codes with canonical units, plausibility caps, and EFSA dietary reference values (direction-aware; chromium cites NIH ODS where EFSA declined a DRV). Deliberately not a nutrition feature: no diary, no calories, no dashboard surface. POST /api/nutrients/batch is gated behind the new opt-in nutrients module (default off — with the module off the server refuses ingest entirely), rate-limited, idempotency-wrapped, with per-entry unit-mismatch and out-of-range skips. GET /api/nutrients serves a catalog-ordered summary; the only visible surface is a read-only card under Settings → Sources. Coach/correlation access is a later slice; the iOS reading side is coordinated in the companion-app tracker.
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
/**
|
|
* v1.4.25 W6c — per-section toggle integration tests.
|
|
*
|
|
* Asserts the privacy-by-default contract for mood data: when the user
|
|
* submits `sections.mood = false`, the generated PDF MUST NOT contain
|
|
* any mood-related text, and the aggregator MUST NOT have fetched the
|
|
* underlying `MoodEntry` rows in the first place.
|
|
*
|
|
* Also asserts the "skip section when toggle off" contract for the
|
|
* non-privacy-sensitive sections (BP / weight / pulse / compliance) so
|
|
* the dialog's toggles produce the expected printed artefact.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
|
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
|
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
const RANGE = {
|
|
start: new Date("2026-01-01T00:00:00.000Z"),
|
|
end: new Date("2026-05-01T00:00:00.000Z"),
|
|
days: 120,
|
|
};
|
|
|
|
/**
|
|
* Seed a user plus one row of every measurement type the toggles
|
|
* gate. Returns the user id for the test to thread into the aggregator
|
|
* and PDF renderer.
|
|
*/
|
|
async function seedUserWithEveryDataType(username: string): Promise<string> {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
heightCm: 180,
|
|
},
|
|
});
|
|
const measuredAt = new Date("2026-03-01T08:00:00.000Z");
|
|
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 82.5,
|
|
unit: "kg",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 128,
|
|
unit: "mmHg",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "BLOOD_PRESSURE_DIA",
|
|
value: 82,
|
|
unit: "mmHg",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "PULSE",
|
|
value: 65,
|
|
unit: "bpm",
|
|
measuredAt,
|
|
},
|
|
// v1.18.0 module-gated data types (no `sections` toggle of their own).
|
|
{
|
|
userId: user.id,
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 95,
|
|
unit: "mg/dL",
|
|
glucoseContext: "FASTING",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "RECOVERY_SCORE",
|
|
value: 72,
|
|
unit: "score",
|
|
source: "WHOOP",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "STRESS_SCORE",
|
|
value: 40,
|
|
unit: "score",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "STRAIN_SCORE",
|
|
value: 12,
|
|
unit: "score",
|
|
measuredAt,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8200,
|
|
unit: "count",
|
|
measuredAt,
|
|
},
|
|
],
|
|
});
|
|
|
|
// Lab result — module `labs` owns these; the `labs` section toggle is ON by
|
|
// default so a present `labs` module surfaces the panel.
|
|
await prisma.labResult.create({
|
|
data: {
|
|
userId: user.id,
|
|
panel: "Lipids",
|
|
analyte: "LDL",
|
|
value: 110,
|
|
unit: "mg/dL",
|
|
takenAt: measuredAt,
|
|
},
|
|
});
|
|
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: user.id,
|
|
score: 4,
|
|
mood: "GUT",
|
|
moodLoggedAt: measuredAt,
|
|
date: "2026-03-01",
|
|
},
|
|
});
|
|
|
|
// Medication compliance — one taken intake event against a scheduled
|
|
// medication. The ledger compliance builder (v1.17 W1a) only tallies
|
|
// medications that carry a schedule (no schedule = PRN/as-needed = no
|
|
// expected dose = excluded so the report never prints a fabricated
|
|
// 100 %), so the fixture must seed a window for the drug to appear in
|
|
// `data.compliance`.
|
|
const med = await prisma.medication.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "Metformin",
|
|
dose: "500 mg",
|
|
active: true,
|
|
schedules: {
|
|
create: [{ windowStart: "08:00", windowEnd: "10:00" }],
|
|
},
|
|
},
|
|
});
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: user.id,
|
|
medicationId: med.id,
|
|
scheduledFor: measuredAt,
|
|
takenAt: measuredAt,
|
|
},
|
|
});
|
|
|
|
return user.id;
|
|
}
|
|
|
|
/**
|
|
* Pull plain UTF-8 text out of the PDF byte stream. jsPDF's text
|
|
* operator stores the strings inline as `(...)` tokens, so a naive
|
|
* regex scan over the buffer is enough to assert presence/absence of a
|
|
* label — we don't need to actually decode the streams. False positives
|
|
* are tolerable because the assertions are negative ("doesn't contain
|
|
* `Stimmung`") and the strings are distinctive.
|
|
*/
|
|
function pdfContainsText(bytes: Uint8Array, needle: string): boolean {
|
|
const haystack = Buffer.from(bytes).toString("latin1");
|
|
return haystack.includes(needle);
|
|
}
|
|
|
|
describe("doctor-report — per-section toggles", () => {
|
|
it("drops mood data entirely when sections.mood = false", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-sections-mood-off");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE, {
|
|
sections: {
|
|
bp: true,
|
|
weight: true,
|
|
pulse: true,
|
|
bmi: true,
|
|
mood: false, // ← the privacy default
|
|
compliance: true,
|
|
sleep: true,
|
|
},
|
|
});
|
|
|
|
// The aggregator must NOT return mood data when the toggle is off.
|
|
// The contract: the data never leaves the DB row, so the JSON
|
|
// payload (this `data` object) is the canonical proof.
|
|
expect(data.mood).toBeNull();
|
|
|
|
// The PDF rendered from this data must not mention the German or
|
|
// English mood section header — both locales render against the
|
|
// same `data` payload so both must be clean.
|
|
const { t: tDe } = getServerTranslator("de");
|
|
const { t: tEn } = getServerTranslator("en");
|
|
const pdfDe = renderDoctorReportPdfBytes(data, { t: tDe, locale: "de" });
|
|
const pdfEn = renderDoctorReportPdfBytes(data, { t: tEn, locale: "en" });
|
|
|
|
expect(pdfContainsText(pdfDe, "Stimmung")).toBe(false);
|
|
expect(pdfContainsText(pdfEn, "Mood")).toBe(false);
|
|
});
|
|
|
|
it("includes mood data when sections.mood = true (opt-in)", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-sections-mood-on");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE, {
|
|
sections: { mood: true },
|
|
});
|
|
|
|
expect(data.mood).not.toBeNull();
|
|
expect(data.mood?.count).toBe(1);
|
|
});
|
|
|
|
it("strips BP / weight / pulse / compliance when their toggles are off", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-sections-others-off");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE, {
|
|
sections: {
|
|
bp: false,
|
|
weight: false,
|
|
pulse: false,
|
|
bmi: false,
|
|
mood: false,
|
|
compliance: false,
|
|
sleep: false,
|
|
},
|
|
});
|
|
|
|
expect(data.stats.BLOOD_PRESSURE_SYS).toBeUndefined();
|
|
expect(data.stats.BLOOD_PRESSURE_DIA).toBeUndefined();
|
|
expect(data.stats.WEIGHT).toBeUndefined();
|
|
expect(data.stats.PULSE).toBeUndefined();
|
|
expect(data.compliance).toEqual({});
|
|
expect(data.bmi).toBeNull();
|
|
expect(data.mood).toBeNull();
|
|
});
|
|
|
|
it("applies documented defaults when sections is omitted (mood OFF, others ON)", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-sections-defaults");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE);
|
|
|
|
// Privacy default per the maintainer — mood is opt-in, not opt-out.
|
|
expect(data.mood).toBeNull();
|
|
// Every other section keeps its data.
|
|
expect(data.stats.WEIGHT).toBeDefined();
|
|
expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
|
|
expect(data.stats.PULSE).toBeDefined();
|
|
expect(data.bmi).not.toBeNull();
|
|
expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("doctor-report — module enable/disable gating", () => {
|
|
// A fully-enabled module map (the default-on shape `resolveModuleMap` would
|
|
// return for an account that never toggled a module off).
|
|
const ALL_ON = {
|
|
cycle: true,
|
|
mood: true,
|
|
sleep: true,
|
|
glucose: true,
|
|
workouts: true,
|
|
recovery: true,
|
|
labs: true,
|
|
illness: true,
|
|
achievements: true,
|
|
coach: true,
|
|
insights: true,
|
|
medications: true,
|
|
doctorReport: true,
|
|
environment: true,
|
|
mcp: true,
|
|
inboundDocuments: true,
|
|
mentalHealth: true,
|
|
nutrients: true,
|
|
} as const;
|
|
|
|
it("includes every module's data when all modules are enabled", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-modules-all-on");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE, {
|
|
// Opt mood in so the present-case asserts the full surface.
|
|
sections: { mood: true },
|
|
moduleMap: { ...ALL_ON },
|
|
});
|
|
|
|
expect(Object.keys(data.glucoseStats).length).toBeGreaterThan(0);
|
|
expect(data.measurements.ACTIVITY_STEPS).toBeDefined();
|
|
expect(data.measurements.BLOOD_GLUCOSE).toBeDefined();
|
|
expect(data.labResults).not.toBeNull();
|
|
expect(data.mood).not.toBeNull();
|
|
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
|
expect(scoreTypes).toEqual(
|
|
expect.arrayContaining([
|
|
"RECOVERY_SCORE",
|
|
"STRESS_SCORE",
|
|
"STRAIN_SCORE",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("excludes a disabled module's section/resources from the payload", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-modules-off");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE, {
|
|
// The user opted mood in at the report level, but turned the modules off
|
|
// — the module gate wins, so none of these surface.
|
|
sections: { mood: true, sleep: true, labs: true },
|
|
moduleMap: {
|
|
...ALL_ON,
|
|
glucose: false,
|
|
sleep: false,
|
|
workouts: false,
|
|
recovery: false,
|
|
labs: false,
|
|
mood: false,
|
|
},
|
|
});
|
|
|
|
// Glucose panel collapses entirely (stats + raw series).
|
|
expect(data.glucoseStats).toEqual({});
|
|
expect(data.glucoseRanges).toEqual({});
|
|
expect(data.measurements.BLOOD_GLUCOSE).toBeUndefined();
|
|
// Sleep series stripped (sections + module agree).
|
|
expect(data.measurements.SLEEP_DURATION).toBeUndefined();
|
|
// Workout activity series stripped.
|
|
expect(data.measurements.ACTIVITY_STEPS).toBeUndefined();
|
|
expect(data.stats.ACTIVITY_STEPS).toBeUndefined();
|
|
// Labs panel gone.
|
|
expect(data.labResults).toBeNull();
|
|
// Mood never assembled.
|
|
expect(data.mood).toBeNull();
|
|
// Recovery + strain wellness scores dropped (recovery + workouts off).
|
|
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
|
expect(scoreTypes).not.toContain("RECOVERY_SCORE");
|
|
expect(scoreTypes).not.toContain("STRESS_SCORE");
|
|
expect(scoreTypes).not.toContain("STRAIN_SCORE");
|
|
// Core clinical sections stay unconditional.
|
|
expect(data.stats.WEIGHT).toBeDefined();
|
|
expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
|
|
expect(data.stats.PULSE).toBeDefined();
|
|
expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("gates recovery and workouts independently (recovery off, workouts on)", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-modules-recovery-off");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE, {
|
|
moduleMap: { ...ALL_ON, recovery: false },
|
|
});
|
|
|
|
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
|
// Recovery readiness scores gone…
|
|
expect(scoreTypes).not.toContain("RECOVERY_SCORE");
|
|
expect(scoreTypes).not.toContain("STRESS_SCORE");
|
|
// …but the workout-owned strain score + activity series stay.
|
|
expect(scoreTypes).toContain("STRAIN_SCORE");
|
|
expect(data.measurements.ACTIVITY_STEPS).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("doctor-report — MedicationAdministration export cap", () => {
|
|
it("caps the administration set at the configured ceiling and flags the truncation", async () => {
|
|
// The default ceiling is 5000; pin the operator-configurable
|
|
// `FHIR_MAX_MEDICATION_ADMINISTRATIONS` to 1000 so the cap is exercised
|
|
// with a tractable row count (the cap is resolved per call).
|
|
vi.stubEnv("FHIR_MAX_MEDICATION_ADMINISTRATIONS", "1000");
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "dr-admin-cap",
|
|
email: "dr-admin-cap@example.test",
|
|
heightCm: 180,
|
|
},
|
|
});
|
|
const med = await prisma.medication.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "Metformin",
|
|
dose: "500 mg",
|
|
active: true,
|
|
},
|
|
});
|
|
|
|
// 1001 acted (taken) intakes spread across the window so the cap
|
|
// must trim exactly one — the OLDEST — keeping the most-recent 1000.
|
|
const rangeMs = RANGE.end.getTime() - RANGE.start.getTime();
|
|
const rows = Array.from({ length: 1001 }, (_, i) => {
|
|
const at = new Date(
|
|
RANGE.start.getTime() + Math.floor((rangeMs * i) / 1001),
|
|
);
|
|
return {
|
|
userId: user.id,
|
|
medicationId: med.id,
|
|
scheduledFor: at,
|
|
takenAt: at,
|
|
};
|
|
});
|
|
await prisma.medicationIntakeEvent.createMany({ data: rows });
|
|
|
|
const data = await collectDoctorReportData(user.id, RANGE);
|
|
|
|
expect(data.medicationAdministrations).toBeDefined();
|
|
expect(data.medicationAdministrations?.length).toBe(1000);
|
|
expect(data.medicationAdministrationsTruncation).toEqual({
|
|
total: 1001,
|
|
included: 1000,
|
|
});
|
|
// The OLDEST row was dropped — the earliest surviving administration
|
|
// is strictly after the window start.
|
|
const first = data.medicationAdministrations?.[0];
|
|
expect(new Date(first!.effectiveAt).getTime()).toBeGreaterThan(
|
|
RANGE.start.getTime(),
|
|
);
|
|
});
|
|
|
|
it("leaves the set untouched (no truncation flag) below the cap", async () => {
|
|
const userId = await seedUserWithEveryDataType("dr-admin-no-cap");
|
|
|
|
const data = await collectDoctorReportData(userId, RANGE);
|
|
|
|
expect(data.medicationAdministrations?.length).toBe(1);
|
|
expect(data.medicationAdministrationsTruncation ?? null).toBeNull();
|
|
});
|
|
});
|