Files
HealthLog/tests/integration/doctor-report-sections.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

470 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,
} 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();
});
});