diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 8f7b11e3e..44a4903f7 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -450,3 +450,183 @@ describe("GET /api/analytics", () => { // contract (the chunked findMany must never re-appear on the default // slice critical path). }); + +/** + * v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist. + * + * The grader returns `{ score, basis }` and the pillar builder copies the + * basis onto the pillar value, and both of those ends have their own unit + * tests. Neither of them sees the assembly in between: the route's envelope + * hand-off, the score reader's envelope narrowing, the pillar list, the JSON + * envelope. A field can be produced correctly, consumed correctly, and still + * never reach a client, which is precisely how v1.33.1 shipped a payload + * field that nothing on the wire ever carried. + * + * So these drive the REAL route. Nothing about the score is stubbed — the + * fixture seeds raw blood-pressure rows on the same `$queryRawUnsafe` the + * live BP path issues, and the route does its own pairing, its own + * recency-weighting, its own grading and its own serialisation. The whole + * payload is read back off `res.json()`. + */ +describe("GET /api/analytics — blood-pressure score basis on the wire", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + + /** + * ESH 2023 band for the fixture's date of birth (age < 65). + */ + const CLINICAL = { sysHigh: 129, diaHigh: 79 } as const; + + interface WirePillar { + id: string; + result: { + status: string; + value?: { + score: number; + scoreBasis?: { + axis: string; + relation: string; + offsetMmHg: number; + boundaryMmHg: number; + }; + observed: { label: string }; + reference: { label: string }; + personalReference?: { label: string }; + }; + }; + } + + /** + * Seed one blood-pressure pair per day over the trailing `pairs` days. + * Every pair carries the same reading, so the recency-weighted + * representative is that reading exactly and the expected score can be + * stated as a literal instead of a tolerance. + */ + function seedBpSeries(input: { sys: number; dia: number; pairs: number }) { + const seriesOf = (value: number) => + Array.from({ length: input.pairs }, (_, i) => ({ + measured_at: new Date(Date.now() - (i + 1) * DAY_MS), + value, + })); + const sysRows = seriesOf(input.sys); + const diaRows = seriesOf(input.dia); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(prisma.$queryRawUnsafe as any).mockImplementation( + async (sql: unknown) => { + if (typeof sql !== "string") return []; + if (sql.includes(`m."type" = 'BLOOD_PRESSURE_SYS'`)) return sysRows; + if (sql.includes(`m."type" = 'BLOOD_PRESSURE_DIA'`)) return diaRows; + return []; + }, + ); + } + + function signInWith(thresholdsJson: unknown) { + vi.mocked(getSession).mockResolvedValue({ + session: SESSION_OK.session, + user: { ...SESSION_USER, thresholdsJson }, + } as never); + } + + async function readBloodPressurePillar(): Promise { + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { healthScore: { pillars: WirePillar[] } | null }; + }; + const pillar = body.data.healthScore?.pillars.find( + (p) => p.id === "BLOOD_PRESSURE", + ); + if (!pillar) throw new Error("no BLOOD_PRESSURE pillar in the payload"); + return pillar; + } + + it("carries the basis onto the pillar the client actually receives", async () => { + // 137/91 against the 129/79 band: systolic sits 8 over its ceiling and + // grades 57, diastolic sits 12 over and grades 50. The pillar takes the + // worse of the two, so diastolic is the axis that set the number. + seedBpSeries({ sys: 137, dia: 91, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.status).toBe("ok"); + expect(pillar.result.value?.observed.label).toBe("137/91 mmHg"); + expect(pillar.result.value?.score).toBe(50); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 12, + boundaryMmHg: CLINICAL.diaHigh, + }); + }); + + it("says the reading is in band when it is", async () => { + // 126/68 clears both ceilings, so no axis is over anything and the + // relation has to say so rather than reporting a distance from a ceiling + // the reading never reached. Systolic is the nearer of the two to its + // own ceiling here, so this also puts the other axis on the wire. + seedBpSeries({ sys: 126, dia: 68, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.value?.observed.label).toBe("126/68 mmHg"); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "systolic", + relation: "in_band", + offsetMmHg: 3, + boundaryMmHg: CLINICAL.sysHigh, + }); + }); + + it("omits the basis entirely when the pillar has no score to explain", async () => { + // Eleven pairs is one short of the floor, so the pillar is insufficient + // and carries no value at all — the popover must fall back to the + // shipped lines rather than to an explanation of a number nobody has. + seedBpSeries({ sys: 137, dia: 91, pairs: 11 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.status).toBe("insufficient"); + expect(pillar.result.value).toBeUndefined(); + }); + + /** + * The coupling the two-ended tests cannot see. + * + * A user with a personal target gets the whole BP helper run twice: once + * against their own band for the in-target percentages on the dashboard, + * once against the clinical band for the score. The score comes from the + * second run. So does the basis, and it has to, because the two runs + * disagree on every field of it — different binding axis, different + * ceiling, different distance. Picking the score off one run and the + * basis off the other reads as correct in review and produces a popover + * that explains a number the user is not looking at. + * + * The fixture is chosen so the two runs share NOTHING: 137/91 against the + * personal 135/95 band binds on systolic 2 over 135 and scores 77; against + * the clinical 129/79 band it binds on diastolic 12 over 79 and scores 50. + */ + it("takes the basis from the same run as the score when a personal target differs", async () => { + signInWith({ + BLOOD_PRESSURE_SYS: { min: 110, max: 135 }, + BLOOD_PRESSURE_DIA: { min: 65, max: 95 }, + }); + seedBpSeries({ sys: 137, dia: 91, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + // The personal band is genuinely in play — this account really does take + // the route's second-run branch, so the assertions below are about the + // coupling and not about a user who has no personal target at all. + expect(pillar.result.value?.personalReference?.label).toBe( + "110–135/65–95 mmHg", + ); + + expect(pillar.result.value?.score).toBe(50); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 12, + boundaryMmHg: CLINICAL.diaHigh, + }); + // Stated negatively as well: nothing from the personal run may leak in. + expect(pillar.result.value?.scoreBasis?.boundaryMmHg).not.toBe(135); + }); +}); diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts index f74f69b6e..43a8c0fd3 100644 --- a/tests/integration/analytics-health-score.test.ts +++ b/tests/integration/analytics-health-score.test.ts @@ -58,7 +58,17 @@ interface PillarEnvelope { result: { status: "ok" | "insufficient"; reason?: string; - value?: { score: number; observed: { value: number } }; + value?: { + score: number; + observed: { value: number }; + scoreBasis?: { + axis: string; + relation: string; + offsetMmHg: number; + boundaryMmHg: number; + }; + personalReference?: { label: string }; + }; }; } @@ -260,6 +270,84 @@ describe("GET /api/analytics — Health Score (v1.34 reference composite)", () = expect(hs.composite.value).toBeUndefined(); }); + it("delivers the BP score basis to the client from the same run that produced the score", async () => { + // v1.34.5 — the popover explains "why 56" from a server-resolved basis. + // The basis and the score must come from ONE grading run: the route + // deliberately re-runs the BP helper against the CLINICAL band when the + // user keeps a personal target, and a basis taken from the other run + // would explain a number nobody is looking at. + // + // This asserts through the REAL route and the REAL JSON response, not a + // hand-built payload, because the assembly between the grader and the + // client is exactly where a correct field goes missing. + const user = await seedSession("hs-bp-basis"); + const prisma = getPrismaClient(); + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + + // A personal band that is WIDER than the clinical one on both axes, so + // the two runs disagree about everything the basis carries: + // clinical 129/79 → diastolic binds, 9 mmHg over its 79 ceiling → 55 + // personal 135/90 → both axes in band → 88 + await prisma.user.update({ + where: { id: user.id }, + data: { + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 120, max: 135 }, + BLOOD_PRESSURE_DIA: { min: 70, max: 90 }, + }, + }, + }); + for (let i = 0; i < 20; i++) { + const at = new Date(now - i * DAY); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + value: 132, + unit: "mmHg", + measuredAt: at, + }, + }); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "BLOOD_PRESSURE_DIA", + value: 88, + unit: "mmHg", + measuredAt: at, + }, + }); + } + + const { GET } = await import("@/app/api/analytics/route"); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/analytics"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as AnalyticsEnvelope; + const bp = env.data!.healthScore!.pillars.find( + (p) => p.id === "BLOOD_PRESSURE", + )!; + expect(bp.result.status).toBe("ok"); + + // The personal band reached the payload, so the route really did take + // the branch that re-grades against the clinical one. + expect(bp.result.value!.personalReference!.label).toContain("135"); + + // Score AND basis, both from the clinical run: the diastolic axis, nine + // over ITS clinical ceiling of 79. Under the personal band the same + // readings sit inside it and score 88 — so a basis sourced from that + // second run cannot pass here. + expect(bp.result.value!.score).toBe(55); + expect(bp.result.value!.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 9, + boundaryMmHg: 79, + }); + }); + it("stays insufficient with only two eligible domains — the three-domain floor", async () => { // The composite requires at least three eligible domains, including a // measured physiological one, before it grades at all (CORE_MIN_ELIGIBLE