Files
HealthLog/tests/integration/analytics-health-score.test.ts
T

287 lines
9.2 KiB
TypeScript

/**
* v1.34.0 — integration coverage for the analytics route's versioned
* reference-composite Health Score (`src/lib/analytics/score/**`).
*
* Replaces the pre-v1.34.0 weighted bp/weight/mood/compliance shape this
* file used to pin. That scorer, its `components.*` envelope, and its
* mood/compliance pillars are gone; the current score reads coverage- and
* provenance-tagged pillars (BLOOD_PRESSURE, GLYCAEMIA, ACTIVITY, SLEEP,
* ADIPOSITY, WELLBEING, FITNESS, LIPIDS) through a real Postgres round trip
* of `GET /api/analytics`.
*/
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;
domain: string;
result: {
status: "ok" | "insufficient";
reason?: string;
value?: { score: number; observed: { value: number } };
};
}
interface AnalyticsEnvelope {
data: {
healthScore: {
composite: {
status: "ok" | "insufficient";
reason?: string;
value?: { score: number; band: "green" | "yellow" | "red" };
};
pillars: PillarEnvelope[];
} | null;
} | null;
error?: string | 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;
}
/**
* BP pillar requires `BLOOD_PRESSURE_MIN_PAIRS` (12) paired readings inside
* the trailing 90-day window read through the same fast-path aggregator the
* BP-in-target tile uses.
*/
async function seedInTargetBp(userId: string, now: number, days: number) {
const prisma = getPrismaClient();
const DAY = 24 * 60 * 60 * 1000;
for (let i = 0; i < days; i++) {
const at = new Date(now - i * DAY);
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_DIA",
value: 78,
unit: "mmHg",
measuredAt: at,
},
});
}
}
/** SLEEP pillar requires `SLEEP_MIN_NIGHTS` (14) distinct wake-day nights. */
async function seedSleepNights(userId: string, now: number, nights: number) {
const prisma = getPrismaClient();
const DAY = 24 * 60 * 60 * 1000;
for (let i = 0; i < nights; i++) {
const wake = new Date(now - i * DAY);
wake.setUTCHours(6, 0, 0, 0);
await prisma.measurement.create({
data: {
userId,
type: "SLEEP_DURATION",
value: 450,
unit: "min",
measuredAt: wake,
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
},
});
}
}
/** ADIPOSITY pillar needs one waist-circumference reading plus a height. */
async function seedWaist(userId: string, now: number) {
const prisma = getPrismaClient();
await prisma.measurement.create({
data: {
userId,
type: "WAIST_CIRCUMFERENCE",
value: 82,
unit: "cm",
measuredAt: new Date(now),
},
});
}
describe("GET /api/analytics — Health Score (v1.34 reference composite)", () => {
it("grades a composite from a strong three-domain synthetic shape", async () => {
const user = await seedSession("hs-strong");
const now = Date.now();
await seedInTargetBp(user.id, now, 20);
await seedSleepNights(user.id, now, 14);
await seedWaist(user.id, now);
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;
expect(env.data).not.toBeNull();
const hs = env.data!.healthScore!;
expect(hs).not.toBeNull();
expect(hs.composite.status).toBe("ok");
expect(hs.composite.value!.score).toBeGreaterThanOrEqual(75);
expect(hs.composite.value!.band).toBe("green");
// Every seeded pillar graded, not just the ones the composite counted.
const byId = new Map(hs.pillars.map((p) => [p.id, p]));
expect(byId.get("BLOOD_PRESSURE")!.result.status).toBe("ok");
expect(byId.get("SLEEP")!.result.status).toBe("ok");
expect(byId.get("ADIPOSITY")!.result.status).toBe("ok");
});
it("scores the BP pillar from the trailing-90-day window when readings predate the last 30 days", async () => {
// v1.17 W1d regression pin, carried forward: the BD-Zielbereich tile
// headline, the Health-Score BP pillar and the coach number all read the
// SAME trailing-90-day window. Readings 60-80 days ago sit OUTSIDE the
// trailing 30 days but INSIDE the 90-day window the pillar and the
// headline share.
const user = await seedSession("hs-bp-old");
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const prisma = getPrismaClient();
for (let i = 0; i < 20; i++) {
const at = new Date(now - (60 + i) * DAY);
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: 78,
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 & {
data: { bpInTargetPct: number | null } | null;
};
const hs = env.data!.healthScore!;
const bp = hs.pillars.find((p) => p.id === "BLOOD_PRESSURE")!;
expect(bp.result.status).toBe("ok");
expect(bp.result.value!.score).toBeGreaterThanOrEqual(80);
// …and the tile headline reads the SAME 90-day window, so it surfaces
// the in-target rate (all 20 pairs in target) rather than dropping to
// null. The pillar and the headline stay unified on the window.
expect(env.data!.bpInTargetPct).toBe(100);
});
it("reports an explicit insufficient composite for a user with no data", async () => {
await seedSession("hs-empty");
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 hs = env.data!.healthScore!;
// v1.34 — the reference composite always returns a structured report,
// even with zero data; it reads as an explicit "insufficient" status,
// never a bare null.
expect(hs).not.toBeNull();
expect(hs.composite.status).toBe("insufficient");
expect(hs.composite.value).toBeUndefined();
});
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
// _DOMAINS). Two graded pillars in two domains must not slip past that
// floor.
const user = await seedSession("hs-two-domains");
const now = Date.now();
await seedSleepNights(user.id, now, 14);
await seedWaist(user.id, now);
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 hs = env.data!.healthScore!;
expect(hs.composite.status).toBe("insufficient");
const byId = new Map(hs.pillars.map((p) => [p.id, p]));
expect(byId.get("SLEEP")!.result.status).toBe("ok");
expect(byId.get("ADIPOSITY")!.result.status).toBe("ok");
});
});