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, + ); + }); +});