Files
HealthLog/src/__tests__/medication-intake-tracking-guard.test.ts
T
Marc-André Bombeck a0dce10c1a fix(activity): word the refusals that now reach a delegate's feed
The custom-metric create and update refusals and the medication layout
refusal are filed through auditLog now, so a delegate's refused request
reaches the owner's activity feed, where it read as the generic change.
Each gets its own sentence in every bundle; the refusal sentences live in
their own function. The audit write guard states its floor, and the
schedule-reader guard names the medication create and edit, which answer
through medicationDetail.
2026-10-23 17:19:21 +02:00

286 lines
13 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 { stripComments, 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",
"lib/dashboard/summary/build.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/medications-backup.ts": "backup of the recorded history",
"app/api/sync/state/route.ts": "sync watermarks, no rate",
"lib/sync/changes/read-rows.ts":
"delta feed mirroring the recorded rows to a paired client, 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",
"lib/medications/list-intake-events.ts":
"one medication's recorded history, listed",
"app/api/medications/[id]/intake/bulk-delete/route.ts":
"deletes rows the person chose",
"lib/medications/update-medication/write.ts":
"one medication's open slots, tombstoned on a schedule replace or the tracking switch",
"lib/medications/intake-bulk/ingest.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",
"lib/medications/ingredients/recompute.ts":
"sums the nutrients of doses that were taken; a recorded dose counts whatever the switch says",
};
/**
* 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/medications-backup.ts": "backup carries the stored rows",
"lib/export/medications-restore.ts": "restore writes the stored rows back",
// v1.44 — the CSV import writes new medications and reads only their names
// and doses to skip duplicates; it derives nothing due.
"lib/import/medications-csv.ts": "imports new rows, derives nothing due",
// v1.43 — the units a taken dose consumed, resolved per slot exactly as
// the inventory does. It reads only doses already recorded as taken.
"lib/medications/ingredients/recompute.ts":
"units per slot of doses already taken",
"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",
// The medication create and edit write the schedule rows and answer
// through `medicationDetail` (read-medication.ts), which shapes them with
// scheduleWireFields and derives the due state the switch allows.
"lib/medications/create-medication.ts":
"writes the schedules; answers through medicationDetail",
"lib/medications/update-medication/update-medication.ts":
"writes the schedules; answers through medicationDetail",
// The medication edit: the schedule count and the primary schedule's id
// for the reminder-window bridge, and the replaced rows archived as an era
// whose payload is empty for a stretch with tracking off. Nothing due comes
// out of either; the edit answers through medicationDetail.
"lib/medications/update-medication/preflight.ts":
"schedule count and the primary schedule's id, derives nothing due",
"lib/medications/update-medication/write.ts":
"archives the replaced schedule; the era payload follows the tracking switch",
// 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 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,
);
});
});