fix(score): read biomarker bounds by their real column names

The score reader's labs query selected referenceLow/referenceHigh on the
joined Biomarker, but the catalog's range columns are lowerBound and
upperBound. Prisma rejects a select against names a model does not have
at query-validation time, before any row is read, so every labs read
threw and both the Glycaemia and Lipids pillars reported a failed read
permanently, Retry included, even for accounts with valid lab results.

Select the real columns, map the catalog bounds onto the reader's range
fallback, and pin two contracts: a real-Postgres round trip that scores
both pillars through the biomarker join (including a lipid row whose
range lives only on the catalog), and the honest empty state, where a
labs account with no rows reads as not tracked rather than read_failed,
which stays reserved for a read that genuinely failed.

Refs #780
This commit is contained in:
Marc-André Bombeck committed 2026-08-12 22:52:03 +02:00
1 parent d21fe48e3c
commit 19c54082bf
3 files changed
+250 -8

No files matched your search

@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { readActivityDays, readFastingGlucoseMeasurements } from "../reader";
import {
computeUserHealthScore,
readActivityDays,
readFastingGlucoseMeasurements,
} from "../reader";
function dbWithRows(rows: unknown[]) {
return {
@@ -107,6 +111,51 @@ describe("score reader bounds and source parity", () => {
expect(result.days).toEqual([{ day: "2026-07-02", value: 1_000 }]);
});
it("reads an empty labs set as not_tracked, never read_failed", async () => {
// A labs-enabled account with no lab rows has honestly recorded
// nothing: the read SUCCEEDED and found an empty set. `read_failed`
// is reserved for a read that threw — showing it here would offer a
// Retry that can never help.
const db = {
measurement: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
labResult: { findMany: vi.fn().mockResolvedValue([]) },
dismissedPriorityItem: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaClient;
const report = await computeUserHealthScore({
prisma: db,
userId: "user-1",
now: new Date("2026-07-28T12:00:00.000Z"),
profile: {
dateOfBirth: new Date("1985-07-09"),
heightCm: 178,
timezone: "Europe/Berlin",
sourcePriorityJson: null,
thresholdsJson: null,
},
modules: {
glucose: true,
labs: true,
sleep: true,
mentalHealth: true,
},
healthScoreConfigJson: null,
bpTargets: null,
bpEnvelope: null,
bpEnvelopePriorWeek: null,
bpEnvelopePriorTwoWeeks: null,
});
for (const id of ["GLYCAEMIA", "LIPIDS"] as const) {
const pillar = report.pillars.find((p) => p.id === id)!;
expect(pillar.result.status).toBe("insufficient");
expect(
pillar.result.status === "insufficient" && pillar.result.reason,
).toBe("not_tracked");
}
});
it("excludes source writers outside the current 28-day score window", async () => {
const db = dbWithRows([
stepRow("2026-06-15T09:00:00.000Z", 8_000, "watch", "APPLE_HEALTH"),
+12 -7
View File
@@ -443,8 +443,8 @@ function scoreInputsFor(args: {
biomarker: {
name: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
lowerBound: number | null;
upperBound: number | null;
} | null;
}>
>;
@@ -597,9 +597,8 @@ function scoreInputsFor(args: {
marker: row.biomarker?.name ?? row.analyte,
value: row.value!,
unit: row.unit || row.biomarker?.unit || "",
referenceLow: row.referenceLow ?? row.biomarker?.referenceLow ?? null,
referenceHigh:
row.referenceHigh ?? row.biomarker?.referenceHigh ?? null,
referenceLow: row.referenceLow ?? row.biomarker?.lowerBound ?? null,
referenceHigh: row.referenceHigh ?? row.biomarker?.upperBound ?? null,
panel: row.panel,
at: row.takenAt,
source: row.source,
@@ -708,12 +707,18 @@ export async function computeUserHealthScore(
panel: true,
takenAt: true,
source: true,
// The catalog's range columns are `lowerBound` / `upperBound`
// (`lower_bound` / `upper_bound`), not the LabResult's own
// `reference*` names. Prisma rejects a select against names a
// model does not have at query-validation time — before any
// row is read — so getting this wrong fails EVERY labs read,
// not just rows that use the catalog fallback.
biomarker: {
select: {
name: true,
unit: true,
referenceLow: true,
referenceHigh: true,
lowerBound: true,
upperBound: true,
},
},
},
@@ -0,0 +1,188 @@
/**
* v1.37.13 — Health Score GLYCAEMIA + LIPIDS pillars through a real
* Postgres round trip of `GET /api/analytics`.
*
* The score reader joins each LabResult to its linked Biomarker for the
* canonical name, unit and reference bounds. The Biomarker columns are
* `lowerBound` / `upperBound`; a select that asks the relation for the
* LabResult's own `referenceLow` / `referenceHigh` names is rejected by
* the Prisma client at query-validation time — before any row is read —
* which turns EVERY labs read into `read_failed` and parks both pillars
* on "Data could not be loaded" permanently, Retry included. A mocked
* client cannot catch that class, so these cases run the real join.
*
* Two contracts:
*
* 1. A linked HbA1c reading and a lipid reading whose reference range
* lives ONLY on the biomarker catalog row (the per-row bounds left
* NULL) score both pillars — the join delivers name, unit and the
* catalog bounds.
* 2. A labs-enabled account with no lab rows reads as `not_tracked` —
* an honest "nothing recorded", never `read_failed`, which is
* reserved for a read that genuinely failed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface PillarEnvelope {
id: string;
result: {
status: "ok" | "insufficient";
reason?: string;
value?: { score: number; observed: { value: number } };
};
}
interface AnalyticsEnvelope {
data: {
healthScore: { pillars: PillarEnvelope[] } | null;
} | null;
}
async function seedSession(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
heightCm: 178,
dateOfBirth: new Date("1985-07-09"),
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function fetchScorePillars() {
const { GET } = await import("@/app/api/analytics/route");
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/analytics"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as AnalyticsEnvelope;
const pillars = env.data!.healthScore!.pillars;
return new Map(pillars.map((p) => [p.id, p]));
}
describe("GET /api/analytics — labs-fed score pillars (biomarker join)", () => {
it("scores GLYCAEMIA and LIPIDS from lab rows joined to their biomarker", async () => {
const user = await seedSession("hs-labs-join");
const prisma = getPrismaClient();
const takenAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const hba1c = await prisma.biomarker.create({
data: {
userId: user.id,
name: "HbA1c",
unit: "%",
lowerBound: 5,
upperBound: 5.6,
},
});
await prisma.labResult.create({
data: {
userId: user.id,
biomarkerId: hba1c.id,
analyte: "HbA1c",
value: 5.2,
unit: "%",
takenAt,
},
});
// The lipid row deliberately carries NO per-row reference bounds: the
// range the pillar grades against must arrive through the biomarker
// join, which is exactly the leg the wrong select severed.
const ldl = await prisma.biomarker.create({
data: {
userId: user.id,
name: "LDL Cholesterol",
unit: "mg/dL",
upperBound: 130,
},
});
await prisma.labResult.create({
data: {
userId: user.id,
biomarkerId: ldl.id,
analyte: "ldl",
value: 110,
unit: "mg/dL",
takenAt,
},
});
const byId = await fetchScorePillars();
const glycaemia = byId.get("GLYCAEMIA")!;
expect(glycaemia.result.reason).not.toBe("read_failed");
expect(glycaemia.result.status).toBe("ok");
expect(glycaemia.result.value!.observed.value).toBe(5.2);
const lipids = byId.get("LIPIDS")!;
expect(lipids.result.reason).not.toBe("read_failed");
expect(lipids.result.status).toBe("ok");
expect(lipids.result.value!.score).toBeGreaterThan(0);
});
it("reads not_tracked, never read_failed, when a labs account has no rows", async () => {
await seedSession("hs-labs-empty");
const byId = await fetchScorePillars();
const glycaemia = byId.get("GLYCAEMIA")!;
expect(glycaemia.result.status).toBe("insufficient");
expect(glycaemia.result.reason).toBe("not_tracked");
const lipids = byId.get("LIPIDS")!;
expect(lipids.result.status).toBe("insufficient");
expect(lipids.result.reason).toBe("not_tracked");
});
});