diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts new file mode 100644 index 000000000..77307fe4a --- /dev/null +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -0,0 +1,306 @@ +/** + * v1.4.35 — unit-level shape-parity pin for the comprehensive route. + * + * The integration coverage (`tests/integration/insights-comprehensive-cache.test.ts`) + * exercises the route end-to-end against the real Postgres testcontainer + * including cache hit/miss. This file mocks the SQL aggregator so the + * envelope shape — exactly what the /insights page consumes — is + * pinned without a container. + * + * What this test covers: + * - Every key the legacy route emitted is still on the response. + * - Empty user → bmi=null, every correlation null, scatter arrays + * empty, totalMeasurements=0, alerts is an array. + * - The route reads from the aggregator (not a 100k-row findMany). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + auditLog: { create: vi.fn() }, + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Mock the aggregator so we feed a controlled fixture without going +// near Postgres. The route's own assembly logic (correlations, +// classifications, alerts) is what we want to pin. +vi.mock("@/lib/insights/comprehensive-aggregator", () => ({ + buildComprehensiveAggregate: vi.fn(), +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(async () => ({ type: "none" })), +})); + +vi.mock("@/lib/medication-category", () => ({ + getMedicationCategories: vi.fn(async () => ({})), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-comp-1", + username: "marc", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest("http://localhost/api/insights/comprehensive"); +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + // Default to assistant-on so the gate doesn't 403 every test. + ( + prisma.appSettings.findUnique as ReturnType + ).mockResolvedValue(null); + (prisma.user.findUnique as ReturnType).mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1985-01-01"), + }); + (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + (prisma.medication.findMany as ReturnType).mockResolvedValue( + [], + ); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue([]); +}); + +describe("GET /api/insights/comprehensive — envelope shape", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(401); + }); + + it("emits every legacy envelope key for an empty user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue({ + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + dailyByType: {}, + firstMeasurementAt: null, + totalMeasurements: 0, + }); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + + // Every legacy key — the consumer (`/insights` page) reads each of + // these. If any one disappears the page silently breaks. + const requiredKeys = [ + "summaries", + "bmi", + "bmiClassification", + "bpClassification", + "bpPctInTarget", + "bpTargets", + "weightBpCorrelation", + "scatterData", + "bpMedicationCorrelation", + "bpMedicationScatterData", + "moodSummary", + "moodBpCorrelation", + "moodBpScatterData", + "moodWeightCorrelation", + "moodWeightScatterData", + "moodPulseCorrelation", + "moodPulseScatterData", + "medications", + "alerts", + "hasProvider", + "dataSpanDays", + "totalMeasurements", + ]; + for (const key of requiredKeys) { + expect(body.data).toHaveProperty(key); + } + + expect(body.data.bmi).toBeNull(); + expect(body.data.bmiClassification).toBeNull(); + expect(body.data.scatterData).toEqual([]); + expect(body.data.bpMedicationScatterData).toEqual([]); + expect(body.data.medications).toEqual([]); + expect(Array.isArray(body.data.alerts)).toBe(true); + expect(body.data.totalMeasurements).toBe(0); + expect(body.data.dataSpanDays).toBe(0); + }); + + it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue({ + summaries: { + WEIGHT: { + count: 1, + latest: 81.0, + min: 81.0, + max: 81.0, + mean: 81.0, + avg7: 81.0, + avg30: 81.0, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }, + }, + bpRawRows: { sys: [], dia: [] }, + dailyByType: {}, + firstMeasurementAt: new Date(), + totalMeasurements: 1, + }); + + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { bmi: number | null; bmiClassification: { category: string } }; + }; + // BMI = 81 / (1.80 ^ 2) = 25.0 → "Overweight" in classifyBMI. + expect(body.data.bmi).toBeCloseTo(25, 1); + expect(body.data.bmiClassification.category).toBe("Overweight"); + }); + + it("pairs sys + dia raw rows with 5-min tolerance for bpPctInTarget", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const at = new Date("2026-05-10T08:00:00Z"); + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue({ + summaries: { + BLOOD_PRESSURE_SYS: { + count: 1, + latest: 125, + min: 125, + max: 125, + mean: 125, + avg7: 125, + avg30: 125, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }, + BLOOD_PRESSURE_DIA: { + count: 1, + latest: 75, + min: 75, + max: 75, + mean: 75, + avg7: 75, + avg30: 75, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }, + }, + bpRawRows: { + sys: [{ measuredAt: at, value: 125 }], + dia: [{ measuredAt: at, value: 75 }], + }, + dailyByType: {}, + firstMeasurementAt: at, + totalMeasurements: 2, + }); + + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + bpPctInTarget: number | null; + bpClassification: { category: string } | null; + }; + }; + // 125/75 sits inside the under-65 target ceiling (sysHigh=129, + // diaHigh=79) and above the hypotension floor → 100% in target. + expect(body.data.bpPctInTarget).toBe(100); + // avg30 sys=125, dia=75 → ESH "Normal" band. + expect(body.data.bpClassification?.category).toBe("Normal"); + }); + + it("derives weight × BP scatter from daily-bucketed series", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation). + const days = [ + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + ]; + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue({ + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + dailyByType: { + WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })), + BLOOD_PRESSURE_SYS: days.map((d, i) => ({ day: d, value: 120 + i * 2 })), + }, + firstMeasurementAt: new Date(), + totalMeasurements: 10, + }); + + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + scatterData: Array<{ weight: number; sysBP: number }>; + weightBpCorrelation: { r: number; n: number } | null; + }; + }; + expect(body.data.scatterData).toHaveLength(5); + expect(body.data.scatterData[0]).toEqual({ weight: 80, sysBP: 120 }); + // Perfect linear relationship → r === 1. + expect(body.data.weightBpCorrelation?.r).toBe(1); + expect(body.data.weightBpCorrelation?.n).toBe(5); + }); +}); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index cd7e6d8cb..b8624c093 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,7 +1,8 @@ import { prisma } from "@/lib/db"; import { resolveProvider } from "@/lib/ai/provider"; import { apiSuccess } from "@/lib/api-response"; -import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; +import { summarize } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { @@ -16,11 +17,11 @@ import { } from "@/lib/analytics/correlations"; import { calculateCompliance } from "@/lib/analytics/compliance"; import { getMedicationCategories } from "@/lib/medication-category"; -import type { MeasurementType } from "@/generated/prisma/client"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; export const dynamic = "force-dynamic"; @@ -30,10 +31,41 @@ export const GET = apiHandler(async () => { // recommendations grid that share the Coach gate. await requireAssistantSurface("coach"); + // v1.4.35 — read-through the analytics cache keyed on + // (userId, "comprehensive"). The /insights page mount routinely + // fans out to this endpoint alongside the Coach drawer + the + // recommendations grid; the 60s TTL converts every duplicate + // mount within the window to a Map lookup. Invalidation is handled + // by `invalidateUserMeasurements` which already evicts every key + // under the `${userId}|` prefix (see `lib/cache/invalidate.ts`). + const body = await cached( + caches.analytics as ServerCache + >>, + `${user.id}|comprehensive`, + () => buildComprehensiveResponse(user), + annotate, + ); + + return apiSuccess(body); +}); + +type AuthedUser = AuthContext["user"]; + +/** + * v1.4.35 — comprehensive insights response body, lifted out of the + * route handler so `cached()` can wrap it. Replaces the previous + * 100k-row-in-JS aggregation with SQL-side aggregates served by + * `buildComprehensiveAggregate`. The medication-compliance block keeps + * its bounded Prisma reads (medications + intake events) — the directive + * called those out as already on the safe path. The byte-shape of the + * returned envelope is unchanged from the legacy route. + */ +export async function buildComprehensiveResponse(user: AuthedUser) { const userId = user.id; const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - // Fetch user profile + // Fetch user profile (height + DOB drive BMI + BP targets). const dbUser = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -42,24 +74,26 @@ export const GET = apiHandler(async () => { }, }); - // Derived from canonical enum so adding a new measurement type does not - // require touching this file (V3 audit finding: enum drift cousins). - const types = [...measurementTypeEnum.options] as MeasurementType[]; + // ── Aggregate-first reads ───────────────────────────────── + // SQL-side rollup replaces the 100k-row findMany on the legacy + // route. Returns per-type DataSummary identical to the JS + // `summarize()` semantics over the 90-day window, plus the bounded + // raw rows the BP target adherence pairing needs, plus daily-mean + // buckets for the correlation pairings. + const aggregate = await buildComprehensiveAggregate(userId); - const allMeasurements = await prisma.measurement.findMany({ - where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } }, - orderBy: { measuredAt: "asc" }, - select: { type: true, value: true, measuredAt: true }, - }); - - // Fetch mood entries (90 days) + // Mood entries are user-recorded; the table is small and the + // `moodSummary` field requires raw scores (`summarize()` over the + // raw score series). The findMany stays. const moodEntries = await prisma.moodEntry.findMany({ where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } }, orderBy: { moodLoggedAt: "asc" }, select: { date: true, score: true, moodLoggedAt: true }, }); - // Aggregate mood to daily averages + // Aggregate mood to daily averages (drives the mood × metric + // correlations). Bucket key matches the legacy code's `date` column + // semantics (YYYY-MM-DD anchored to the user's tz at write time). const moodByDay = new Map(); for (const entry of moodEntries) { const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; @@ -74,7 +108,9 @@ export const GET = apiHandler(async () => { value: Math.round((stats.sum / stats.count) * 100) / 100, })); - // Build DataPoint array for mood summary + // moodSummary uses the same `summarize()` helper over raw scores — + // the table is bounded by user-recorded entries so the in-JS path + // is safe. const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({ date: e.moodLoggedAt, value: e.score, @@ -82,21 +118,9 @@ export const GET = apiHandler(async () => { const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; - const byType = (t: MeasurementType): DataPoint[] => - allMeasurements - .filter((m) => m.type === t) - .map((m) => ({ date: m.measuredAt, value: m.value })); + const summaries: Record = aggregate.summaries; - // Summaries - const summaries: Record> = {}; - for (const t of types) { - const data = byType(t); - if (data.length > 0) { - summaries[t] = summarize(data); - } - } - - // BMI + // ── BMI ────────────────────────────────────────────────── let bmi: number | null = null; let bmiClassification = null; if (dbUser?.heightCm && summaries.WEIGHT?.latest) { @@ -105,7 +129,7 @@ export const GET = apiHandler(async () => { bmiClassification = classifyBMI(bmi); } - // BP classification (30-day average) + // ── BP classification (30-day average) ──────────────────── let bpClassification = null; const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); if ( @@ -118,11 +142,21 @@ export const GET = apiHandler(async () => { ); } - // BP target adherence + // ── BP target adherence ────────────────────────────────── + // Keeps the 5-minute tolerance contract — sys + dia must pair on + // the same reading session. The aggregator pulled the bounded raw + // rows; we reuse `pairByTimestamp` against them so the output is + // byte-identical to the legacy path. let bpPctInTarget: number | null = null; if (bpTargets) { - const sysData = byType("BLOOD_PRESSURE_SYS"); - const diaData = byType("BLOOD_PRESSURE_DIA"); + const sysData: DataPoint[] = aggregate.bpRawRows.sys.map((r) => ({ + date: r.measuredAt, + value: r.value, + })); + const diaData: DataPoint[] = aggregate.bpRawRows.dia.map((r) => ({ + date: r.measuredAt, + value: r.value, + })); const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000); if (sysPairs.length > 0) { @@ -135,82 +169,48 @@ export const GET = apiHandler(async () => { } } - // Correlations: Weight vs Sys BP - const weightData = byType("WEIGHT"); - const sysData = byType("BLOOD_PRESSURE_SYS"); - const weightBpPairs = pairByTimestamp(weightData, sysData); + // ── Correlations: weight × Sys BP ───────────────────────── + // Daily-key pairing (the legacy `pairByTimestamp` ran with a + // 24-hour default tolerance — daily-mean buckets are the + // SQL-side equivalent and the directive explicitly accepts the + // small semantic shift to bound the raw-row footprint). + const weightDaily = aggregate.dailyByType.WEIGHT ?? []; + const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; + const weightBpPairs: PairedPoint[] = joinDailyByDay( + weightDaily, + sysDaily, + ); const weightBpCorrelation = pearsonCorrelation(weightBpPairs); const scatterData = weightBpPairs.map((p) => ({ weight: p.a, sysBP: p.b, })); - // Correlations: Mood vs metrics (using daily averages matched by date) - function buildMoodMetricPairs( - dailyMood: Array<{ day: string; value: number }>, - measurements: Array<{ measuredAt: Date; value: number }>, - ): PairedPoint[] { - // Group measurements by day - const metricByDay = new Map(); - for (const m of measurements) { - const dayKey = m.measuredAt.toISOString().slice(0, 10); - const current = metricByDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += m.value; - current.count += 1; - metricByDay.set(dayKey, current); - } - - const pairs: PairedPoint[] = []; - for (const mood of dailyMood) { - const metric = metricByDay.get(mood.day); - if (metric) { - pairs.push({ - a: mood.value, - b: Math.round((metric.sum / metric.count) * 100) / 100, - date: new Date(`${mood.day}T12:00:00.000Z`), - }); - } - } - return pairs; - } - - // Mood vs Systolic BP - const moodBpPairs = buildMoodMetricPairs( - dailyMoodEntries, - allMeasurements - .filter((m) => m.type === "BLOOD_PRESSURE_SYS") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - ); + // ── Correlations: mood × {sys BP, weight, pulse} ───────── + // Daily-key matches the legacy `buildMoodMetricPairs` exactly — + // mood is already per-day in the legacy code and we just align the + // metric side to the same daily bucket. + const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDaily); const moodBpCorrelation = pearsonCorrelation(moodBpPairs); const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); - // Mood vs Weight - const moodWeightPairs = buildMoodMetricPairs( - dailyMoodEntries, - allMeasurements - .filter((m) => m.type === "WEIGHT") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - ); + const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDaily); const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs); const moodWeightScatterData = moodWeightPairs.map((p) => ({ mood: p.a, weight: p.b, })); - // Mood vs Pulse - const moodPulsePairs = buildMoodMetricPairs( - dailyMoodEntries, - allMeasurements - .filter((m) => m.type === "PULSE") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - ); + const pulseDaily = aggregate.dailyByType.PULSE ?? []; + const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily); const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs); const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, pulse: p.b, })); - // Medication compliance + // ── Medication compliance ──────────────────────────────── + // Bounded reads — left untouched per the directive. const medications = await prisma.medication.findMany({ where: { userId, active: true }, include: { schedules: true }, @@ -229,9 +229,7 @@ export const GET = apiHandler(async () => { (med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE", ); // Single round-trip for all medications instead of N+1: one query keyed - // on `medicationId IN (...)`, then group in memory. The previous loop - // hit Postgres once per medication, which scales poorly for users with - // many active meds. + // on `medicationId IN (...)`, then group in memory. const allEvents = medications.length ? await prisma.medicationIntakeEvent.findMany({ where: { @@ -284,7 +282,11 @@ export const GET = apiHandler(async () => { } } - // Correlation: continuity of BP medications vs systolic BP + // ── Correlation: BP medication continuity × systolic BP ── + // Uses the same daily BP_SYS series the aggregator pre-computed — + // no second round-trip. The intake-side aggregation matches the + // legacy code: continuity = min(1, taken / expectedPerDay), pairing + // every day that had a sys reading. let bpMedicationCorrelation: { r: number; strength: string; @@ -302,13 +304,10 @@ export const GET = apiHandler(async () => { ); if (expectedBpIntakesPerDay > 0) { - const sysByDay = new Map(); - for (const m of allMeasurements) { - if (m.type !== "BLOOD_PRESSURE_SYS") continue; - const dayKey = m.measuredAt.toISOString().slice(0, 10); - const list = sysByDay.get(dayKey) ?? []; - list.push(m.value); - sysByDay.set(dayKey, list); + // Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass. + const sysByDay = new Map(); + for (const row of sysDaily) { + sysByDay.set(row.day, row.value); } const takenByDay = new Map(); @@ -319,11 +318,9 @@ export const GET = apiHandler(async () => { } const pairs: Array<{ a: number; b: number; date: Date }> = []; - for (const [dayKey, sysValues] of sysByDay.entries()) { + for (const [dayKey, avgSys] of sysByDay.entries()) { const taken = takenByDay.get(dayKey) ?? 0; const continuity = Math.min(1, taken / expectedBpIntakesPerDay); - const avgSys = - sysValues.reduce((sum, value) => sum + value, 0) / sysValues.length; pairs.push({ a: continuity, b: avgSys, @@ -360,25 +357,24 @@ export const GET = apiHandler(async () => { })), }); - // Data span - const firstMeasurement = - allMeasurements.length > 0 ? allMeasurements[0].measuredAt : null; - const dataSpanDays = firstMeasurement + // Data span — derived from the earliest 90-day measurement. + const dataSpanDays = aggregate.firstMeasurementAt ? Math.ceil( - (Date.now() - firstMeasurement.getTime()) / (24 * 60 * 60 * 1000), + (Date.now() - aggregate.firstMeasurementAt.getTime()) / + (24 * 60 * 60 * 1000), ) : 0; annotate({ action: { name: "insights.comprehensive" }, meta: { - totalMeasurements: allMeasurements.length, + totalMeasurements: aggregate.totalMeasurements, moodEntries: moodEntries.length, medications: medications.length, }, }); - return apiSuccess({ + return { summaries, bmi, bmiClassification, @@ -400,6 +396,75 @@ export const GET = apiHandler(async () => { alerts, hasProvider: (await resolveProvider(userId)).type !== "none", dataSpanDays, - totalMeasurements: allMeasurements.length, - }); -}); + totalMeasurements: aggregate.totalMeasurements, + }; +} + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/** + * Daily-key inner join between two daily-aggregated series. Returns + * `PairedPoint` shapes compatible with `pearsonCorrelation`. + * + * Inputs are sorted ASC by `day` (the aggregator's ORDER BY + * guarantees that). The walk is O(n + m) merge-join style instead of + * Map.lookup-per-element so it scales with the worst-case 90 buckets + * x 90 buckets case without surprises. + */ +function joinDailyByDay( + a: Array<{ day: string; value: number }>, + b: Array<{ day: string; value: number }>, +): PairedPoint[] { + const pairs: PairedPoint[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + const ai = a[i]; + const bj = b[j]; + if (ai.day === bj.day) { + pairs.push({ + a: ai.value, + b: bj.value, + date: new Date(`${ai.day}T12:00:00.000Z`), + }); + i += 1; + j += 1; + } else if (ai.day < bj.day) { + i += 1; + } else { + j += 1; + } + } + return pairs; +} + +/** + * Pair the daily mood series (left side) with a daily-aggregated + * metric (right side). Matches the legacy `buildMoodMetricPairs` + * helper byte-for-byte: same daily YYYY-MM-DD key, same + * `T12:00:00.000Z` anchor for the `date` field. + */ +function pairMoodWithDaily( + dailyMood: Array<{ day: string; value: number }>, + dailyMetric: Array<{ day: string; value: number }>, +): PairedPoint[] { + if (dailyMood.length === 0 || dailyMetric.length === 0) return []; + const metricByDay = new Map(); + for (const m of dailyMetric) { + metricByDay.set(m.day, m.value); + } + const pairs: PairedPoint[] = []; + for (const mood of dailyMood) { + const metric = metricByDay.get(mood.day); + if (metric !== undefined) { + pairs.push({ + a: mood.value, + b: metric, + date: new Date(`${mood.day}T12:00:00.000Z`), + }); + } + } + return pairs; +} diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts new file mode 100644 index 000000000..09c2a6948 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts @@ -0,0 +1,175 @@ +/** + * v1.4.35 — unit pin for `buildComprehensiveAggregate`. + * + * Heavier integration coverage (real Postgres, real `regr_slope`, + * cache hit/miss assertions) lives in + * `tests/integration/insights-comprehensive-cache.test.ts`. This file + * mocks `$queryRaw` and Prisma's `measurement.findMany` so the shape + + * rounding + slope-direction contracts are pinned without a container. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + measurement: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { buildComprehensiveAggregate } from "../comprehensive-aggregator"; + +const RAW = prisma.$queryRaw as unknown as ReturnType; +const FIND_MANY = + prisma.measurement.findMany as unknown as ReturnType; + +beforeEach(() => { + RAW.mockReset(); + FIND_MANY.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildComprehensiveAggregate", () => { + it("returns an empty bundle for a user with no measurements", async () => { + // Pass order: aggregates, latests, daily, sysRaw, diaRaw. The + // `firstMeasurementAt` query is skipped when totalMeasurements === 0. + RAW.mockResolvedValueOnce([]) // aggregates + .mockResolvedValueOnce([]) // latests + .mockResolvedValueOnce([]); // daily + FIND_MANY.mockResolvedValueOnce([]) // sys + .mockResolvedValueOnce([]); // dia + + const result = await buildComprehensiveAggregate("user-empty"); + + expect(result.summaries).toEqual({}); + expect(result.bpRawRows.sys).toEqual([]); + expect(result.bpRawRows.dia).toEqual([]); + expect(result.dailyByType).toEqual({}); + expect(result.firstMeasurementAt).toBeNull(); + expect(result.totalMeasurements).toBe(0); + // 3 $queryRaw calls (aggregates, latests, daily) + 2 findMany. + // No firstMeasurementAt query when total === 0. + expect(RAW).toHaveBeenCalledTimes(3); + expect(FIND_MANY).toHaveBeenCalledTimes(2); + }); + + it("maps a populated WEIGHT aggregate into the DataSummary shape", async () => { + const now = new Date(); + RAW.mockResolvedValueOnce([ + { + type: "WEIGHT", + count: BigInt(42), + min_value: 79.2, + max_value: 84.1, + mean_value: 82.05, + stddev_value: 1.2, + anomaly_count: BigInt(3), + avg7: 81.9, + avg30: 82.1, + avg30_last_month: 83.0, + slope7: -0.014, + r2_7: 0.65, + slope30: -0.005, + r2_30: 0.42, + slope90: 0.001, + r2_90: 0.12, + }, + ]) + .mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4, measured_at: now }]) + .mockResolvedValueOnce([ + { type: "WEIGHT", day: "2026-05-10", mean_value: 82.0 }, + { type: "WEIGHT", day: "2026-05-11", mean_value: 82.2 }, + ]) + .mockResolvedValueOnce([{ first_at: new Date(now.getTime() - 86400000) }]); + FIND_MANY.mockResolvedValueOnce([]) // sys + .mockResolvedValueOnce([]); // dia + + const result = await buildComprehensiveAggregate("user-pop"); + const weight = result.summaries.WEIGHT; + + expect(weight.count).toBe(42); + expect(weight.latest).toBe(81.4); + expect(weight.min).toBe(79.2); + expect(weight.max).toBe(84.1); + expect(weight.mean).toBe(82.05); + expect(weight.avg7).toBe(81.9); + expect(weight.avg30).toBe(82.1); + expect(weight.avg30LastMonth).toBe(83.0); + // Always null in the 90-day window — preserved legacy semantics. + expect(weight.avg30LastYear).toBeNull(); + expect(weight.anomalyCount).toBe(3); + // Slope direction matches the JS threshold rules: + // -0.014 → "down" (|s| >= 0.01) + // -0.005 → "stable" (|s| < 0.01) + // 0.001 → "stable" + expect(weight.slope7).toEqual({ + slope: -0.014, + direction: "down", + confidence: 0.65, + }); + expect(weight.slope30).toEqual({ + slope: -0.005, + direction: "stable", + confidence: 0.42, + }); + expect(weight.slope90).toEqual({ + slope: 0.001, + direction: "stable", + confidence: 0.12, + }); + + expect(result.totalMeasurements).toBe(42); + expect(result.firstMeasurementAt).toBeInstanceOf(Date); + expect(result.dailyByType.WEIGHT).toEqual([ + { day: "2026-05-10", value: 82.0 }, + { day: "2026-05-11", value: 82.2 }, + ]); + }); + + it("threads sys/dia raw rows through bpRawRows so 5-min pairing survives", async () => { + const measuredAt = new Date("2026-05-10T08:00:00Z"); + RAW.mockResolvedValueOnce([ + { + type: "BLOOD_PRESSURE_SYS", + count: BigInt(1), + min_value: 120, + max_value: 120, + mean_value: 120, + stddev_value: 0, + anomaly_count: BigInt(0), + avg7: 120, + avg30: 120, + avg30_last_month: null, + slope7: null, + r2_7: null, + slope30: null, + r2_30: null, + slope90: null, + r2_90: null, + }, + ]) + .mockResolvedValueOnce([ + { + type: "BLOOD_PRESSURE_SYS", + value: 120, + measured_at: measuredAt, + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ first_at: measuredAt }]); + FIND_MANY.mockResolvedValueOnce([ + { measuredAt, value: 120 }, + ]).mockResolvedValueOnce([{ measuredAt, value: 80 }]); + + const result = await buildComprehensiveAggregate("user-bp"); + expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]); + expect(result.bpRawRows.dia).toEqual([{ measuredAt, value: 80 }]); + }); +}); diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts new file mode 100644 index 000000000..f9ccb9d66 --- /dev/null +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -0,0 +1,373 @@ +/** + * v1.4.35 — SQL-driven aggregator for `/api/insights/comprehensive`. + * + * Background + * ---------- + * The route previously pulled every measurement of every type from the + * trailing 90 days into JS, then ran `summarize()` per type plus a + * pile of in-JS correlation pairings. Post Apple-Health import the + * unbounded `findMany` on line 49 of the old route routinely returned + * 100 000+ rows and dominated request time (29 s in the v1.4.34 prod + * HAR capture, with sibling endpoints cascading into 503s on pool + * starvation). + * + * The new shape mirrors the `summaries-slice` aggregator + * (`src/lib/analytics/summaries-slice.ts`) the v1.4.33 C1 dashboard + * tile strip already proved out: a single `$queryRaw` per surface, + * grouped by `type`, with Postgres-side `AVG` / `MIN` / `MAX` / + * `REGR_SLOPE` / `REGR_R2` against the index path + * `measurements (user_id, type, measured_at)`. Difference vs. the + * slim slice: the comprehensive endpoint also needs `anomalyCount`, + * `avg30LastMonth`, `avg30LastYear`, and the 90-day window cap. + * + * Scope notes + * ----------- + * - The 90-day window is preserved (the legacy `findMany` clamped on + * `measuredAt >= ninetyDaysAgo`). `min` / `max` / `mean` therefore + * describe the 90-day window, NOT the user's all-time range — + * identical to the old behaviour. + * + * - For correlations: + * - **weight × BP**: directive accepts a daily-key swap from the + * old `pairByTimestamp` (24h default tolerance). Postgres + * joins on `date_trunc('day', measured_at)`. + * - **mood × {BP, weight, pulse}**: same daily-key shape the + * legacy code already used (the `buildMoodMetricPairs` helper + * bucketed by `toISOString().slice(0,10)`). + * + * - BP target adherence (`bpPctInTarget`) keeps the 5-minute + * tolerance contract — that pairing is sys/dia within the *same* + * reading session. The aggregator pulls sys+dia raw rows (90d, + * bounded — typically <2k rows even for power users) so + * `pairByTimestamp` semantics survive byte-identical. + * + * - `bpMedicationCorrelation` is computed in the route itself; the + * aggregator surfaces daily BP_SYS means via the same daily-join + * query so the medication block can pair compliance × systolic + * without a second findMany. + * + * - Mood data still loads via `prisma.moodEntry.findMany` — that + * table is small (a row per user-recorded entry, bounded) and the + * `moodSummary` field calls `summarize()` against raw scores. The + * legacy route's `moodSummary` was built off raw rows so we keep + * the raw fetch. + * + * Output shape + * ------------ + * The aggregator returns the bundle of pre-computed values the route + * stitches into the final response. Helper functions inside the + * aggregator collapse `$queryRaw` rows into the same `DataSummary` + * shape `summarize()` would return. + */ +import { prisma } from "@/lib/db"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * Raw row from the aggregate query. Keyed on `MeasurementType`, with + * windowed AVG/SLOPE/R2 expressions plus anomaly-detection inputs + * (window-wide mean + stddev so we can compute the |z| > 2 count + * client-side without a second pass over the raw rows). + */ +interface AggregateRow { + type: string; + count: bigint; + min_value: number | null; + max_value: number | null; + mean_value: number | null; + stddev_value: number | null; + anomaly_count: bigint; + avg7: number | null; + avg30: number | null; + avg30_last_month: number | null; + slope7: number | null; + r2_7: number | null; + slope30: number | null; + r2_30: number | null; + slope90: number | null; + r2_90: number | null; +} + +/** Latest raw value per type (DISTINCT ON over the 90-day window). */ +interface LatestRow { + type: string; + value: number; + measured_at: Date; +} + +/** Daily-mean per (type, day) for correlation pairings. */ +export interface DailyAggregateRow { + type: string; + day: string; + mean_value: number; +} + +/** + * Result bundle. Everything the route handler needs to assemble the + * comprehensive response without holding the 100k-row raw measurement + * array in memory. + */ +export interface ComprehensiveAggregate { + /** Per-type DataSummary, 90-day window. Only types with rows are + * populated — matches the legacy route's behaviour (only types + * with rows landed on `summaries[t]`). */ + summaries: Record; + /** Sys/dia raw rows for the BP target adherence path. Bounded: + * typically <2k rows per user over 90 days even with Apple Health. */ + bpRawRows: { + sys: Array<{ measuredAt: Date; value: number }>; + dia: Array<{ measuredAt: Date; value: number }>; + }; + /** Daily means per (type, day) for the correlation pairings. The + * route filters down to the types it needs (WEIGHT, BLOOD_PRESSURE_SYS, + * PULSE) when joining against mood / weight / bp. */ + dailyByType: Record>; + /** Earliest measurement timestamp across the 90-day window — feeds + * the route's `dataSpanDays` field. NULL when the user has no rows. */ + firstMeasurementAt: Date | null; + /** Total row count across all types in the 90-day window — feeds the + * wide-event meta + the response's `totalMeasurements` field. */ + totalMeasurements: number; +} + +/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */ +function buildSlope( + slope: number | null, + rSquared: number | null, +): DataSummary["slope7"] { + if (slope === null) return null; + const threshold = 0.01; + const direction: "up" | "down" | "stable" = + Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down"; + return { + slope: Math.round(slope * 1000) / 1000, + direction, + confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100, + }; +} + +function round2(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) return null; + return Math.round(value * 100) / 100; +} + +/** + * Build the per-type `DataSummary` bundle for the comprehensive route. + * + * Three SQL passes against the same `(user_id, type, measured_at)` + * index, all targeting the trailing 90 days: + * + * 1. Per-type aggregate (count + min/max/mean/stddev + windowed + * avgs + regr_slope/r2 for the 7/30/90-day slopes). + * 2. `DISTINCT ON (type)` for the most-recent raw value per type. + * 3. Per-type-per-day mean (`date_trunc('day', measured_at)`) for the + * correlation pairings and the daily-bucket consumers. + * + * Plus two bounded raw-row queries for sys / dia (BP target pairing + * needs sub-day timestamps, can't be replaced by a daily mean). + */ +export async function buildComprehensiveAggregate( + userId: string, +): Promise { + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + + const [aggregates, latests, dailyRows, sysRaw, diaRaw] = await Promise.all([ + prisma.$queryRaw` + WITH window_stats AS ( + SELECT + m."type", + AVG(m."value") AS mean_value, + STDDEV_POP(m."value") AS stddev_value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."measured_at" >= ${ninetyDaysAgo} + GROUP BY m."type" + ) + SELECT + m."type"::text AS type, + COUNT(*) AS count, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + AVG(m."value")::double precision AS mean_value, + ws.stddev_value::double precision AS stddev_value, + -- |z| > 2 anomaly count over the 90-day window. STDDEV_POP + -- matches the JS helper which divides by n (not n-1). + COUNT(*) FILTER ( + WHERE ws.stddev_value IS NOT NULL + AND ws.stddev_value > 0 + AND ABS( + (ROUND(((m."value" - ws.mean_value) / ws.stddev_value)::numeric, 2)) + ) > 2 + ) AS anomaly_count, + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS avg7, + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS avg30, + -- Prior 30d window (days [30, 60) ago) used by tile delta callout. + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '60 days' + AND m."measured_at" < NOW() - INTERVAL '30 days' + )::double precision AS avg30_last_month, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS slope7, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS r2_7, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS slope30, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS r2_30, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + )::double precision AS slope90, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + )::double precision AS r2_90 + FROM measurements m + JOIN window_stats ws ON ws."type" = m."type" + WHERE m."user_id" = ${userId} + AND m."measured_at" >= ${ninetyDaysAgo} + GROUP BY m."type", ws.stddev_value + `, + prisma.$queryRaw` + SELECT DISTINCT ON (m."type") + m."type"::text AS type, + m."value"::double precision AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."measured_at" >= ${ninetyDaysAgo} + ORDER BY m."type", m."measured_at" DESC + `, + prisma.$queryRaw` + SELECT + m."type"::text AS type, + TO_CHAR(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') AS day, + (ROUND((AVG(m."value"))::numeric, 2))::double precision AS mean_value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."measured_at" >= ${ninetyDaysAgo} + AND m."type" IN ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'PULSE') + GROUP BY m."type", date_trunc('day', m."measured_at") + ORDER BY m."type", day ASC + `, + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_PRESSURE_SYS", + measuredAt: { gte: ninetyDaysAgo }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_PRESSURE_DIA", + measuredAt: { gte: ninetyDaysAgo }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + ]); + + // ── avg30LastYear ───────────────────────────────────────── + // The 90-day window guarantees this is null (no rows >= 365 days + // old will appear) — preserves legacy semantics. + + const latestByType = new Map(); + for (const row of latests) { + latestByType.set(row.type, { + value: Number(row.value), + measuredAt: new Date(row.measured_at), + }); + } + + // Seed only types that actually have rows — the legacy route only + // populated `summaries[t]` when `data.length > 0`. + const summaries: Record = {}; + let totalMeasurements = 0; + let firstMeasurementAt: Date | null = null; + + for (const row of aggregates) { + const count = Number(row.count); + totalMeasurements += count; + const latest = latestByType.get(row.type); + summaries[row.type] = { + count, + latest: latest?.value ?? null, + min: round2(row.min_value), + max: round2(row.max_value), + mean: round2(row.mean_value), + avg7: round2(row.avg7), + avg30: round2(row.avg30), + slope7: buildSlope(row.slope7, row.r2_7), + slope30: buildSlope(row.slope30, row.r2_30), + slope90: buildSlope(row.slope90, row.r2_90), + anomalyCount: Number(row.anomaly_count), + avg30LastMonth: round2(row.avg30_last_month), + // Legacy semantics: the 90-day findMany window guarantees no + // rows from a year ago. Always null. + avg30LastYear: null, + }; + } + + // ── firstMeasurementAt ─────────────────────────────────── + // Take the earliest `measuredAt` across the user's 90-day rows. + // One extra cheap aggregate query keeps the route's `dataSpanDays` + // truthful. Run lazily: skip when no aggregate rows came back + // (totalMeasurements === 0). + if (totalMeasurements > 0) { + const firstRows = await prisma.$queryRaw>` + SELECT MIN(m."measured_at") AS first_at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."measured_at" >= ${ninetyDaysAgo} + `; + firstMeasurementAt = firstRows[0]?.first_at + ? new Date(firstRows[0].first_at) + : null; + } + + // ── daily-by-type ──────────────────────────────────────── + const dailyByType: Record> = {}; + for (const row of dailyRows) { + const list = dailyByType[row.type] ?? []; + list.push({ day: row.day, value: Number(row.mean_value) }); + dailyByType[row.type] = list; + } + + return { + summaries, + bpRawRows: { + sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), + dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), + }, + dailyByType, + firstMeasurementAt, + totalMeasurements, + }; +} + diff --git a/tests/integration/insights-comprehensive-cache.test.ts b/tests/integration/insights-comprehensive-cache.test.ts new file mode 100644 index 000000000..4333eb9e5 --- /dev/null +++ b/tests/integration/insights-comprehensive-cache.test.ts @@ -0,0 +1,282 @@ +/** + * v1.4.35 — integration coverage for the comprehensive insights cache. + * + * Pins two contracts the unit suite cannot reach: + * + * 1. **Read-through cache.** Priming with one call counts as a + * miss; the second call inside the 60-second TTL counts as a hit + * and annotates `cache.analytics.outcome === "hit"` on the route's + * wide event. + * + * 2. **SQL aggregation parity end-to-end.** The route's envelope + * includes every legacy key (summaries / bmi / bp* / mood* / + * medications / alerts / hasProvider / dataSpanDays / + * totalMeasurements) computed from a fixture of ~50 measurement + * rows. The byte-shape matches what the consumer (/insights page) + * reads on first paint. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +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), +})); + +// `resolveProvider` reads `app_settings.default_locale` which trails the +// production schema in this integration env. Mock the leaf: the route +// only uses `provider.type !== "none"` to set the `hasProvider` flag. +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), +})); + +import { clearLogBuffer, readLogBuffer } from "@/lib/logging/in-memory-buffer"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + clearLogBuffer(); + const { __resetAllCachesForTests } = await import( + "@/lib/cache/server-cache" + ); + __resetAllCachesForTests(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + heightCm: 180, + dateOfBirth: new Date("1985-01-01"), + }, + }); + 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/insights/comprehensive — server cache", () => { + it("returns a hit on the second call inside the TTL", async () => { + const user = await seedSession("comp-cache-hit-user"); + const prisma = getPrismaClient(); + + // Seed ~50 rows so the aggregator has data to return. + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const weightRows = Array.from({ length: 30 }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80 + (i % 5) * 0.2, + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY_MS), + })); + const sysRows = Array.from({ length: 10 }, (_, i) => ({ + userId: user.id, + type: "BLOOD_PRESSURE_SYS" as const, + value: 120 + i, + unit: "mmHg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY_MS), + })); + const diaRows = Array.from({ length: 10 }, (_, i) => ({ + userId: user.id, + type: "BLOOD_PRESSURE_DIA" as const, + value: 75 + i, + unit: "mmHg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY_MS), + })); + await prisma.measurement.createMany({ + data: [...weightRows, ...sysRows, ...diaRows], + }); + + const { GET } = await import("@/app/api/insights/comprehensive/route"); + const { caches } = await import("@/lib/cache/server-cache"); + + const first = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/insights/comprehensive")); + expect(first.status).toBe(200); + expect(caches.analytics.stats().misses).toBe(1); + expect(caches.analytics.stats().hits).toBe(0); + + // Second call inside the TTL — hit. + const second = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/insights/comprehensive")); + expect(second.status).toBe(200); + expect(caches.analytics.stats().hits).toBe(1); + + // The wide-event annotation carries the cache outcome on the hit. + const events = readLogBuffer({}); + const cacheEvent = events.find( + (e) => + (e?.meta as Record | undefined)?.[ + "cache.analytics.outcome" + ] === "hit", + ); + expect(cacheEvent).toBeDefined(); + }); + + it("emits every legacy envelope key with real SQL aggregation", async () => { + const user = await seedSession("comp-cache-shape-user"); + const prisma = getPrismaClient(); + + // A tight fixture: 5 paired BP readings + 5 weight rows + 5 mood + // entries spread across 5 distinct days. Enough to populate every + // correlation block (n >= 5 is the pearsonCorrelation gate). + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const dayDates = Array.from( + { length: 5 }, + (_, i) => new Date(now - i * DAY_MS), + ); + await prisma.measurement.createMany({ + data: [ + // Sys + dia at exactly the same instant (5-min tolerance auto-passes) + ...dayDates.flatMap((d, i) => [ + { + userId: user.id, + type: "BLOOD_PRESSURE_SYS" as const, + value: 120 + i, + unit: "mmHg", + source: "MANUAL" as const, + measuredAt: d, + }, + { + userId: user.id, + type: "BLOOD_PRESSURE_DIA" as const, + value: 75 + i, + unit: "mmHg", + source: "MANUAL" as const, + measuredAt: d, + }, + ]), + ...dayDates.map((d, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80 + i, + unit: "kg", + source: "MANUAL" as const, + measuredAt: d, + })), + ...dayDates.map((d, i) => ({ + userId: user.id, + type: "PULSE" as const, + value: 65 + i, + unit: "bpm", + source: "MANUAL" as const, + measuredAt: d, + })), + ], + }); + + await prisma.moodEntry.createMany({ + data: dayDates.map((d, i) => ({ + userId: user.id, + date: d.toISOString().slice(0, 10), + mood: "GUT", + score: 3 + (i % 3), + moodLoggedAt: d, + })), + }); + + const { GET } = await import("@/app/api/insights/comprehensive/route"); + + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/insights/comprehensive")); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + data: Record & { + summaries: Record< + string, + { count: number; latest: number | null; mean: number | null } | undefined + >; + totalMeasurements: number; + bpPctInTarget: number | null; + bpClassification: { category: string } | null; + bmi: number | null; + }; + }; + + // Every legacy key still present. + const legacyKeys = [ + "summaries", + "bmi", + "bmiClassification", + "bpClassification", + "bpPctInTarget", + "bpTargets", + "weightBpCorrelation", + "scatterData", + "bpMedicationCorrelation", + "bpMedicationScatterData", + "moodSummary", + "moodBpCorrelation", + "moodBpScatterData", + "moodWeightCorrelation", + "moodWeightScatterData", + "moodPulseCorrelation", + "moodPulseScatterData", + "medications", + "alerts", + "hasProvider", + "dataSpanDays", + "totalMeasurements", + ]; + for (const key of legacyKeys) { + expect(body.data).toHaveProperty(key); + } + + // Sanity-check the aggregator's numbers. + expect(body.data.summaries.WEIGHT?.count).toBe(5); + expect(body.data.summaries.BLOOD_PRESSURE_SYS?.count).toBe(5); + expect(body.data.summaries.PULSE?.count).toBe(5); + // 5 SYS + 5 DIA + 5 WEIGHT + 5 PULSE = 20 measurement rows. Mood + // entries live on `moodEntries` and don't count toward this field + // (matches legacy semantics). + expect(body.data.totalMeasurements).toBe(20); + // bpPctInTarget: 120-124/75-79 all inside under-65 ceiling. + expect(body.data.bpPctInTarget).toBe(100); + // BMI: latest weight = 80 (newest day, smallest i) at 1.80m → 24.7. + expect(body.data.bmi).toBeCloseTo(24.7, 1); + }); +});