From f251ae13f82e6e7dcb54672a7a07b4400012a60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 15:10:24 +0200 Subject: [PATCH] test(insights): cover the adherence-card screen and the hero-line envelope guard Compliance card: a dose-change imperative and a fabricated risk figure are withheld to the deterministic fallback (no model text persisted, updatedAt stays null), while a clean grounded summary still renders and persists. Hero lines: a well-formed, truncated, or fenced JSON envelope resolves to the deterministic lead/template for both the arrival reaction and the coach nudge; a plain-text line still ships verbatim. --- .../medication-compliance-status.test.ts | 95 +++++++++++++++++++ .../__tests__/hero-line-json-envelope.test.ts | 67 +++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/lib/jobs/__tests__/hero-line-json-envelope.test.ts diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 61aea6cd8..1e3a850b0 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -26,6 +26,7 @@ vi.mock("@/lib/medication-category", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; +import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks"; import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status"; const dayMs = 24 * 60 * 60 * 1000; @@ -218,6 +219,100 @@ describe("generateMedicationComplianceStatusForUser — cache-read skips a stub" }); }); +describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.20)", () => { + /** + * The medication-adherence card is the highest-leverage surface for the + * outbound screen — a dose-change imperative or a fabricated figure lands + * exactly here. v1.32.20 routes it through `finalizeStatusSummary`, so it now + * WITHHOLDS the same dose / risk / causal violations its seven sibling status + * cards do (previously it only withheld unparseable envelopes). WITHHOLD = + * serve the deterministic no-key line, persist no model text, `updatedAt` + * stays null; the per-medication placeholder rows still render. + */ + function setupCard(now: Date) { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medFixture(now), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + } + + it("withholds a dose-change imperative instead of rendering the raw prose", async () => { + const now = new Date(); + setupCard(now); + stubCompletion('{"summary":"Increase your dose to 10 mg next week."}'); + + const result = await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(result.summary).toBe(getNoKeyMedicationComplianceStatusText("en")); + expect(result.summary).not.toContain("10 mg"); + expect(result.summary).not.toContain("Increase"); + expect(result.updatedAt).toBeNull(); + // The per-medication placeholder rows still surface for the active med. + expect(result.medications).toHaveLength(1); + // No cache row ever persisted the screened model text. + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ) as { summary?: string }; + expect(details.summary ?? "").not.toContain("10 mg"); + } + }); + + it("withholds a fabricated risk figure instead of rendering the raw prose", async () => { + const now = new Date(); + setupCard(now); + stubCompletion( + '{"summary":"Your 10-year cardiovascular risk is about 14%."}', + ); + + const result = await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(result.summary).toBe(getNoKeyMedicationComplianceStatusText("en")); + expect(result.summary).not.toContain("14%"); + expect(result.updatedAt).toBeNull(); + expect(result.medications).toHaveLength(1); + }); + + it("still renders a clean grounded compliance summary", async () => { + const now = new Date(); + setupCard(now); + stubCompletion( + '{"summary":"Adherence held steady at 96% across the last 30 days."}', + ); + + const result = await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(result.summary).toBe( + "Adherence held steady at 96% across the last 30 days.", + ); + expect(result.updatedAt).not.toBeNull(); + expect(result.cached).toBe(false); + // The clean assessment was persisted (not the negative stub). + const persisted = vi + .mocked(prisma.auditLog.create) + .mock.calls.map( + (call) => + JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ) as { summary?: string }, + ) + .find((details) => (details.summary ?? "").includes("96%")); + expect(persisted).toBeTruthy(); + }); +}); + describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached summary", async () => { const now = new Date(); diff --git a/src/lib/jobs/__tests__/hero-line-json-envelope.test.ts b/src/lib/jobs/__tests__/hero-line-json-envelope.test.ts new file mode 100644 index 000000000..1204fc953 --- /dev/null +++ b/src/lib/jobs/__tests__/hero-line-json-envelope.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; + +// The sanitisers are pure, but their modules import the db client at load time. +// Stub it so the unit under test never reaches for a connection. +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { sanitiseReactionLine } from "@/lib/jobs/reaction-line"; +import { sanitiseAiBody } from "@/lib/jobs/coach-nudge-ai"; + +/** + * v1.32.20 — both background hero lines (the arrival reaction and the coach + * nudge body) now run the status cards' structured-envelope detection + * (`extractAssessmentSummary`). A hero line is one plain sentence; a brace-led + * or fenced `{"line":"…"}` reply, or a truncated envelope under the char cap, + * is a broken contract and must resolve to the deterministic lead/template + * rather than render raw JSON. Genuine prose still ships. + */ +describe("sanitiseReactionLine — JSON-envelope detection", () => { + it("rejects a well-formed JSON envelope so the deterministic lead stands", () => { + expect( + sanitiseReactionLine( + '{"line":"Nice work today, your numbers are holding steady."}', + "en", + ), + ).toBeNull(); + }); + + it("rejects a truncated brace-led envelope", () => { + expect(sanitiseReactionLine('{"line":"Nice work today', "en")).toBeNull(); + }); + + it("rejects a fenced JSON envelope", () => { + expect( + sanitiseReactionLine('```json\n{"line":"Steady week."}\n```', "en"), + ).toBeNull(); + }); + + it("still renders a plain-text line verbatim", () => { + expect( + sanitiseReactionLine( + "Nice work today, your numbers are holding steady.", + "en", + ), + ).toBe("Nice work today, your numbers are holding steady."); + }); +}); + +describe("sanitiseAiBody — JSON-envelope detection", () => { + it("rejects a well-formed JSON envelope so the template stands", () => { + expect( + sanitiseAiBody( + '{"body":"A gentle check-in on your routine this week."}', + "en", + ), + ).toBeNull(); + }); + + it("rejects a truncated brace-led envelope", () => { + expect(sanitiseAiBody('{"body":"A gentle check-in', "en")).toBeNull(); + }); + + it("still renders a plain-text body verbatim", () => { + expect( + sanitiseAiBody("A gentle check-in on your routine this week.", "en"), + ).toBe("A gentle check-in on your routine this week."); + }); +});