From 0ee50817666ffb24fc635f62cd0ecb1a6f2df296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 12:11:10 +0200 Subject: [PATCH] test(insights): cover local-day mood correlation pairing Berlin boundary pairs (previously dropped), the gone New York lag-1 offset, all three metric partners moving, UTC-user invariance, and a metric-by-metric guard proving the weight x BP and BP-medication joins stay byte-identical across timezones. Update the mocked aggregates for the additive weightRawRows field and pin the widened raw pull in the aggregator test. --- .../comprehensive/__tests__/route.test.ts | 284 ++++++++++++++++++ .../comprehensive-aggregator.test.ts | 11 + 2 files changed, 295 insertions(+) diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 9e69f49d6..5f2a4eedd 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -93,6 +93,7 @@ import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { getMedicationCategories } from "@/lib/medication-category"; import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -157,6 +158,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { { summaries: {}, bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: null, totalMeasurements: 0, @@ -229,6 +231,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { }, }, bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: new Date(), totalMeasurements: 1, @@ -285,6 +288,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { sys: [{ measuredAt: at, value: 125 }], dia: [{ measuredAt: at, value: 75 }], }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: at, totalMeasurements: 2, @@ -324,6 +328,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }], dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }], }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: new Date("2026-05-14T21:50:00Z"), totalMeasurements: 2, @@ -350,6 +355,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { { summaries: {}, bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: new Date(), totalMeasurements: 0, @@ -409,6 +415,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { { summaries: {}, bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: new Date(), totalMeasurements: 0, @@ -456,6 +463,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { { summaries: {}, bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], dailyByType: { WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })), BLOOD_PRESSURE_SYS: days.map((d, i) => ({ @@ -491,6 +499,7 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", () const EMPTY_AGGREGATE = { summaries: {}, bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], dailyByType: {}, firstMeasurementAt: null, totalMeasurements: 0, @@ -543,3 +552,278 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", () expect(freshBody.data.revalidating).toBe(false); }); }); + +// v1.32.17 (R1) — mood × metric correlations must pair on the LOCAL +// logging day. The mood series has been local-day-keyed since v1.32.12; +// the three metric partners (sys BP, weight, resting pulse) were still +// UTC-keyed, so for non-UTC users an evening reading landed a day off and +// paired with the wrong mood entry. These tests run under CI's TZ=UTC and +// carry tz-explicit fixtures so the assertions are host-TZ-independent. +describe("GET /api/insights/comprehensive — mood × metric local-day pairing (v1.32.17)", () => { + type MoodBody = { + data: { + moodBpScatterData: Array<{ mood: number; sysBP: number }>; + moodWeightScatterData: Array<{ mood: number; weight: number }>; + moodPulseScatterData: Array<{ mood: number; pulse: number }>; + scatterData: Array<{ weight: number; sysBP: number }>; + weightBpCorrelation: unknown; + bpMedicationCorrelation: unknown; + }; + }; + + function moodRollupDay(midnightZ: string, mean: number, count = 1) { + return { + userId: "user-comp-1", + granularity: "DAY", + bucketStart: new Date(midnightZ), + count, + mean, + minScore: mean, + maxScore: mean, + sd: null, + computedAt: new Date(), + }; + } + + function sessionWithTz(tz: string) { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: tz }, + } as never); + } + + it("Berlin: a mood + BP logged the same local evening now pair (previously dropped)", async () => { + sessionWithTz("Europe/Berlin"); + // Mood logged 00:30 local on 2026-07-10 → rollup bucketStart is the + // canonical local day at UTC midnight. + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + // 22:35Z = 00:35 Berlin on Jul 10 → local day 2026-07-10, but UTC + // day 2026-07-09. The old UTC `dailyByType` key (below) would never + // match the mood's local 2026-07-10 → the pair was dropped. + bpRawRows: { + sys: [{ measuredAt: new Date("2026-07-09T22:35:00Z"), value: 128 }], + dia: [], + }, + weightRawRows: [], + dailyByType: { + BLOOD_PRESSURE_SYS: [{ day: "2026-07-09", value: 128 }], + }, + firstMeasurementAt: new Date("2026-07-09T22:35:00Z"), + totalMeasurements: 1, + }, + ); + + const res = await callGet(makeReq()); + const body = (await res.json()) as MoodBody; + // Derived from the raw row in the user's tz → pairs on local 2026-07-10. + expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 128 }]); + }); + + it("New York: the lag-1 offset is gone — each pair couples mood(D) with the metric logged local day D", async () => { + sessionWithTz("America/New_York"); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([ + moodRollupDay("2026-07-10T00:00:00.000Z", 2), + moodRollupDay("2026-07-11T00:00:00.000Z", 3), + moodRollupDay("2026-07-12T00:00:00.000Z", 4), + ]); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + // Each sys row is at (D+1)T01:00Z = 21:00 local on day D (EDT UTC-4). + // Old UTC code keyed each on D+1 and paired it with mood(D+1) — a + // lag-1 correlation sold as same-day. Local derivation keys on D. + bpRawRows: { + sys: [ + { measuredAt: new Date("2026-07-11T01:00:00Z"), value: 120 }, + { measuredAt: new Date("2026-07-12T01:00:00Z"), value: 130 }, + { measuredAt: new Date("2026-07-13T01:00:00Z"), value: 140 }, + ], + dia: [], + }, + weightRawRows: [], + dailyByType: {}, + firstMeasurementAt: new Date("2026-07-11T01:00:00Z"), + totalMeasurements: 3, + }, + ); + + const res = await callGet(makeReq()); + const body = (await res.json()) as MoodBody; + // mood(07-10)=2 ↔ 120 (logged local 07-10), not 130 (the old +1 offset). + expect(body.data.moodBpScatterData).toEqual([ + { mood: 2, sysBP: 120 }, + { mood: 3, sysBP: 130 }, + { mood: 4, sysBP: 140 }, + ]); + }); + + it("Berlin: mood × weight and mood × resting pulse also pair on the local day (all three partners moved)", async () => { + sessionWithTz("Europe/Berlin"); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]); + // Resting-HR row at 00:30 local Jul 10 (= 22:30Z Jul 9). + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 58 }], + ); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + // Weight row at 00:30 local Jul 10 (= 22:30Z Jul 9). + weightRawRows: [ + { measuredAt: new Date("2026-07-09T22:30:00Z"), value: 80.5 }, + ], + dailyByType: {}, + firstMeasurementAt: new Date("2026-07-09T22:30:00Z"), + totalMeasurements: 2, + }, + ); + + const res = await callGet(makeReq()); + const body = (await res.json()) as MoodBody; + expect(body.data.moodWeightScatterData).toEqual([ + { mood: 4, weight: 80.5 }, + ]); + expect(body.data.moodPulseScatterData).toEqual([{ mood: 4, pulse: 58 }]); + }); + + it("UTC user: pairing is byte-identical to the pre-fix UTC-slice join (invariance)", async () => { + sessionWithTz("UTC"); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + // Under UTC, `userDayKey` degenerates to the ISO slice, so the raw + // derivation reproduces exactly what the old `dailyByType` join gave. + bpRawRows: { + sys: [{ measuredAt: new Date("2026-07-10T08:00:00Z"), value: 122 }], + dia: [], + }, + weightRawRows: [], + dailyByType: { + BLOOD_PRESSURE_SYS: [{ day: "2026-07-10", value: 122 }], + }, + firstMeasurementAt: new Date("2026-07-10T08:00:00Z"), + totalMeasurements: 1, + }, + ); + + const res = await callGet(makeReq()); + const body = (await res.json()) as MoodBody; + expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 122 }]); + }); + + it("metric × metric guard: weight × BP and BP-med continuity are byte-identical across timezones (UTC joins untouched)", async () => { + // R1 must NOT re-key the two internally-consistent UTC joins + // (weight × BP, BP-med continuity) — those migrate with the rollup + // tier in R12. Both read the UTC `dailyByType` / UTC `scheduledFor` + // slice, so their output must be invariant to the caller's timezone. + // Run the SAME fixture under UTC then America/New_York and deep-equal. + const days = [ + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + ]; + const aggregate = { + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], + 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 medication = { + id: "med-bp", + name: "Amlodipine", + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date("2026-04-01T00:00:00Z"), + schedules: [ + { + id: "sch-1", + windowStart: "07:00", + windowEnd: "08:00", + timesOfDay: ["07:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: [], + }, + ], + scheduleRevisions: [], + pauseEras: [], + }; + // Intake taken on the first three days, missed on the last two → a + // non-degenerate continuity series. + const intakeEvents = days.slice(0, 3).map((d) => ({ + medicationId: "med-bp", + userId: "user-comp-1", + deletedAt: null, + scheduledFor: new Date(`${d}T07:00:00Z`), + takenAt: new Date(`${d}T07:05:00Z`), + skipped: false, + })); + + function primeMocks() { + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue(aggregate); + ( + prisma.medication.findMany as ReturnType + ).mockResolvedValue([medication]); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue(intakeEvents); + vi.mocked(getMedicationCategories).mockResolvedValue({ + "med-bp": "BLOOD_PRESSURE", + }); + } + + sessionWithTz("UTC"); + primeMocks(); + const utcBody = (await (await callGet(makeReq())).json()) as MoodBody; + + // Fresh cache so the second run recomputes rather than serving the + // cached UTC body under the same user id. + __resetAllCachesForTests(); + + sessionWithTz("America/New_York"); + primeMocks(); + const nyBody = (await (await callGet(makeReq())).json()) as MoodBody; + + expect(nyBody.data.scatterData).toEqual(utcBody.data.scatterData); + expect(nyBody.data.weightBpCorrelation).toEqual( + utcBody.data.weightBpCorrelation, + ); + expect(nyBody.data.bpMedicationCorrelation).toEqual( + utcBody.data.bpMedicationCorrelation, + ); + // And the guard is meaningful only if these joins actually produced + // output to compare. + expect(utcBody.data.scatterData).toHaveLength(5); + expect(utcBody.data.bpMedicationCorrelation).not.toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts index d7a8e6f34..07c377a9a 100644 --- a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts +++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts @@ -416,14 +416,25 @@ describe("buildComprehensiveAggregate", () => { // already-collapsed sys + dia rows in raw SQL snake_case shape; the // aggregator partitions by type in JS so the bpRawRows.sys / .dia // byte-shape stays identical. + // v1.32.17 — WEIGHT now rides this same canonical pull so the route + // can derive a local-day mood × weight series. It partitions into a + // separate `weightRawRows` field and must not disturb sys / dia. .mockResolvedValueOnce([ { type: "BLOOD_PRESSURE_SYS", measured_at: measuredAt, value: 120 }, { type: "BLOOD_PRESSURE_DIA", measured_at: measuredAt, value: 80 }, + { type: "WEIGHT", measured_at: measuredAt, value: 81.4 }, ]); const result = await buildComprehensiveAggregate("user-bp"); expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]); expect(result.bpRawRows.dia).toEqual([{ measuredAt, value: 80 }]); + // WEIGHT partitions into its own field; sys / dia stay byte-identical. + expect(result.weightRawRows).toEqual([{ measuredAt, value: 81.4 }]); + // The widened pull selects WEIGHT alongside the BP types in one query. + const bpPullSql = UNSAFE.mock.calls + .map((c) => String(c[0])) + .find((sql) => sql.includes("'WEIGHT'")); + expect(bpPullSql).toBeTruthy(); // The BP pull must route through the canonical-source subquery (the // collapse fires in SQL); pin the marker so a revert to a plain findMany // (double-counting overlapping sources) fails here.