Files
HealthLog/tests/integration/analytics-health-score.test.ts
T
Marc-André BombeckandClaude Opus 4.7 236b4986a4 feat(api): emit Health Score from /api/analytics
The route now builds the four-component Health Score input from the
already-fetched BP figure plus a fresh weight + mood + medication
intake fetch (last 30 days, plus a 7-day-shifted snapshot for the
delta). The response gains a `healthScore: { score, band,
components, delta } | null` field; null when no component is
computable so the UI hero panel can hide cleanly.

The wide-event annotation carries `healthScore.score` + `band` +
`delta` so admin-app-logs can attribute coverage without DB queries.

The previous-week BP rate reuses the all-time figure (it's a slow-
moving aggregate; rewinding it cheaply isn't possible without a
full historical re-pair). Delta therefore primarily reflects week-
over-week movement in weight / mood / compliance — the components
that actually move on a weekly cadence.

Integration coverage seeds three synthetic-data shapes against the
postgres testcontainer:
- strong-positive → green band (>= 75)
- empty user → null healthScore
- compliance-only → bp/weight/mood null, compliance weight 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:37:20 +02:00

249 lines
7.3 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++) {
const scheduledFor = new Date(now - i * DAY);
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("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++) {
const scheduledFor = new Date(now - i * DAY);
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);
});
});