mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The BD-Zielbereich headline, the Health-Score BP pillar and the coach number now all read the same trailing-90-day in-target window. Re-derive the integration expectations from the fixtures: the analytics headline is the independent 90-day figure (distinct from the 30-day and all-time windows), and the BP pillar grades from the 90-day window when readings predate the trailing month rather than dropping out.
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
||
* v1.4.20 phase B5 — integration coverage for the analytics route's
|
||
* Personal Health Score field.
|
||
*
|
||
* Seeds a deterministic mix of weight + mood + medication-intake
|
||
* fixtures alongside the existing BP-in-target seed, then asserts the
|
||
* route returns a `healthScore` envelope whose band corresponds to the
|
||
* synthetic-data shape.
|
||
*/
|
||
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 AnalyticsEnvelope {
|
||
data: {
|
||
healthScore: {
|
||
score: number;
|
||
band: "green" | "yellow" | "red";
|
||
components: {
|
||
bp: { value: number | null; weight: number };
|
||
weight: { value: number | null; weight: number };
|
||
mood: { value: number | null; weight: number };
|
||
compliance: { value: number | null; weight: number };
|
||
};
|
||
delta: number | null;
|
||
} | 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;
|
||
}
|
||
|
||
describe("GET /api/analytics — Health Score", () => {
|
||
it("returns a green-band score for a strong-positive synthetic shape", async () => {
|
||
const prisma = getPrismaClient();
|
||
const user = await seedSession("hs-strong");
|
||
|
||
const now = Date.now();
|
||
const DAY = 24 * 60 * 60 * 1000;
|
||
|
||
// ── BP: 18 in-target paired readings + 2 out → ≈ 90 % ──
|
||
for (let i = 0; i < 20; i++) {
|
||
const at = new Date(now - i * DAY);
|
||
const inTarget = i >= 2; // 18 in-target, 2 out
|
||
await prisma.measurement.create({
|
||
data: {
|
||
userId: user.id,
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
value: inTarget ? 120 : 145,
|
||
unit: "mmHg",
|
||
measuredAt: at,
|
||
},
|
||
});
|
||
await prisma.measurement.create({
|
||
data: {
|
||
userId: user.id,
|
||
type: "BLOOD_PRESSURE_DIA",
|
||
value: inTarget ? 78 : 95,
|
||
unit: "mmHg",
|
||
measuredAt: at,
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Weight: closing toward BMI-22 target (≈ 69.7 kg for 178 cm) ──
|
||
for (let i = 0; i < 10; i++) {
|
||
await prisma.measurement.create({
|
||
data: {
|
||
userId: user.id,
|
||
type: "WEIGHT",
|
||
value: 72 - i * 0.2, // 72.0 -> 70.2 across 10 days
|
||
unit: "kg",
|
||
measuredAt: new Date(now - (10 - i) * DAY),
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Mood: stable high score ──
|
||
for (let i = 0; i < 10; i++) {
|
||
const at = new Date(now - i * DAY);
|
||
const ymd = at.toISOString().slice(0, 10);
|
||
await prisma.moodEntry.create({
|
||
data: {
|
||
userId: user.id,
|
||
score: i % 2 === 0 ? 5 : 4,
|
||
mood: "GUT",
|
||
source: "WEB",
|
||
date: ymd,
|
||
moodLoggedAt: at,
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Medication: one med, perfect compliance ──
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: user.id,
|
||
name: "Ramipril",
|
||
dose: "5mg",
|
||
active: true,
|
||
createdAt: new Date(now - 60 * DAY),
|
||
schedules: {
|
||
create: [{ windowStart: "08:00", windowEnd: "10:00" }],
|
||
},
|
||
},
|
||
});
|
||
for (let i = 0; i < 30; i++) {
|
||
// Anchor the take to a fixed wall-clock instant that lands inside the
|
||
// 08:00–10:00 Berlin window (07:30 UTC ≈ 09:30 Berlin) so the slot
|
||
// matcher reads "on-time" deterministically regardless of host TZ —
|
||
// an unaligned `now - i*DAY` shifts its Berlin hour with the runner TZ.
|
||
const scheduledFor = new Date(now - i * DAY);
|
||
scheduledFor.setUTCHours(7, 30, 0, 0);
|
||
await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: user.id,
|
||
medicationId: med.id,
|
||
scheduledFor,
|
||
takenAt: scheduledFor,
|
||
skipped: false,
|
||
},
|
||
});
|
||
}
|
||
|
||
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();
|
||
expect(env.data!.healthScore).not.toBeNull();
|
||
const hs = env.data!.healthScore!;
|
||
expect(hs.band).toBe("green");
|
||
expect(hs.score).toBeGreaterThanOrEqual(75);
|
||
expect(hs.components.bp.value).not.toBeNull();
|
||
expect(hs.components.compliance.value).toBeGreaterThanOrEqual(95);
|
||
});
|
||
|
||
it("scores the BP pillar from the trailing-90-day window when readings predate the last 30 days", async () => {
|
||
// v1.17 W1d regression pin: the BD-Zielbereich tile headline, the
|
||
// Health-Score BP pillar and the coach number all read the SAME
|
||
// trailing-90-day window. A prior shape fed the 30-day value into the
|
||
// score, so an account whose BP readings predate the trailing month
|
||
// lost the 0.30-weight BP pillar entirely and rendered "no rating"
|
||
// despite having recent-enough BP data. This pins the 90-day feed:
|
||
// readings 60–80 days ago sit OUTSIDE the trailing 30 days but INSIDE
|
||
// the 90-day window, so both the pillar and the headline must surface
|
||
// them.
|
||
const prisma = getPrismaClient();
|
||
const user = await seedSession("hs-bp-old");
|
||
|
||
const now = Date.now();
|
||
const DAY = 24 * 60 * 60 * 1000;
|
||
|
||
// 20 paired in-target readings, all ~60–80 days ago: outside the
|
||
// trailing-30-day window, inside the trailing-90-day window the score
|
||
// and headline now share. Well above the 5-reading confidence floor,
|
||
// so the pillar grades rather than suppressing as thin data.
|
||
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;
|
||
};
|
||
expect(env.data!.healthScore).not.toBeNull();
|
||
const hs = env.data!.healthScore!;
|
||
// The BP pillar scores from the 90-day window (recency-weighted).
|
||
// 122/78 sits just inside the under-65 target ceiling (129/79), so the
|
||
// graded clinical-proximity score lands high (worst-of-axis ≈ 86)
|
||
// rather than the old binary 100. The point: the pillar still SCORES
|
||
// from history when the trailing-30-day window is empty — a clearly
|
||
// in-target 90-day history reads as a strong (≥ 80) pillar, not drop
|
||
// out entirely (the regression this test pins).
|
||
expect(hs.components.bp.value).not.toBeNull();
|
||
expect(hs.components.bp.value).toBeGreaterThanOrEqual(80);
|
||
expect(hs.components.bp.weight).toBeGreaterThan(0);
|
||
// …and the tile headline now reads the SAME 90-day window, so it
|
||
// surfaces the in-target rate (all 20 pairs in target = 100 %) rather
|
||
// than dropping to null. The pillar and headline are unified on the
|
||
// window — that is the v1.17 W1d contract.
|
||
expect(env.data!.bpInTargetPct).toBe(100);
|
||
});
|
||
|
||
it("returns null healthScore 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;
|
||
expect(env.data!.healthScore).toBeNull();
|
||
});
|
||
|
||
it("redistributes weights when only compliance is present", async () => {
|
||
const prisma = getPrismaClient();
|
||
const user = await seedSession("hs-compliance-only");
|
||
|
||
const now = Date.now();
|
||
const DAY = 24 * 60 * 60 * 1000;
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: user.id,
|
||
name: "Vitamin D",
|
||
dose: "2000 IU",
|
||
active: true,
|
||
createdAt: new Date(now - 60 * DAY),
|
||
schedules: {
|
||
create: [{ windowStart: "08:00", windowEnd: "10:00" }],
|
||
},
|
||
},
|
||
});
|
||
for (let i = 0; i < 30; i++) {
|
||
// Anchor the take to a fixed wall-clock instant that lands inside the
|
||
// 08:00–10:00 Berlin window (07:30 UTC ≈ 09:30 Berlin) so the slot
|
||
// matcher reads "on-time" deterministically regardless of host TZ —
|
||
// an unaligned `now - i*DAY` shifts its Berlin hour with the runner TZ.
|
||
const scheduledFor = new Date(now - i * DAY);
|
||
scheduledFor.setUTCHours(7, 30, 0, 0);
|
||
await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: user.id,
|
||
medicationId: med.id,
|
||
scheduledFor,
|
||
takenAt: scheduledFor,
|
||
skipped: false,
|
||
},
|
||
});
|
||
}
|
||
|
||
const { GET } = await import("@/app/api/analytics/route");
|
||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||
new Request("http://localhost/api/analytics"),
|
||
);
|
||
const env = (await res.json()) as AnalyticsEnvelope;
|
||
const hs = env.data!.healthScore!;
|
||
expect(hs).not.toBeNull();
|
||
expect(hs.components.bp.value).toBeNull();
|
||
expect(hs.components.weight.value).toBeNull();
|
||
expect(hs.components.mood.value).toBeNull();
|
||
expect(hs.components.compliance.value).not.toBeNull();
|
||
expect(hs.components.compliance.weight).toBeCloseTo(1, 5);
|
||
});
|
||
});
|