mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'feat/biomarker-detail-page' into release/v1.25.0
This commit is contained in:
commit
95ee088646
36 files changed
+2209
-147
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();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,10 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
|
||||
*
|
||||
* PUT applies a partial edit (`data` built field-by-field; an explicit `null`
|
||||
* clears `context` / `panel` / a bound, an omitted key leaves it untouched).
|
||||
* DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on
|
||||
* `LabResult.biomarkerId` unlinks every reading without losing it (the row
|
||||
* keeps its legacy `analyte` / `unit` historical fields). Cross-user rows
|
||||
* surface as 404 (existence sealed).
|
||||
* DELETE hard-deletes the catalog definition together with every reading it
|
||||
* owns, in one userId-narrowed transaction — removing a biomarker is a
|
||||
* deliberate "drop this and its values" action. Cross-user rows surface as
|
||||
* 404 (existence sealed).
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -187,10 +187,17 @@ export const DELETE = apiHandler(
|
||||
return apiError("Biomarker not found", 404);
|
||||
}
|
||||
|
||||
// Hard delete — the `onDelete: SetNull` FK unlinks every reading rather
|
||||
// than cascading it away. The readings keep their legacy `analyte` /
|
||||
// `unit` so the history survives the catalog edit.
|
||||
await prisma.biomarker.delete({ where: { id } });
|
||||
// Hard delete the marker AND its readings in one transaction. Deleting a
|
||||
// biomarker is a deliberate "remove this and its history" action, so the
|
||||
// readings go with it rather than lingering as orphaned, unlinked rows.
|
||||
// Both legs are userId-narrowed so a cross-user id can never reach another
|
||||
// account's data.
|
||||
await prisma.$transaction([
|
||||
prisma.labResult.deleteMany({
|
||||
where: { userId: user.id, biomarkerId: id },
|
||||
}),
|
||||
prisma.biomarker.delete({ where: { id } }),
|
||||
]);
|
||||
|
||||
await auditLog("biomarker.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -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,72 @@
|
||||
/**
|
||||
* Per-biomarker assessment route.
|
||||
*
|
||||
* `GET /api/insights/biomarker-assessment?biomarkerId=<id>` serves the
|
||||
* data-driven assessment for one user-scoped biomarker. The shape is
|
||||
* byte-identical to `/api/insights/metric-status` so `InsightStatusCard`
|
||||
* consumes it unchanged.
|
||||
*
|
||||
* Read-only by construction: a cache miss enqueues an out-of-band
|
||||
* generation (via `resolveReadOnlyStatusMiss` inside the generator) and
|
||||
* serves the last-good text (stale-while-revalidate), never blocking on the
|
||||
* provider. The generator regenerates ONLY when the latest reading
|
||||
* fingerprint changes, so an idle marker re-stamps cached text without an
|
||||
* LLM round-trip.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
|
||||
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const querySchema = z.object({
|
||||
biomarkerId: z.string().min(1).max(64),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = querySchema.safeParse({
|
||||
biomarkerId: request.nextUrl.searchParams.get("biomarkerId"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.biomarker-status.invalid" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMetricStatusLocale(resolved);
|
||||
|
||||
const result = await generateBiomarkerStatus({
|
||||
biomarkerId: parsed.data.biomarkerId,
|
||||
userId: user.id,
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.biomarker-status" },
|
||||
meta: { biomarkerId: parsed.data.biomarkerId },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
Reference in new issue
Block a user