mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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"),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user