diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..bb3f0a75d --- /dev/null +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }, + labResult: { deleteMany: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/labs/biomarker-store", () => ({ + encryptContextToBytes: vi.fn(() => new Uint8Array([1])), + decryptContextFromBytes: vi.fn(() => "ctx"), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT, DELETE } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const EXISTING = { + id: "bm-1", + userId: "user-1", + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hidden: false, + contextEncrypted: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function putReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "PUT", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +function delReq(): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation((async ( + ops: Promise[], + ) => Promise.all(ops)) as never); +}); + +describe("DELETE /api/biomarkers/[id]", () => { + it("drops the marker and its readings in one userId-narrowed transaction", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({ + count: 3, + } as never); + vi.mocked(prisma.biomarker.delete).mockResolvedValue(EXISTING as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } | null }; + expect(body.data?.deleted).toBe(true); + + expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1", biomarkerId: "bm-1" }, + }); + expect(prisma.biomarker.delete).toHaveBeenCalledWith({ + where: { id: "bm-1" }, + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it("404s a cross-user marker without deleting anything", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({ + ...EXISTING, + userId: "other-user", + } as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(404); + expect(prisma.labResult.deleteMany).not.toHaveBeenCalled(); + expect(prisma.biomarker.delete).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/biomarkers/[id] — target range", () => { + it("persists an updated reference window", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.biomarker.update).mockResolvedValue({ + ...EXISTING, + lowerBound: 10, + upperBound: 50, + } as never); + + const res = await PUT( + putReq({ lowerBound: 10, upperBound: 50 }), + params("bm-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { lowerBound: number; upperBound: number } | null; + }; + expect(body.data?.lowerBound).toBe(10); + expect(body.data?.upperBound).toBe(50); + + const updateCall = vi.mocked(prisma.biomarker.update).mock.calls[0][0] as { + data: Record; + }; + expect(updateCall.data.lowerBound).toBe(10); + expect(updateCall.data.upperBound).toBe(50); + }); + + it("422s an inverted target range without updating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + + const res = await PUT( + putReq({ lowerBound: 80, upperBound: 20 }), + params("bm-1"), + ); + expect(res.status).toBe(422); + expect(prisma.biomarker.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 939ff22f8..c21fb2bd9 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest"; const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // Non-Coach assistant surfaces — gated on a sibling sub-flag. + // Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as + // the metric-status + specialised status routes (no Coach prose). + "src/app/api/insights/biomarker-assessment/route.ts", "src/app/api/insights/blood-pressure-status/route.ts", "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts new file mode 100644 index 000000000..49f419399 --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +// The route resolves the `insights` module gate after `requireAuth()`. Mock +// it default-enabled so the assertions ride through; the off → 403 coverage +// lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated from the real +// status pipeline. +vi.mock("@/lib/insights/biomarker-status", () => ({ + generateBiomarkerStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), +})); + +vi.mock("@/lib/insights/metric-status", () => ({ + resolveMetricStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(biomarkerId?: string): NextRequest { + const url = new URL("http://localhost/api/insights/biomarker-assessment"); + if (biomarkerId !== undefined) + url.searchParams.set("biomarkerId", biomarkerId); + return new NextRequest(url); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("GET /api/insights/biomarker-assessment", () => { + it("returns 200 + envelope and calls the generator read-only", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { text: string; hasProvider: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.text).toBe("ok"); + expect(generateBiomarkerStatus).toHaveBeenCalledWith( + expect.objectContaining({ + biomarkerId: "bm-1", + userId: "user-1", + readOnly: true, + }), + ); + }); + + it("422s on a missing biomarkerId without calling the generator", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(generateBiomarkerStatus).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(401); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); +}); diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx new file mode 100644 index 000000000..8c6b6ab0b --- /dev/null +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { + BiomarkerDto, + LabResultDto, + LabResultListResponse, +} from "../types"; + +/** + * The per-biomarker detail page mirrors the metric sub-pages: a numbers-first + * stat strip over the numeric readings, the (lazy) chart, the history, and the + * AI assessment card. This test seeds the marker + reading queries and asserts + * the strip, the chart placeholder, and the assessment card all paint. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiDelete: vi.fn(), + apiPut: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), +})); + +vi.mock("@/hooks/use-feature-flags", () => ({ + useFeatureFlags: () => ({ insightStatus: true }), +})); + +import { LabBiomarkerDetail } from "../lab-biomarker-detail"; + +const BIOMARKER_ID = "bm-1"; + +const MARKER: BiomarkerDto = { + id: BIOMARKER_ID, + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hasContext: false, + context: null, + hidden: false, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", +}; + +function reading(id: string, value: number, takenAt: string): LabResultDto { + return { + id, + biomarkerId: BIOMARKER_ID, + panel: null, + analyte: "LDL Cholesterol", + value, + valueText: null, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 100, + takenAt, + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: takenAt, + updatedAt: takenAt, + }; +} + +const LIST: LabResultListResponse = { + results: [ + reading("r3", 95, "2026-06-20T08:00:00.000Z"), + reading("r2", 88, "2026-05-20T08:00:00.000Z"), + reading("r1", 102, "2026-04-20T08:00:00.000Z"), + ], + meta: { total: 3, limit: 500, offset: 0 }, +}; + +function renderWithCache() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER); + queryClient.setQueryData( + queryKeys.labResultsList({ + biomarkerId: BIOMARKER_ID, + analyte: undefined, + panel: undefined, + from: undefined, + to: undefined, + page: 0, + sortDir: "desc", + }), + LIST, + ); + queryClient.setQueryData( + queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"), + { + hasProvider: true, + text: "Your LDL is steady inside the reference range.", + cached: true, + updatedAt: "2026-06-20T09:00:00.000Z", + }, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + const html = renderWithCache(); + + it("paints the marker heading", () => { + expect(html).toContain("LDL Cholesterol"); + }); + + it("paints the numbers-first stat strip", () => { + expect(html).toContain('data-slot="metric-stat-strip"'); + }); + + it("paints the chart placeholder (lazy chart shell)", () => { + // The recharts chart is `next/dynamic({ ssr: false })`; the server render + // paints the layout-stable skeleton shell in its place. + expect(html).toContain('data-slot="skeleton"'); + }); + + it("paints the assessment card", () => { + // useMounted() is false during SSR, so the card renders its loading + // skeleton geometry — proof the assessment card mounted on the page. + expect(html).toContain('data-testid="insight-status-card-loading"'); + }); +}); diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts new file mode 100644 index 000000000..1c677c606 --- /dev/null +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn() }, + labResult: { findMany: vi.fn(), count: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + generateBiomarkerStatus, + biomarkerStatusScope, +} from "../biomarker-status"; + +const MARKER = { + id: "bm-1", + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, +}; + +const TAKEN_AT = new Date("2026-06-20T08:00:00.000Z"); + +function stubCompletion(content: string, capture?: { userPrompt: string }) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +/** The fingerprint the generator computes for a single-reading marker. */ +function inputHashFor(reading: { + id: string; + value: number; + takenAt: Date; +}): string { + return hashInsightSnapshot({ + scope: biomarkerStatusScope(MARKER.id), + locale: "en", + promptVersion: PROMPT_VERSION, + count: 1, + bounds: { lower: MARKER.lowerBound, upper: MARKER.upperBound }, + latest: { + id: reading.id, + takenAt: reading.takenAt.toISOString(), + value: reading.value, + }, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date("2026-06-20T09:00:00.000Z"), + } as never); +}); + +describe("generateBiomarkerStatus — empty-data guard", () => { + it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(result.text).toBeNull(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.labResult.count).not.toHaveBeenCalled(); + }); + + it("returns insufficient for an unknown / cross-user marker", async () => { + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(null as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: "nope", + userId: "u1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(runStatusCompletion).not.toHaveBeenCalled(); + }); +}); + +describe("generateBiomarkerStatus — cache read", () => { + it("serves today's cached text without calling the provider", async () => { + const todayKey = toBerlinDayKey(new Date()); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Your LDL is steady.", + model: "x", + }), + } as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(result.cached).toBe(true); + expect(result.text).toBe("Your LDL is steady."); + expect(runStatusCompletion).not.toHaveBeenCalled(); + // Cache hit short-circuits before the reading read. + expect(prisma.labResult.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("generateBiomarkerStatus — generation path", () => { + it("builds a snapshot, runs the completion, and persists with an inputHash", async () => { + const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + const capture = { userPrompt: "" }; + stubCompletion( + '{"summary":"Your LDL sits inside the reference range."}', + capture, + ); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Your LDL sits inside the reference range."); + expect(result.cached).toBe(false); + expect(result.hasProvider).toBe(true); + + // The snapshot carries the marker metadata + the latest reading. + const match = capture.userPrompt.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + expect(snapshot.marker.name).toBe("LDL Cholesterol"); + expect(snapshot.marker.unit).toBe("mg/dL"); + expect(snapshot.latest.value).toBe(95); + + // Persisted under the biomarker scope cache action, carrying the inputHash. + const createCall = vi + .mocked(prisma.auditLog.create) + .mock.calls.at(-1)![0] as { + data: { action: string; details: string }; + }; + expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en"); + const persisted = JSON.parse(createCall.data.details); + expect(persisted.inputHash).toBe(inputHashFor(reading)); + }); +}); + +describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => { + it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => { + const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + // A prior assessment from an earlier day whose inputHash matches the + // current latest reading — readFreshStatusText misses on the day key, but + // the input gate matches the fingerprint. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: "2000-01-01", + locale: "en", + text: "Stable LDL from a previous reading.", + model: "x", + inputHash: inputHashFor(reading), + }), + } as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.cached).toBe(true); + expect(result.text).toBe("Stable LDL from a previous reading."); + }); + + it("regenerates when a new reading lands (inputHash differs)", async () => { + const oldReading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + const newReading = { + id: "r2", + value: 130, + takenAt: new Date("2026-06-25T08:00:00.000Z"), + }; + // The cached row's inputHash is for the OLD reading; the live latest is the + // new reading, so the fingerprint differs and the gate misses. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: "2000-01-01", + locale: "en", + text: "Stale assessment.", + model: "x", + inputHash: inputHashFor(oldReading), + }), + } as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + newReading, + ] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + stubCompletion('{"summary":"LDL rose above the reference range."}'); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("LDL rose above the reference range."); + expect(result.cached).toBe(false); + }); +}); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index e39e547ed..39e867291 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -231,7 +231,15 @@ export async function generateBiomarkerStatus(args: { // stored `inputHash`, nothing the prompt could see has changed since the // last reading, so re-stamp that text under today's day key and skip the // LLM entirely. A new reading flips the latest id / value / count and - // forces a real regeneration. + // forces a real regeneration — so the assessment regenerates ONLY on a new + // reading. + // + // The gate runs with `force: false` even on the worker's forced path: the + // outer `force` exists to bypass the SAME-DAY text cache (so a fresh day + // re-evaluates the fingerprint), not to burn an LLM call on an unchanged + // marker. The worker is re-enqueued whenever today's text cache misses, so + // letting the input gate run keeps an idle marker to one cheap re-stamp per + // day instead of a daily regeneration. const inputHash = hashInsightSnapshot({ scope, locale, @@ -249,7 +257,7 @@ export async function generateBiomarkerStatus(args: { cacheAction, todayKey, inputHash, - force, + force: false, }); if (unchanged) { return {