Files
HealthLog/src/lib/ai/coach/__tests__/snapshot.test.ts
T
Marc-André Bombeck 600498da12 perf(coach): share the heavy per-user reads across the tool-snapshot fan-out
The F1 coach tools each rebuild a snapshot with a distinct single-source
scope, so the 60s snapshot LRU (keyed on the source list) never shares their
reads. On a multi-metric turn the model can fan out up to six tools that each
fire `extractFeatures` (a user lookup plus a windowed measurement read plus the
all-time extremes) and a prefs `findUnique` concurrently — up to twelve heavy
reads against the shared Prisma pool at once.

Memoise both reads on the request-scoped event cache, keyed on userId (and
windowDays for the features read), so the fan-out collapses to one of each
within a request while staying correct across requests. Project the feature
extractor's measurement read to the six columns its consumers actually use
(type, value, measuredAt, sleepStage, source, deviceType) so the 200k-row-class
PULSE / glucose windows stop pulling every column. Outputs are unchanged.
2026-06-22 08:18:13 +02:00

1063 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
__resetCoachSnapshotCacheForTests,
buildCoachSnapshot,
} from "../snapshot";
import { eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
// v1.18.1 P4 — the illness context block reads active + recent episodes.
illnessEpisode: { findMany: vi.fn(async () => []) },
// v1.18.11 (#65) — the labs context block reads recent lab results.
labResult: { findMany: vi.fn(async () => []) },
},
}));
vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(),
}));
// v1.18.0 — the snapshot resolves the per-user module map at build start
// so a disabled data-domain module's data never enters the coach context.
// Mock the gate's `resolveModuleMap` so the default fixture is "all
// modules on" (the legacy behaviour) and individual tests can flip a
// single module off without standing up the real gate's DB reads.
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
const allModulesEnabled = (): Record<ModuleKey, boolean> =>
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record<
ModuleKey,
boolean
>;
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
// v1.18.0 — the coach cycle block now gates through
// `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back the
// mock with the same resolved map the tests already drive via
// `resolveModuleMap`, so flipping `cycle` off in a fixture also closes the
// cycle block (operator/user kill-switch parity) without a DB read.
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
const key = args[1];
const map = await (
resolveModuleMap as unknown as () => Promise<Record<ModuleKey, boolean>>
)();
return map[key] !== false;
}),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
typeof vi.fn
>;
// The rolling-profile memory block reads its own persisted sources
// (period narrative + band transitions); stub it out here so the
// query-count + cache assertions below stay scoped to the core snapshot
// reads. Its own assembly is covered in memory-snapshot.test.ts.
vi.mock("../memory-snapshot", () => ({
buildCoachMemoryBlock: vi.fn().mockResolvedValue(null),
}));
// v1.18.0 — the recovery composites (derived block + trajectory) are read
// through these builders. Stub them so the recovery-disable test can
// assert they are never invoked (the disabled module pays no read cost);
// their own assembly is covered in derived-snapshot.test.ts /
// trajectory-snapshot.test.ts.
vi.mock("../derived-snapshot", () => ({
buildDerivedSnapshotBlock: vi.fn().mockResolvedValue(null),
}));
vi.mock("../trajectory-snapshot", () => ({
buildTrajectorySnapshotBlock: vi.fn().mockResolvedValue(null),
}));
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
import {
reconstructNights,
sleepNeedMinutes,
} from "@/lib/insights/derived/sleep-score";
import { computeSleepRhythmFromNights } from "@/lib/insights/derived/sleep-rhythm";
const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
/**
* Helper: produce a measurement row N days before "now" at 09:00 UTC.
*/
function daysAgo(
n: number,
value: number,
type: string,
): {
type: string;
value: number;
measuredAt: Date;
} {
const ms = Date.now() - n * 24 * 60 * 60 * 1000;
const d = new Date(ms);
d.setUTCHours(9, 0, 0, 0);
return { type, value, measuredAt: d };
}
describe("buildCoachSnapshot", () => {
beforeEach(() => {
vi.clearAllMocks();
// v1.4.33 — `buildCoachSnapshot` now memoises results in-process
// for 60 s. Reset the cache between tests so each test sees its
// own freshly-mocked Prisma fixture.
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply
// per-user excludeMetrics. Default null = use legacy defaults.
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
// v1.18.0 — default fixture: every toggleable module enabled.
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
pulse: undefined,
mood: undefined,
});
});
it("returns a 'general'-only provenance when nothing is in the log", async () => {
const out = await buildCoachSnapshot("user-1");
expect(out.provenance.metrics).toContain("general");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("last30days");
});
it("includes day-level BP rows with weekday labels for the recent window", async () => {
featuresMock.mockResolvedValue({
bloodPressure: {
avgSys30: 138,
avgDia30: 85,
coverage: { count: 4 },
},
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 142, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 92, "BLOOD_PRESSURE_DIA"),
daysAgo(5, 130, "BLOOD_PRESSURE_SYS"),
daysAgo(5, 80, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
const recent = parsed.bloodPressure.timeline.recent as Array<{
date: string;
weekday: string;
sys: number;
dia: number;
}>;
expect(recent.length).toBe(2);
expect(recent[0]).toMatchObject({ sys: expect.any(Number) });
expect(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]).toContain(
recent[0].weekday,
);
expect(out.provenance.metrics).toContain("bp");
});
it("respects the scope.sources filter — excluded metrics drop out", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 138, coverage: { count: 4 } },
weight: { latest: 80, coverage: { count: 4 } },
});
const out = await buildCoachSnapshot("user-1", {
sources: ["weight"],
window: "last30days",
});
expect(out.provenance.metrics).toContain("weight");
expect(out.provenance.metrics).not.toContain("bp");
// Snapshot shouldn't mention BP either
expect(out.snapshotJson).not.toContain("bloodPressure");
});
it("respects the scope.window — last7days yields a tighter window", async () => {
featuresMock.mockResolvedValue({
pulse: { avg7: 70, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 70, "PULSE"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["pulse"],
window: "last7days",
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("last7days");
expect(parsed.pulse.timeline.recent.length).toBe(1);
});
it("respects the scope.window — lastYear flags the year-in-review window", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avg30Sys: 124, coverage: { count: 50 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(120, 124, "BLOOD_PRESSURE_SYS"),
daysAgo(120, 81, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["bp"],
window: "lastYear",
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("lastYear");
expect(out.provenance.windows).toContain("lastYear");
});
// v1.7.0 — the default source set now expands the legacy default
// clusters (cardio + body + mood + medication) instead of a flat
// five-source list. The legacy core sources (bp/weight/pulse/mood/
// compliance) are still present; the additive members (HRV, resting
// HR, body-composition, …) ride along but only surface a block when
// the user has rows for them. This is the documented additive
// default + PROMPT_VERSION bump, not strict legacy byte-parity.
it("expands the default clusters when no scope is provided", async () => {
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
const sources = parsed.scope.sources as string[];
for (const core of ["bp", "weight", "pulse", "mood", "compliance"]) {
expect(sources).toContain(core);
}
// Additive members from the cardio + body clusters.
expect(sources).toContain("hrv");
expect(sources).toContain("body_fat");
// Clusters that are OFF by default contribute no sources.
expect(sources).not.toContain("steps");
expect(sources).not.toContain("glucose");
expect(sources).not.toContain("workouts");
expect(parsed.scope.window).toBe("last30days");
});
// v1.4.36 W3 T2 — `medications` exclusion drops the GLP-1 weeklyContext
// block + the compliance source so no medication data reaches the
// prompt. Empty-data behaviour: when the user has no GLP-1 medication
// the block is absent anyway (this test only proves the toggle path).
it("omits weeklyContext.glp1 when excludeMetrics contains 'medications'", async () => {
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: { excludeMetrics: ["medications"] },
timezone: "Europe/Berlin",
});
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.weeklyContext).toBeUndefined();
expect(parsed.compliance).toBeUndefined();
expect(parsed.scope.sources).not.toContain("compliance");
});
// v1.17 W1c — the coach's headline adherence % must route through the
// SAME `calculateCompliance(...).rate` authority the medication card
// shows, never a per-day / per-week denominator of its own. A daily med
// with 7 of 10 expected doses taken reads 70 % on the card; the coach
// snapshot must surface the identical figure on `compliance.rate`.
it("surfaces a headline compliance rate equal to calculateCompliance().rate", async () => {
const { calculateCompliance, buildComplianceMedicationContext } =
await import("@/lib/analytics/compliance");
const dayMs = 24 * 60 * 60 * 1000;
const createdAt = new Date(Date.now() - 40 * dayMs);
// Ten daily doses 1..10 days ago at 08:00 UTC; the 3 most recent
// confirmed-late and the rest taken on time, except days 4/7/9 missed.
const schedule = {
id: "sched-1",
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: null,
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const missedDays = new Set([4, 7, 9]);
const intakeEvents = [];
for (let n = 1; n <= 10; n++) {
const scheduledFor = new Date(Date.now() - n * dayMs);
scheduledFor.setUTCHours(8, 0, 0, 0);
const missed = missedDays.has(n);
intakeEvents.push({
scheduledFor,
takenAt: missed ? null : scheduledFor,
skipped: false,
autoMissed: missed,
});
}
const medication = {
id: "med-1",
name: "Testdrug",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
schedules: [schedule],
scheduleRevisions: [],
intakeEvents,
};
// The compliance query and the GLP-1 query share this mock; only the
// compliance one (no `treatmentClass` filter) gets the fixture so the
// GLP-1 block — which selects `doseChanges` etc. — sees no rows.
prismaMock.medication.findMany.mockImplementation(
(args?: { where?: { treatmentClass?: string } }) => {
if (args?.where?.treatmentClass === "GLP1") return Promise.resolve([]);
return Promise.resolve([medication]);
},
);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "Europe/Berlin",
});
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.compliance).toBeDefined();
expect(parsed.compliance.rate).not.toBeNull();
// Independent authority over the same window the coach uses (30-day
// default scope), same ledger context — the coach rate must equal it.
const now = new Date();
const ctx = buildComplianceMedicationContext(
medication,
null,
"Europe/Berlin",
);
const authority = calculateCompliance(
intakeEvents,
[schedule],
30,
createdAt,
{ now, medicationContext: ctx },
);
expect(parsed.compliance.rate).toBe(authority.rate);
});
// v1.4.36 W3 T2 — `anthropometrics` exclusion drops the profile
// block even when features.context has populated fields.
it("omits anthropometrics when excludeMetrics contains 'anthropometrics'", async () => {
featuresMock.mockResolvedValue({
context: { heightCm: 180, ageYears: 45, gender: "MALE" },
});
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: { excludeMetrics: ["anthropometrics"] },
timezone: "Europe/Berlin",
});
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.anthropometrics).toBeUndefined();
});
// v1.4.36 W3 T2 — anthropometrics block is added when features.context
// has at least one non-null field AND the exclusion is off.
it("includes anthropometrics when context has data and exclusion is off", async () => {
featuresMock.mockResolvedValue({
context: { heightCm: 180, ageYears: 45, gender: "MALE" },
});
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.anthropometrics).toEqual({
heightCm: 180,
ageYears: 45,
gender: "MALE",
});
});
// v1.4.36 W3 T2 — empty-block omit: when every anthropometric field
// is null the block is dropped entirely, not emitted as a labelled
// null-trio that would render as `Hier sind die Profildaten: [keine]`
// in the eventual prompt.
it("drops anthropometrics when every field is null", async () => {
featuresMock.mockResolvedValue({
context: { heightCm: null, ageYears: null, gender: null },
});
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.anthropometrics).toBeUndefined();
});
it("scope-only-mood pulls just mood data, no measurements query", async () => {
featuresMock.mockResolvedValue({
mood: { avg30: 4.2, coverage: { count: 12 } },
});
prismaMock.moodEntry.findMany.mockResolvedValue([
{
moodLoggedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
score: 4,
},
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["mood"],
});
expect(prismaMock.measurement.findMany).not.toHaveBeenCalled();
expect(out.provenance.metrics).toContain("mood");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.mood.timeline.recent.length).toBe(1);
});
// v1.4.33 — 60 s in-process snapshot cache. A chat conversation
// sends 2-4 turns within a minute and rebuilding the snapshot from
// ~10 measurement reads on every turn is wasteful. Cache hits skip
// every persistent read; cache misses on a different scope (window
// or sources) compute fresh.
it("memoises the result for repeated (userId, scope) within the 60 s window", async () => {
featuresMock.mockResolvedValue({
weight: { avg30: 82.1, coverage: { count: 5 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 82.0, "WEIGHT"),
daysAgo(5, 82.4, "WEIGHT"),
]);
const first = await buildCoachSnapshot("user-1", { sources: ["weight"] });
const second = await buildCoachSnapshot("user-1", { sources: ["weight"] });
// Same JSON shape on both calls.
expect(second.snapshotJson).toBe(first.snapshotJson);
// Prisma reads ran once for the first call; the second call short-
// circuits on the cache so the count is still 1.
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(1);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1);
});
it("recomputes when the scope window or sources change", async () => {
featuresMock.mockResolvedValue({
weight: { avg30: 82.1, coverage: { count: 5 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(3, 82.0, "WEIGHT"),
]);
await buildCoachSnapshot("user-1", {
window: "last7days",
sources: ["weight"],
});
await buildCoachSnapshot("user-1", {
window: "last30days",
sources: ["weight"],
});
// Two distinct window keys → two cache slots → two Prisma reads.
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2);
});
// ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
//
// The coach reads the SAME computed values the Sleep page + dashboard
// show: it hands the canonical per-night reconstruction to the SAME
// assembler (`computeSleepRhythmFromNights`) the dashboard route uses,
// never recomputing sleep-debt or chronotype inline. These tests pin
// the reuse contract + the learning-gate honesty.
/**
* One night's bare-ASLEEP block, wake instant in UTC. `reconstructNights`
* reads asleep = `asleepMinutes` and a midpoint at wake − asleep/2 — the
* same fixture shape the sleep-rhythm unit suite uses.
*/
function nightRow(wakeIso: string, asleepMinutes: number) {
return {
type: "SLEEP_DURATION",
value: asleepMinutes,
measuredAt: new Date(wakeIso),
sleepStage: "ASLEEP" as const,
source: null,
deviceType: null,
};
}
it("emits a ready sleepRhythm block equal to the assembler's DTO", async () => {
// 14 consecutive nights across two weekends → debt ready (≥7 nights)
// and chronotype ready (≥3 free/weekend nights). 2026-06-01 is a Monday.
const rows = Array.from({ length: 14 }, (_, i) => {
const day = 1 + i; // 2026-06-01 .. 2026-06-14
const dd = String(day).padStart(2, "0");
return nightRow(`2026-06-${dd}T06:00:00Z`, 360 + (i % 3) * 20);
});
prismaMock.measurement.findMany.mockResolvedValue(rows);
featuresMock.mockResolvedValue({
context: { heightCm: null, ageYears: 40, gender: null },
});
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "UTC",
});
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
const parsed = JSON.parse(out.snapshotJson);
// Independently run the SAME engine over the SAME rows + need — the
// snapshot must equal it field-for-field (reuse, never recompute).
const expected = computeSleepRhythmFromNights(
reconstructNights(rows, "UTC", null),
sleepNeedMinutes(40),
);
expect(expected.sleepDebt.state).toBe("ready");
expect(expected.chronotype.state).toBe("ready");
expect(expected.chronotype.band).not.toBeNull();
expect(parsed.sleepRhythm.sleepDebt).toEqual({
state: expected.sleepDebt.state,
debtMinutes: expected.sleepDebt.debtMinutes,
needMinutes: expected.sleepDebt.needMinutes,
});
expect(parsed.sleepRhythm.chronotype).toEqual({
state: expected.chronotype.state,
band: expected.chronotype.band,
socialJetlagMinutes: expected.chronotype.socialJetlagMinutes,
});
});
it("never asserts a band for a learning chronotype", async () => {
// 8 weekday nights (2026-06-15 Mon .. 2026-06-22 Mon, weekends 06-20/21
// are the only free days) — enough for debt to be ready but the
// chronotype stays learning until it has ≥3 free-day nights. With only
// two weekend nights here, the band must NOT be asserted.
const weekdayRows = [15, 16, 17, 18, 19, 22].map((d) =>
nightRow(`2026-06-${String(d).padStart(2, "0")}T06:00:00Z`, 380),
);
prismaMock.measurement.findMany.mockResolvedValue(weekdayRows);
featuresMock.mockResolvedValue({
context: { heightCm: null, ageYears: 40, gender: null },
});
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "UTC",
});
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
const parsed = JSON.parse(out.snapshotJson);
const expected = computeSleepRhythmFromNights(
reconstructNights(weekdayRows, "UTC", null),
sleepNeedMinutes(40),
);
expect(expected.chronotype.state).toBe("learning");
expect(parsed.sleepRhythm.chronotype.state).toBe("learning");
// A learning chronotype carries no band the data supports.
expect(parsed.sleepRhythm.chronotype.band).toBeNull();
expect(parsed.sleepRhythm.chronotype.socialJetlagMinutes).toBeNull();
});
it("omits the sleepRhythm block when no sleep rows exist", async () => {
prismaMock.measurement.findMany.mockResolvedValue([]);
featuresMock.mockResolvedValue({
context: { heightCm: null, ageYears: 40, gender: null },
});
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "UTC",
});
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.sleepRhythm).toBeUndefined();
});
// ── v1.18.0 module enable/disable — disabled domains never reach the
// coach context ───────────────────────────────────────────────────────
//
// When a toggleable data-domain module is disabled for the account, the
// snapshot must exclude that domain entirely — the source drops from
// `scope.sources`, no block is emitted, and the provenance metric is
// absent. The gate's resolved map is authoritative (delegations already
// resolved), so the snapshot folds it into the same SYSTEM-side
// exclusion the user's `excludeMetrics` flow already drives.
it("drops the mood domain from the snapshot when the mood module is disabled", async () => {
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
mood: false,
});
featuresMock.mockResolvedValue({
mood: { avg30: 4.2, coverage: { count: 12 } },
});
prismaMock.moodEntry.findMany.mockResolvedValue([
{
moodLoggedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
score: 4,
},
]);
const out = await buildCoachSnapshot("user-1", { sources: ["mood"] });
const parsed = JSON.parse(out.snapshotJson);
// No mood block, no mood source in scope, no mood metric in provenance.
expect(parsed.mood).toBeUndefined();
expect(parsed.scope.sources).not.toContain("mood");
expect(out.provenance.metrics).not.toContain("mood");
// The disabled domain's table is never even read.
expect(prismaMock.moodEntry.findMany).not.toHaveBeenCalled();
});
it("drops the glucose domain when the glucose module is disabled", async () => {
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
glucose: false,
});
featuresMock.mockResolvedValue({});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 105, "BLOOD_GLUCOSE"),
daysAgo(4, 98, "BLOOD_GLUCOSE"),
]);
const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.glucose).toBeUndefined();
expect(parsed.scope.sources).not.toContain("glucose");
expect(out.provenance.metrics).not.toContain("glucose");
});
it("drops the sleep + sleepRhythm blocks when the sleep module is disabled", async () => {
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
sleep: false,
});
// 14 nights that would otherwise build both the sleep block and a
// ready sleepRhythm DTO.
const rows = Array.from({ length: 14 }, (_, i) => {
const dd = String(1 + i).padStart(2, "0");
return {
type: "SLEEP_DURATION",
value: 360 + (i % 3) * 20,
measuredAt: new Date(`2026-06-${dd}T06:00:00Z`),
sleepStage: "ASLEEP" as const,
source: null,
deviceType: null,
};
});
prismaMock.measurement.findMany.mockResolvedValue(rows);
featuresMock.mockResolvedValue({
context: { heightCm: null, ageYears: 40, gender: null },
});
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "UTC",
});
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.sleep).toBeUndefined();
expect(parsed.sleepRhythm).toBeUndefined();
expect(parsed.scope.sources).not.toContain("sleep");
expect(out.provenance.metrics).not.toContain("sleep");
});
it("drops the recovery composites when the recovery module is disabled, even with sleep on", async () => {
// Recovery off but sleep on. `derivedActive` reads the sleep signal,
// so without an explicit recovery gate the derived / dayStrain /
// trajectory blocks would still build off sleep alone.
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
recovery: false,
});
const { buildDerivedSnapshotBlock } = await import("../derived-snapshot");
const { buildTrajectorySnapshotBlock } =
await import("../trajectory-snapshot");
prismaMock.measurement.findMany.mockResolvedValue([]);
featuresMock.mockResolvedValue({
context: { heightCm: 180, ageYears: 40, gender: "MALE" },
});
const out = await buildCoachSnapshot("user-1", {
sources: ["sleep", "hrv", "resting_hr", "vo2_max"],
});
const parsed = JSON.parse(out.snapshotJson);
// No recovery composites reach the prompt.
expect(parsed.derived).toBeUndefined();
expect(parsed.dayStrain).toBeUndefined();
expect(parsed.trajectory).toBeUndefined();
// The recovery source tokens are stripped from scope.
for (const src of ["hrv", "resting_hr", "vo2_max"]) {
expect(parsed.scope.sources).not.toContain(src);
}
// The derived / trajectory readers are never even called — the
// disabled module pays no read cost.
expect(buildDerivedSnapshotBlock).not.toHaveBeenCalled();
expect(buildTrajectorySnapshotBlock).not.toHaveBeenCalled();
});
it("keeps an enabled domain present while a sibling module is disabled", async () => {
// glucose off, weight on — weight must still flow through.
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
glucose: false,
});
featuresMock.mockResolvedValue({
weight: { latest: 80, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 80.0, "WEIGHT"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["weight", "glucose"],
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.weight).toBeDefined();
expect(parsed.scope.sources).toContain("weight");
expect(out.provenance.metrics).toContain("weight");
expect(parsed.scope.sources).not.toContain("glucose");
});
});
// ────────────────────────────────────────────────────────────────────
// v1.4.43 W13 L-2 — snapshot free-text regression guard.
//
// Every snapshot builder is allowed to ship arbitrary structured data
// (numbers, booleans, ISO dates, enum strings) into the SNAPSHOT JSON
// that prefixes the Coach userPrompt. Free-text fields (medication name,
// dose unit, note bodies, free-form descriptions) MUST wrap through
// `sanitizeForPrompt` first — otherwise a user-controlled string
// containing "SYSTEM:" / "---END---" / control sequences would bleed
// into the prompt and could override the patient-safety guardrails.
//
// This regression guard scans the snapshot-builder source files and
// fails when a recognised free-text field name is assigned a value that
// doesn't include `sanitizeForPrompt`. Cheap, deterministic, no false
// positives on numeric / boolean / date assignments (those don't trip
// the heuristic because the field-name allow-list is the only trigger).
//
// Adding a new free-text field name to the heuristic is a one-row edit.
// ────────────────────────────────────────────────────────────────────
const SNAPSHOT_BUILDER_FILES = [
"src/lib/ai/coach/glp1-snapshot.ts",
"src/lib/insights/blood-pressure-status.ts",
"src/lib/insights/medication-compliance-status.ts",
"src/lib/insights/glp1-plateau.ts",
];
/**
* Field names that consistently mean "free-text the user typed". This
* list is conservative — date / count / value / unit-numeric fields
* never appear here. Adding a new free-text name is the documented
* extension hook.
*/
const FREE_TEXT_FIELD_NAMES = [
"name",
"note",
"notes",
"description",
"comment",
"drug",
"doseUnit",
"dose",
] as const;
/**
* Walk the file looking for `name: <expr>` style property assignments
* where the key matches a free-text field. For each hit, capture the
* full single-line `name: …,` or up to the next comma at the same
* brace-depth. Any hit must contain the literal token `sanitizeForPrompt`
* somewhere in its value expression — OR the file must demonstrably
* import + use `sanitizeForPrompt` for the same source identifier
* elsewhere (the GLP-1-plateau context case: production builds a raw
* struct, the consumer prompt-builder wraps).
*
* Pragmatically: a new snapshot builder that forgets to import the
* sanitiser fails immediately. An existing file that derives an
* intermediate context and sanitises at consumption stays green —
* because the audit-row contract (v1.4.43 W13 L-2) is about preventing
* an un-sanitised field from reaching the prompt, and a file-level
* sanitisation pattern provably catches that.
*/
function findUnsanitisedFreeTextAssignments(
filePath: string,
): { fieldName: string; line: number; snippet: string }[] {
const source = readFileSync(resolve(process.cwd(), filePath), "utf-8");
const lines = source.split("\n");
const violations: { fieldName: string; line: number; snippet: string }[] = [];
// File-level escape hatch: any file that imports + uses
// `sanitizeForPrompt` for the free-text fields is considered safe.
// The check only fires on a NEW file (added without the import) or
// on a file that uses sanitise nowhere — both meaningful regression
// signals.
const fileSanitisesSomewhere = /sanitizeForPrompt\s*\(/.test(source);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const field of FREE_TEXT_FIELD_NAMES) {
// Match `<field>:` as a property key. Anchored after `{` / `(`
// / `,` / start-of-line whitespace so we don't trip on words
// inside string literals or identifiers.
const re = new RegExp(`(?:^|[\\s,{(])${field}\\s*:`);
if (!re.test(line)) continue;
// The value can extend to the next comma at the same depth or
// to the next line. Capture the trailing portion of the current
// line plus up to two lookahead lines as the "value expression".
const valueExpr = [line, lines[i + 1] ?? "", lines[i + 2] ?? ""].join(
"\n",
);
// Numeric / boolean / null / undefined assignments don't need
// sanitisation. They originate inside the server (counts,
// slopes, ids) or are already-bounded enum strings.
const numericOrBoolean =
/:\s*(?:-?\d|true\b|false\b|null\b|undefined\b)/.test(line);
if (numericOrBoolean) continue;
// String literals / template literals built entirely from
// server-controlled tokens are accepted; the heuristic only
// demands `sanitizeForPrompt` when the value reads from a
// user-controlled identifier (Identifier or Member-Expression
// ending in `name|note|description|doseUnit|drug|dose`).
const referencesFreeTextSource = new RegExp(
`\\.(${FREE_TEXT_FIELD_NAMES.join("|")})\\b`,
).test(valueExpr);
if (!referencesFreeTextSource) continue;
if (valueExpr.includes("sanitizeForPrompt")) continue;
// File-level pass: the file calls sanitizeForPrompt at least
// once, so the free-text value is wrapped at the consumer.
if (fileSanitisesSomewhere) continue;
violations.push({
fieldName: field,
line: i + 1,
snippet: line.trim(),
});
}
}
return violations;
}
describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L-2)", () => {
for (const filePath of SNAPSHOT_BUILDER_FILES) {
it(`${filePath} — every free-text assignment routes through sanitizeForPrompt`, () => {
const violations = findUnsanitisedFreeTextAssignments(filePath);
if (violations.length > 0) {
const formatted = violations
.map(
(v) => ` ${filePath}:${v.line} — \`${v.fieldName}\`: ${v.snippet}`,
)
.join("\n");
throw new Error(
`Snapshot builder leaks an un-sanitised free-text field into the Coach prompt:\n${formatted}\n` +
`Wrap the value via \`sanitizeForPrompt(value, maxLen)\` from \`@/lib/insights/sanitize\`.`,
);
}
expect(violations).toEqual([]);
});
}
it("the guard itself trips on an obvious leak (sanity check)", () => {
// Sanity check — the heuristic must flag a synthetic snapshot
// builder that reads from a user-controlled identifier without the
// wrap AND has no sanitiser anywhere in the file. This is the
// "new builder forgot the import" failure mode.
const synthetic = `
// synthetic snapshot builder — no sanitiser imported anywhere.
export function buildBlock(med) {
return {
name: med.name,
dose: med.dose,
};
}
`;
const lines = synthetic.split("\n");
const fileSanitisesSomewhere = /sanitizeForPrompt\s*\(/.test(synthetic);
let hit = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const valueExpr = [line, lines[i + 1] ?? "", lines[i + 2] ?? ""].join(
"\n",
);
if (
/(?:^|[\s,{(])name\s*:/.test(line) &&
/\.name\b/.test(valueExpr) &&
!valueExpr.includes("sanitizeForPrompt") &&
!fileSanitisesSomewhere
) {
hit = true;
}
}
expect(hit).toBe(true);
});
});
// v1.18.6 (W7) — the citation-aware reference-range grounding rides through
// buildCoachSnapshot for the metrics present, gated on the W6 diabetes opt-in.
describe("buildCoachSnapshot — reference grounding (W7)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
featuresMock.mockResolvedValue({});
});
it("attaches an ESH-2023-cited grounding block when blood pressure is present", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 138, avgDia30: 85, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 138, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 85, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1", { sources: ["bp"] });
expect(out.referenceGrounding).not.toBeNull();
expect(out.referenceGrounding).toContain("REFERENCE GROUNDING");
expect(out.referenceGrounding).toContain("ESH 2023");
// 138 sits in the high-normal band (130–139), two tiers past optimal →
// the four-state contract reads that as "outside".
expect(out.referenceGrounding).toContain(
"sits outside the general reference band",
);
expect(out.referenceGrounding).toContain("not a diagnosis");
});
it("returns null grounding when no present metric is covered", async () => {
const out = await buildCoachSnapshot("user-1");
// Empty snapshot — no reference-covered scalar collected.
expect(out.referenceGrounding).toBeNull();
});
it("uses the general non-diabetic glucose band when hasDiabetes is unset", async () => {
featuresMock.mockResolvedValue({});
prismaMock.measurement.findMany.mockResolvedValue([
{ ...daysAgo(2, 95, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
]);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
hasDiabetes: false,
});
const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
expect(out.referenceGrounding).toContain("general non-diabetic normal");
expect(out.referenceGrounding).not.toContain("management goal");
});
it("uses the tighter ADA goal band when hasDiabetes is opted in", async () => {
featuresMock.mockResolvedValue({});
prismaMock.measurement.findMany.mockResolvedValue([
{ ...daysAgo(2, 150, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
]);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
hasDiabetes: true,
});
const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
expect(out.referenceGrounding).toContain("80–130 mg/dL");
expect(out.referenceGrounding).toContain("management goal");
// 150 fasting is outside the diabetic goal band.
expect(out.referenceGrounding).toContain(
"outside the typical diabetes management goal",
);
});
it("never emits a commercial brand token in the grounding block", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 120, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 120, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 78, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1", { sources: ["bp"] });
for (const brand of ["Mounjaro", "Withings", "WHOOP", "Oura", "Dexcom"]) {
expect(out.referenceGrounding!.toLowerCase()).not.toContain(
brand.toLowerCase(),
);
}
});
});
describe("buildCoachSnapshot request-scoped read sharing (H-1)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
pulse: undefined,
mood: undefined,
});
});
it("runs extractFeatures + the prefs read once across distinct-scope tool builds in one request", async () => {
// The F1 coach tools each call buildCoachSnapshot with a DIFFERENT
// single-source scope, so the 60s snapshot LRU (keyed on the source list)
// does NOT share their reads. Memoising the two heavy per-user reads on the
// request-scoped WideEventBuilder cache collapses the fan-out to one of each.
await eventStorage.run(new WideEventBuilder(), async () => {
// Same window (→ same windowDays key), distinct sources (→ distinct LRU
// keys ⇒ two separate snapshot builds, mirroring the real tool fan-out).
await Promise.all([
buildCoachSnapshot("user-h1", {
sources: ["bp"],
window: "last30days",
}),
buildCoachSnapshot("user-h1", {
sources: ["pulse"],
window: "last30days",
}),
]);
});
expect(featuresMock).toHaveBeenCalledTimes(1);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1);
});
it("keeps separate caches per request (no cross-request leakage)", async () => {
// Distinct users so the in-process 60s snapshot LRU (keyed on userId) cannot
// short-circuit the second request — the only thing under test here is that
// the request-scoped feature/prefs cache does NOT survive across requests.
await eventStorage.run(new WideEventBuilder(), async () => {
await buildCoachSnapshot("user-h1a", {
sources: ["bp"],
window: "last30days",
});
});
await eventStorage.run(new WideEventBuilder(), async () => {
await buildCoachSnapshot("user-h1b", {
sources: ["bp"],
window: "last30days",
});
});
expect(featuresMock).toHaveBeenCalledTimes(2);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(2);
});
});