Files
HealthLog/src/app/api/analytics/__tests__/route.test.ts
T
Marc-André Bombeck e4880dcdc1 feat: the score says when a pillar stopped counting
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.

The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.

Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.

The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.

This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.

Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.

Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
2026-08-30 16:16:57 +02:00

681 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.33 P0 regression coverage for `GET /api/analytics`.
*
* Production stacktrace 2026-05-16 14:39:51 UTC (cf-ray 9fcb223c…):
* `RangeError: Maximum call stack size exceeded`
* at Promise.all (index 3 — `PULSE`)
*
* Root cause was in `summarize()` (`src/lib/analytics/trends.ts`) — the
* `Math.min(...values)` / `Math.max(...values)` spread blew V8's
* ~125 000-arg function-arity ceiling once an Apple-Health-synced PULSE
* series for a multi-year power user grew past it.
*
* The fix folds min/max into the single sum/mean pass; this test pins
* the contract from the route entry-point so a future refactor (e.g.
* the v1.4.33 C1 SQL-side aggregation rewrite) can't silently
* reintroduce a spread anywhere along the call chain.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.4.37 W2 — `ensureUserRollupsFresh` pokes `measurement.findFirst`
// for the newest-measurement watermark; mock both shapes.
measurement: {
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
},
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
// v1.34 — the versioned reference score reads these three models
// directly (not through the shared measurement/rollup path); default
// to empty/null so a brand-new-user response doesn't throw before the
// route can even decide the composite is ungraded.
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
labResult: { findMany: vi.fn().mockResolvedValue([]) },
dismissedPriorityItem: { findUnique: vi.fn().mockResolvedValue(null) },
// v1.38 — the composition note reads the account's last stored score
// day. Nothing stored in these fixtures, so no note is raised.
healthScoreRecord: { findFirst: vi.fn().mockResolvedValue(null) },
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`. The
// v1.4.36 per-type coverage probe and the v1.4.37 default-slice
// probe also ride `$queryRaw`. Default to an empty coverage map so
// the route falls back to the live aggregator branches and the
// assertions stay byte-shape stable.
$queryRaw: vi.fn().mockResolvedValue([]),
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
// v1.4.35 — slim slice also reads DAY buckets; the freshness
// watermark inside `ensureUserRollupsFresh` pokes `findFirst`.
// Default both to empty so the parity check falls back to live
// SQL and the slim slice assertions stay byte-shape stable.
measurementRollup: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
upsert: vi.fn().mockResolvedValue({}),
// v1.11.1 — the writer now delete-then-inserts via createMany.
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
// v1.11.1 — the source-aware rollup readers load the user's
// source-priority blob; null → default ladders.
user: { findUnique: vi.fn().mockResolvedValue(null) },
$transaction: vi.fn().mockImplementation(async (queries: unknown[]) => {
if (Array.isArray(queries)) return Promise.all(queries);
return undefined;
}),
},
}));
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/insights/correlation-patterns", () => ({
PATTERN_FAMILIES: { fixed: "FIXED" },
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
decisionForEvidence: vi.fn().mockReturnValue(null),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// v1.18.0 — the health-score fast path resolves the per-user module map to
// drop disabled-module pillars; stub the gate so analytics tests don't need
// the real DB-backed resolver (default: every module enabled).
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(),
isModuleEnabled: vi.fn(),
requireModuleEnabled: vi.fn(),
};
});
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import {
resolveModuleMap,
isModuleEnabled,
requireModuleEnabled,
} from "@/lib/modules/gate";
const SESSION_USER = {
id: "user-1",
username: "test",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
sourcePriorityJson: null,
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: SESSION_USER as never,
};
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset the analytics LRU between tests so each case
// observes a cold cache (otherwise tests sharing a userId would land
// on the prior test's cached response).
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.dismissedPriorityItem.findUnique).mockResolvedValue(
null as never,
);
// v1.4.35 — rollup table defaults. `resetAllMocks` clears the
// module-level implementations, so we re-seed both per test. Empty
// findMany + null findFirst means the slim slice's parity check
// diverges and falls back to live SQL (preserves the pre-v1.4.35
// assertions in this file).
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue({} as never);
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
// the per-type coverage probe + the rollup-recompute aggregator ride
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
// falls back to the live fast-path branches and the assertions stay
// byte-shape stable.
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
// v1.4.49.1 — `computeSleepStageBreakdown` + the glucose 30-day
// window both call `prisma.measurement.findMany`; the per-type
// chunked fan-out that used to override this mock per test is gone
// (folded into `computeSummariesSlice` $queryRaw), so the default
// needs to be a safe empty array — undefined would crash
// `rows.length` inside the sleep-stage builder.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$queryRaw as any).mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$queryRawUnsafe as any).mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$transaction as any).mockImplementation(
async (queries: unknown) => {
if (Array.isArray(queries)) return Promise.all(queries);
return undefined;
},
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/analytics", () => {
// v1.4.49.1 — the default slice no longer fans out 15 per-type
// `prisma.measurement.findMany` walks; it delegates to
// `computeSummariesSlice`, which feeds entirely from `$queryRaw`
// against `measurement_rollups` (DAY buckets + a 90-day narrow
// aggregate). Tests now exercise the rollup-tier path. The slim
// slice's own test file (`summaries-slice.test.ts`) covers the per-
// type SQL contract; the route tests below verify the wiring + the
// shape of the default-slice response.
it("returns a 200 envelope for a brand-new user with zero rows", async () => {
// v1.4.49.1 — slim slice's `probeRollupCoverage` (one `$queryRaw`)
// returns an empty coverage set so `isFullyCovered` is false; the
// path falls through to `computeFromLiveAggregate`, which fires
// three `$queryRaw`s (allTime, windowed, latests) — all empty for
// a brand-new user. The default-slice handler chains a few more
// `$queryRaw`s through the BP / health-score / correlations fast
// paths; the shared empty-`[]` default seeded in `beforeEach`
// satisfies every one.
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
summaries: Record<string, { count: number }>;
bmi: number | null;
healthScore: { composite: { status: string } } | null;
};
};
expect(body.data.summaries.PULSE.count).toBe(0);
expect(body.data.summaries.WEIGHT.count).toBe(0);
expect(body.data.bmi).toBeNull();
// v1.34 — the versioned reference score always returns a structured
// report, even with zero data; a brand-new user reads as an explicit
// "insufficient" composite, never a bare null.
expect(body.data.healthScore?.composite.status).toBe("insufficient");
// v1.4.49.1 — the legacy 15-way per-type live walk used a chunked
// pagination select `(id, measuredAt, value, source, deviceType)`
// unique to `fetchMeasurementSeriesChunked`. Other `findMany` calls
// (sleep-stage breakdown, glucose 30-day window, BP fallback) all
// use different select shapes, so this negative check pins the
// deleted fan-out without false-positiving on legitimate reads.
expect(prisma.measurement.findMany).not.toHaveBeenCalledWith(
expect.objectContaining({
select: expect.objectContaining({
id: true,
source: true,
deviceType: true,
}),
take: 5000,
}),
);
});
// v1.4.49.1 — the dashboard tile-strip's `lastSeenByType[type]?.daysAgo`
// contract is now produced entirely by the slim slice
// (`computeSummariesSlice` → `latests` `$queryRaw`). The shape +
// freshness math is covered by `summaries-slice.test.ts`. This route
// test only pins that the field reaches the response envelope and
// that the GET wrapper's `enrichLastSeenDaysAgo` re-derives `daysAgo`
// from the cached `lastSeenAt` ISO so a slice straddling midnight
// still surfaces a wall-clock-truthful caption.
it("includes lastSeenByType + bfcache Cache-Control on the default slice", async () => {
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toBe(
"private, max-age=0, must-revalidate",
);
const body = (await res.json()) as {
data: {
lastSeenByType: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
summaries: Record<
string,
{ avg30LastMonth: number | null; avg30LastYear: number | null }
>;
};
};
// Types the user never logged report `null` — the tile-strip helper
// falls through without painting a freshness caption.
expect(body.data.lastSeenByType.BLOOD_GLUCOSE).toBeNull();
// v1.4.49.1 — `avg30LastMonth` plumbs through the default slice
// now that the slim narrow query carries it. The empty-mocks
// fixture produces null; the field must still EXIST on the shape
// so the dashboard's `tileCompareDelta` helper can `?? null`.
expect("avg30LastMonth" in (body.data.summaries.WEIGHT ?? {})).toBe(true);
});
// v1.4.33 C1 — slim summaries slice. The route branches on
// `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw`
// passes carry the per-type DataSummary shape with the same
// contract the dashboard tile strip reads.
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
// v1.4.36 — slim slice opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe; `n: 0` forces the cold-fallback
// path. v1.4.48 M0 split that path's single aggregate query into
// two parallel ones (all-time + windowed), so the cold fixture
// now mocks 4 `$queryRaw` calls: coverage + allTime + windowed +
// latest.
// The coverage probe rides `$queryRaw` (n:0 → cold path). v1.11.1 — the
// cold path's data aggregates (allTime + windowed + latest) now run via
// `$queryRawUnsafe` (the source-aware collapse splices a whitelisted CASE).
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ n: BigInt(0) },
] as never);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$queryRawUnsafe as any)
.mockResolvedValueOnce([
{
type: "WEIGHT",
count: BigInt(12),
min_value: 80.0,
max_value: 84.5,
mean_value: 82.1,
},
] as never)
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82.0,
avg30: 82.2,
slope7: -0.05,
r2_7: 0.4,
slope30: -0.02,
r2_30: 0.3,
r2_90: 0.2,
},
] as never)
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
] as never);
const req = new Request("http://localhost/api/analytics?slice=summaries");
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
req,
);
expect(res.status).toBe(200);
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
// slice too.
expect(res.headers.get("Cache-Control")).toBe(
"private, max-age=0, must-revalidate",
);
const body = (await res.json()) as {
data: {
summaries: Record<
string,
{
count: number;
latest: number | null;
slope30: { slope: number; direction: string } | null;
}
>;
bmi: number | null;
lastSeenByType: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
};
};
// The slim slice produced WEIGHT from the SQL pass; no chunked
// findMany was called.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(body.data.summaries.WEIGHT.count).toBe(12);
expect(body.data.summaries.WEIGHT.latest).toBe(81.8);
expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down");
// Slim slice never carries BMI — the consumer re-derives.
expect(body.data.bmi).toBeNull();
// v1.4.34 IW-B — slim slice surfaces lastSeenByType too so the
// tile-strip caption works regardless of which slice the client
// read.
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeGreaterThanOrEqual(4);
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(6);
expect(body.data.lastSeenByType.PULSE).toBeNull();
});
// v1.18.0 B1 — the default slice serves the glucose-only surfaces
// (per-context summaries + the TIR/GMI/eA1C clinical panel) only when
// the `glucose` module is enabled. A disabled-glucose account nulls
// both fields rather than 403-ing the whole core-metrics payload.
function seedGlucoseRows() {
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
where?: { type?: string };
}) => {
if (args?.where?.type === "BLOOD_GLUCOSE") {
return [
{ value: 95, measuredAt, glucoseContext: "FASTING" },
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
];
}
return [];
}) as never);
}
it("serves glucose clinical + per-context when glucose enabled", async () => {
seedGlucoseRows();
vi.mocked(isModuleEnabled).mockResolvedValue(true as never);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
glucoseByContext: Record<string, unknown> | null;
glucoseClinical: unknown | null;
};
};
expect(body.data.glucoseClinical).not.toBeNull();
expect(body.data.glucoseByContext).not.toBeNull();
expect(body.data.glucoseByContext?.FASTING).toBeDefined();
});
it("nulls glucose clinical + per-context when glucose disabled", async () => {
seedGlucoseRows();
vi.mocked(isModuleEnabled).mockImplementation(
(async (_userId: string, key: string) => key !== "glucose") as never,
);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
glucoseByContext: Record<string, unknown> | null;
glucoseClinical: unknown | null;
summaries: Record<string, { count: number }>;
};
};
// Core metrics still served — the route did NOT 403.
expect(body.data.summaries.WEIGHT.count).toBe(0);
// Glucose-only surfaces are nulled.
expect(body.data.glucoseClinical).toBeNull();
expect(body.data.glucoseByContext).toBeNull();
});
// v1.4.49.1 — the 15-way per-type `fetchMeasurementSeriesChunked`
// fan-out was removed entirely; the default slice now delegates to
// `computeSummariesSlice` which runs three rollup-tier `$queryRaw`
// passes regardless of the type count. The pre-v1.4.49.1 "caps
// per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY" test
// belonged to a code path that no longer exists; the no-fan-out
// assertion in the "brand-new user" test above pins the negative
// contract (the chunked findMany must never re-appear on the default
// slice critical path).
});
/**
* `?slice=` is a closed set, and the cost of it not being one was the point.
*
* The route compared the raw string against `"summaries"` and fell through to
* the DEFAULT body on anything else, so `?slice=summary` answered 200 after
* running the heaviest chain on the surface. A client could sit on that typo
* indefinitely and see nothing but a slow, correct-looking response — the
* failure mode was invisible and expensive at the same time.
*/
describe("GET /api/analytics — the slice parameter is a closed set", () => {
const call = (url: string) =>
(GET as unknown as (request: Request) => Promise<Response>)(
new Request(url),
);
it.each(["summary", "SUMMARIES", "default", ""])(
"refuses ?slice=%s rather than serving the expensive default body",
async (value) => {
const res = await call(
`http://localhost/api/analytics?slice=${encodeURIComponent(value)}`,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
details?: { issues?: Array<{ path: string }> };
};
expect(body.meta?.errorCode).toBe("analytics.invalid_query");
expect(body.details?.issues?.map((i) => i.path)).toContain("slice");
},
);
it("still serves the default body when the parameter is absent", async () => {
const res = await call("http://localhost/api/analytics");
expect(res.status).toBe(200);
});
it("still serves the slim body for the one legal value", async () => {
const res = await call("http://localhost/api/analytics?slice=summaries");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
// The slim slice is recognisable by what it does NOT carry.
expect(body.data).not.toHaveProperty("healthScore");
expect(body.data).toHaveProperty("summaries");
});
});
/**
* 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);
});
});