Files
HealthLog/src/__tests__/medication-intake-tracking-guard.test.ts
T
Marc-André Bombeck 1d1570733e fix(medications): leave record-only doses out of the nudge, the status model and the BP correlation
Three readers compute a rate straight from intake rows without reading a
schedule: the weekly compliance nudge, the adherence series behind the
general status, and the BP-versus-compliance correlation. They now read
tracked medications only. The structural guard covers intake-row readers
as well as schedule readers, with a whitespace-tolerant matcher, a floor
on the match count and a reviewed allowlist.
2026-09-25 10:03:08 +02:00

266 lines
11 KiB
TypeScript

/**
* Structural guard — every reader of medication schedules honours the
* per-medication intake-tracking switch (#1033).
*
* An as-needed medication is "never due, never reminded, never counted"
* structurally: it carries no schedule rows, so every reader that expands
* schedules into slots finds nothing to expand. A medication with intake
* tracking off (`Medication.trackIntake = false`) keeps its schedule rows as a
* record. Nothing about the rows themselves says "do not derive a due dose
* from me", so the promise holds only as long as every reader asks. A new
* reminder path, compliance surface or projection that reads `schedules` and
* forgets the flag would quietly start reminding a person who asked not to be
* reminded.
*
* So the set of schedule readers is frozen here. A file that reads medication
* schedules through Prisma either uses the shared vocabulary in
* `src/lib/medications/intake-tracking.ts` (or `expectsDoses`, whose required
* `trackIntake` field makes the type checker enforce the same thing), or it is
* on the allowlist below with the reason it may see every stored row.
*
* Limits, stated plainly. The matcher is textual: it finds a Prisma
* `schedules: true` / `schedules: { … }` include or select and the
* `medicationSchedule.find*` delegates. A schedule read spelt some other way
* (raw SQL on `medication_schedules`, a helper that returns rows it read
* elsewhere) is invisible to it. It proves a file names the vocabulary, not
* that the named helper is applied to the right query. The behavioural tests
* beside each consumer carry that half.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** A Prisma read (or write payload) that touches medication schedule rows. */
const SCHEDULE_READ =
/\bschedules\s*:\s*(?:true\b|\{)|\bmedicationSchedule\s*\.\s*find(?:Many|First|Unique)\b/;
/** The vocabulary that honours the switch. */
const HONOURS =
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/;
/**
* A bulk read of intake rows. Rate readers that never touch a schedule (a
* nudge that divides taken by resolved rows, a model series that buckets
* rows per day) count a record-only medication's rows unless they filter.
* Whitespace-tolerant: the delegate and the method may sit on two lines.
*/
const INTAKE_READ =
/\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/;
/**
* Intake readers that may see every medication's rows, and why: nothing in
* them is an adherence rate, an expected-dose count or a due dose.
*/
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"app/api/admin/status/route.ts": "instance row counts",
"app/api/dashboard/summary/route.ts":
"engagement streak of days with any logged action, no rate",
"app/api/export/medications/route.ts": "export of the recorded history",
"app/api/export/route.ts": "export of the recorded history",
"lib/export/full-backup-payload.ts": "backup of the recorded history",
"app/api/sync/state/route.ts": "sync watermarks, no rate",
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
"lib/ai/coach/tools/availability.ts":
"whether any intake history exists, and its date range",
"app/api/medications/[id]/intake/route.ts":
"one medication's recorded history, listed or written",
"app/api/medications/[id]/intake/bulk-delete/route.ts":
"deletes rows the person chose",
"app/api/medications/[id]/route.ts":
"one medication's slots; tombstones on the tracking switch",
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/scheduling/worker-helpers.ts":
"slot lookups for one medication the caller already selected",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
"lib/doctor-report/collect.ts":
"administration ledger lists recorded doses; rates gate via expectsDoses",
};
/**
* Readers that may see every stored schedule row, and why. Each reason says
* why no due dose, reminder or adherence figure can come out of the file.
*/
const MAY_READ_EVERY_ROW: Record<string, string> = {
// Record-keeping: a backup, a restore and an export carry the medication
// as stored, schedule and switch included.
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
"lib/export/restore-backup.ts": "restore writes the stored rows back",
"app/api/export/route.ts": "data export of the stored record",
"app/api/export/medications/route.ts": "medication CSV export of the record",
// Intake WRITE helpers: they run only when a dose is being recorded, bind
// it to the slot it belongs to and consume stock. They derive nothing due.
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
// Readers that hand the rows to a builder whose input TYPE requires
// `trackIntake` and which gates on it: the per-medication compliance
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
// report's ledger compliance (`expectsDoses`). The report's medication
// list shows the stored schedule as information.
"app/api/medications/[id]/compliance/route.ts":
"buildCompliancePayload gates on the type-required trackIntake",
"lib/doctor-report/collect.ts":
"buildLedgerCompliance gates via expectsDoses; list is information",
// The MCP medication resources list the stored schedule as information,
// with `intakeTracked` beside it; they carry no due time or rate.
"lib/mcp/resources.ts":
"schedule as information, intakeTracked flag beside it",
// The Coach's GLP-1 block reads the stored cadence as information and
// gates its next-injection prediction on the flag inline.
"lib/ai/coach/glp1-snapshot.ts":
"cadence as information; prediction gated on trackIntake",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
/** Drop line and block comments so a mention in prose never counts. */
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function code(rel: string): string {
return stripComments(readFileSync(join(SRC, rel), "utf8"));
}
/**
* The code without its import statements. An import of the vocabulary is
* not a use of it: a reader that still imports `TRACKED_INTAKE_WHERE` after
* its filter was deleted would otherwise pass.
*/
function usage(rel: string): string {
return code(rel).replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, "");
}
const readers = sourceFiles().filter((rel) => SCHEDULE_READ.test(code(rel)));
describe("medication intake tracking — schedule readers honour the switch", () => {
it("finds the schedule readers it exists to police", () => {
// An empty match set would agree with any allowlist. Pin the floor and
// the readers whose silence would matter most.
expect(readers.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/reminder/medication-reminder-check.ts",
"lib/medications/scheduling/project-today-intakes.ts",
"lib/dashboard/meds-today.ts",
"lib/medications/list-read.ts",
"lib/jobs/intake-auto-skip.ts",
]) {
expect(readers).toContain(known);
}
});
it("every schedule reader uses the vocabulary or is allowlisted", () => {
const offenders = readers.filter(
(rel) => !(rel in MAY_READ_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read medication schedules without honouring " +
"Medication.trackIntake. Filter with TRACKED_INTAKE_WHERE, derive " +
"from dueSchedules(), shape the wire with scheduleWireFields(), or " +
"add the file to MAY_READ_EVERY_ROW with the reason it derives " +
"nothing due.",
).toEqual([]);
});
it("the allowlist names no file that stopped reading schedules", () => {
const stale = Object.keys(MAY_READ_EVERY_ROW).filter(
(rel) => !readers.includes(rel),
);
expect(stale).toEqual([]);
});
it("the matchers catch the shapes they claim to", () => {
// Defeat check: the guard must be able to fail.
expect(SCHEDULE_READ.test("include: { schedules: true }")).toBe(true);
expect(SCHEDULE_READ.test("schedules: {\n select: X }")).toBe(true);
expect(
SCHEDULE_READ.test("prisma.medicationSchedule\n .findMany({})"),
).toBe(true);
expect(SCHEDULE_READ.test("const schedules = m.schedules;")).toBe(false);
expect(HONOURS.test(stripComments("// dueSchedules(m)"))).toBe(false);
expect(HONOURS.test("where: { ...TRACKED_INTAKE_WHERE }")).toBe(true);
// An import alone is not a use.
const importOnly =
'import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";\n' +
"const rows = await prisma.medication.findMany({ where: { active: true } });";
expect(
HONOURS.test(
importOnly.replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, ""),
),
).toBe(false);
});
});
const intakeReaders = sourceFiles().filter((rel) =>
INTAKE_READ.test(code(rel)),
);
describe("medication intake tracking — intake-row readers honour the switch", () => {
it("finds the intake readers it exists to police", () => {
expect(intakeReaders.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/coach-nudge.ts",
"lib/insights/general-status.ts",
"lib/analytics/correlations-fast-path.ts",
"lib/medications/outstanding-doses.ts",
]) {
expect(intakeReaders).toContain(known);
}
});
it("every intake reader uses the vocabulary or is allowlisted", () => {
const offenders = intakeReaders.filter(
(rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read intake rows without honouring Medication.trackIntake. " +
"Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " +
"medications filtered with TRACKED_INTAKE_WHERE), or add the file to " +
"INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.",
).toEqual([]);
});
it("the intake allowlist names no file that stopped reading intake rows", () => {
expect(
Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter(
(rel) => !intakeReaders.includes(rel),
),
).toEqual([]);
});
it("the intake matcher catches the shapes it claims to", () => {
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe(
true,
);
expect(
INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"),
).toBe(true);
expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true);
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe(
false,
);
});
});