From ca0d80b85cbf95a29fc17784f06f05847d049d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 23:00:16 +0100 Subject: [PATCH] test(insights): prove the health status cache against Postgres The route test that pinned the cached answer replaced the database client and the engines, so it saw the calls the route makes and not what a reader gets back. The integration test drives the shipped route: a reading written past the API leaves the second open on the stored answer, while a measurement written through the API and a module switch both show it. --- .../health-status/__tests__/route.test.ts | 113 ------------- tests/integration/health-status-cache.test.ts | 157 ++++++++++++++++++ 2 files changed, 157 insertions(+), 113 deletions(-) delete mode 100644 src/app/api/insights/health-status/__tests__/route.test.ts create mode 100644 tests/integration/health-status-cache.test.ts diff --git a/src/app/api/insights/health-status/__tests__/route.test.ts b/src/app/api/insights/health-status/__tests__/route.test.ts deleted file mode 100644 index 4f2b428e1..000000000 --- a/src/app/api/insights/health-status/__tests__/route.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -/** - * The health status card reads two engines that walk every vital over up to - * 180 days. The answer is kept in the record's analytics cell: a second open - * of Insights serves it, and a write that sweeps the record's cells, or a - * module switch, computes it again. - */ - -const computeCoincidentDeviation = vi.fn(); -const buildExtendedChangepointSignals = vi.fn(); -const resolveModuleMap = vi.fn(); - -vi.mock("@/lib/insights/derived/coincident-deviation", () => ({ - computeCoincidentDeviation: (...args: unknown[]) => - computeCoincidentDeviation(...args), -})); -vi.mock("@/lib/insights/derived/changepoint", () => ({ - buildExtendedChangepointSignals: (...args: unknown[]) => - buildExtendedChangepointSignals(...args), -})); -vi.mock("@/lib/insights/derived", () => ({ - loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }), -})); -vi.mock("@/lib/db", () => ({ - prisma: { - auditLog: { create: vi.fn() }, - appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, - }, -})); -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/modules/gate", async (importOriginal) => ({ - ...(await importOriginal()), - resolveModuleMap: (userId: string) => resolveModuleMap(userId), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkAnalyticsReadRateLimit: vi.fn(), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET } from "../route"; -import { getSession } from "@/lib/auth/session"; -import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; -import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; - -const call = () => GET(); - -beforeEach(() => { - __resetAllCachesForTests(); - vi.mocked(getSession).mockResolvedValue({ - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { - id: "user-1", - username: "testuser", - role: "USER", - timezone: "Europe/Berlin", - }, - } as unknown as Awaited>); - vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ - allowed: true, - } as unknown as Awaited>); - computeCoincidentDeviation - .mockReset() - .mockResolvedValue({ status: "insufficient" }); - buildExtendedChangepointSignals.mockReset().mockResolvedValue([]); - resolveModuleMap.mockReset().mockResolvedValue({}); -}); - -describe("GET /api/insights/health-status — the cached answer", () => { - it("computes once for two opens", async () => { - const first = await call(); - const second = await call(); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1); - expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1); - expect((await second.json()).data).toEqual((await first.json()).data); - }); - - it("computes again after a measurement write", async () => { - await call(); - invalidateUserMeasurements("user-1", { evict: true }); - await call(); - - expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2); - }); - - it("computes again when a module is switched off", async () => { - await call(); - resolveModuleMap.mockResolvedValue({ sleep: false }); - await call(); - - expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2); - expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith( - "user-1", - expect.any(Date), - { sleep: false }, - ); - }); -}); diff --git a/tests/integration/health-status-cache.test.ts b/tests/integration/health-status-cache.test.ts new file mode 100644 index 000000000..42253cf6f --- /dev/null +++ b/tests/integration/health-status-cache.test.ts @@ -0,0 +1,157 @@ +/** + * `GET /api/insights/health-status` answers from the record's analytics cell, + * against Postgres. + * + * Both engines behind the card walk every vital over up to 180 days, so a + * second open of Insights serves the stored answer. A reading written past + * the API (no sweep) is the witness: the cell still holds the answer from + * before it. A measurement written through the API sweeps the cell, and a + * module switch changes the key, so either one shows the reading. All data + * invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getHealthStatus } from "@/app/api/insights/health-status/route"; +import { POST as createMeasurement } from "@/app/api/measurements/route"; +import { PATCH as patchModules } from "@/app/api/auth/me/modules/route"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { makeMeasurement, makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const DAY_MS = 86_400_000; +/** Well above the fortnight's 64 to 68 bpm, so the card reports it. */ +const RAISED_PULSE = 112; + +interface HealthStatusBody { + present: boolean; + deviations: Array<{ type: string; value: number }>; + shifts: unknown[]; + generatedAt: string; +} + +async function readHealthStatus(auth: RouteAuth) { + const res = await callRoute(getHealthStatus, { + method: "GET", + route: "/api/insights/health-status", + auth, + }); + expect(res.status).toBe(200); + return res.data; +} + +/** + * A fortnight of ordinary pulse and weight readings, one per earlier local + * day at noon: the coincident engine needs two banded vitals before it + * reports. + */ +async function seedFortnight(userId: string) { + const todayStart = startOfLocalDayInTz(new Date(), TZ).getTime(); + const rows = []; + for (let d = 14; d >= 1; d -= 1) { + const measuredAt = new Date(todayStart - d * DAY_MS + DAY_MS / 2); + rows.push( + { + userId, + type: "PULSE" as const, + value: 64 + (d % 5), + unit: "bpm", + measuredAt, + source: "MANUAL" as const, + }, + { + userId, + type: "WEIGHT" as const, + value: 80 + (d % 3) * 0.2, + unit: "kg", + measuredAt, + source: "MANUAL" as const, + }, + ); + } + await prisma.measurement.createMany({ data: rows }); +} + +async function signedInWithFortnight() { + const user = await makeUser({ timezone: TZ }); + await seedFortnight(user.id); + return { user, auth: await makeSession(user) }; +} + +const pulseDeviation = (body: HealthStatusBody) => + body.deviations.find((d) => d.type === "PULSE"); + +beforeEach(async () => { + await truncateAllTables(prisma); + __resetAllCachesForTests(); +}); + +describe("GET /api/insights/health-status — the cached answer", () => { + it("serves the second open from the cell", async () => { + const { user, auth } = await signedInWithFortnight(); + const first = await readHealthStatus(auth); + expect(pulseDeviation(first)).toBeUndefined(); + + // Written past the API: nothing sweeps the cell. + await makeMeasurement(user, { + type: "PULSE", + value: RAISED_PULSE, + unit: "bpm", + measuredAt: new Date(Date.now() - 60_000), + }); + const second = await readHealthStatus(auth); + + expect(second).toEqual(first); + }); + + it("computes again after a measurement written through the API", async () => { + const { auth } = await signedInWithFortnight(); + expect(pulseDeviation(await readHealthStatus(auth))).toBeUndefined(); + + const write = await callRoute(createMeasurement, { + method: "POST", + route: "/api/measurements", + body: { + type: "PULSE", + value: RAISED_PULSE, + unit: "bpm", + measuredAt: new Date(Date.now() - 60_000).toISOString(), + }, + auth, + }); + expect(write.status).toBe(201); + + expect(pulseDeviation(await readHealthStatus(auth))?.value).toBeCloseTo( + RAISED_PULSE, + 6, + ); + }); + + it("computes again when a module is switched off", async () => { + const { user, auth } = await signedInWithFortnight(); + expect(pulseDeviation(await readHealthStatus(auth))).toBeUndefined(); + + await makeMeasurement(user, { + type: "PULSE", + value: RAISED_PULSE, + unit: "bpm", + measuredAt: new Date(Date.now() - 60_000), + }); + const patch = await callRoute(patchModules, { + method: "PATCH", + route: "/api/auth/me/modules", + body: { mood: false }, + auth, + }); + expect(patch.status).toBe(200); + + expect(pulseDeviation(await readHealthStatus(auth))?.value).toBeCloseTo( + RAISED_PULSE, + 6, + ); + }); +});