mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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.
1063 lines
41 KiB
TypeScript
1063 lines
41 KiB
TypeScript
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);
|
||
});
|
||
});
|