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:
Marc-André Bombeck committed 2026-06-28 13:58:43 +02:00
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");
});
});