mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Prove the blood-pressure basis survives the assembly, not just both ends
The grader returns the score and its basis together and the pillar builder copies both across, and each of those ends already had its own test. Neither sees what happens between them: the envelope hand-off, the reader's narrowing, the pillar list, the JSON that finally goes out. A field can be produced correctly, consumed correctly, and never reach anyone, which is exactly how a payload field shipped earlier this month and carried nothing on the wire. These tests read the assembled response and assert the basis that arrives, so a future hand-picked key list on that path fails here rather than in someone's browser.
This commit is contained in:
1 parent
cf7bb13ee8
commit
5784d5ec72
2 files changed
+269
-1
No files matched your search
@@ -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<WirePillar> {
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Response>)(
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user