/** * 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)( 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)( 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)( 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)( 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); }); });