mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(labs): cover the biomarker assessment, detail page, and marker delete/range
Add coverage for the new biomarker-assessment seam and the marker detail surface: - the generator serves today's cache, re-stamps unchanged readings without an LLM call, and regenerates only when the latest-reading fingerprint changes - the read-only endpoint returns the envelope, 422s a missing id, and honours the auth + insightStatus gates - the detail page paints the stat strip, the chart placeholder, and the assessment card - the biomarker resource route drops the marker with its readings in one userId-narrowed transaction and rejects an inverted target range Hold the input gate independent of the worker's forced run so an idle marker re-stamps cached text instead of regenerating, and list the new route on the non-Coach gate allowlist.
This commit is contained in:
1 parent
0790a96ca4
commit
f17131dbdf
6 files changed
+699
-2
No files matched your search
@@ -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<unknown>[]) => 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<unknown>[],
|
||||
) => 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<string, unknown>;
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// 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",
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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<Response>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<LabBiomarkerDetail biomarkerId={BIOMARKER_ID} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabBiomarkerDetail>", () => {
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user