diff --git a/tests/integration/correlations-rollup-swap.test.ts b/tests/integration/correlations-rollup-swap.test.ts new file mode 100644 index 000000000..c160aa598 --- /dev/null +++ b/tests/integration/correlations-rollup-swap.test.ts @@ -0,0 +1,192 @@ +/** + * Correlation-discovery rollup read-swap — DB round trip. + * + * The unit battery + * (`src/lib/insights/__tests__/correlation-channel-series-rollup-swap.test.ts`) + * derives rollup fixtures in JS; this suite proves the SAME parity through + * the real pipe: seed raw measurements, fold them with the real rollup + * writer (`recomputeUserRollups` — the Postgres `AVG` / `SUM` / `COUNT` + * grouped per `(type, day, source)`), then read the tiered fetch twice — + * once BEFORE the fold (coverage probe misses → raw path) and once AFTER + * (DAY buckets serve) — and assert the two daily series are equivalent. + * A drifted writer projection, coverage-probe SQL, or bucket compose all + * surface here instead of in production. + * + * Fixtures are now-anchored (never fixed calendar dates) and pinned to + * mid-day UTC wall times, so the UTC bucket day and the Europe/Berlin + * profile day agree by construction and the suite cannot age out. + * + * Watched-red record: skewing `composeRollupDailyMeans` (`acc.sum += sum + * + 1`) failed the post-fold parity assertions here naming the composed + * values; restored green. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { fetchMeasurementDailySeriesTiered } from "@/lib/insights/correlation-channel-series"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// The DAY-only recompute never touches pg-boss; leave the boss detached for +// parity with the sibling rollup suites (`isolate: false` shares the mock). +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Now-anchored instant: `daysAgo` days back, pinned to a UTC wall time. */ +function at(daysAgo: number, hourUtc: number, minute = 0): Date { + const d = new Date(Date.now() - daysAgo * DAY_MS); + d.setUTCHours(hourUtc, minute, 0, 0); + return d; +} + +let seq = 0; +async function seedUser(prisma: ReturnType) { + seq += 1; + return prisma.user.create({ + data: { + username: `corr-rollup-swap-${seq}`, + email: `corr-rollup-swap-${seq}@example.test`, + role: "USER", + timezone: "Europe/Berlin", + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("correlations rollup swap — real writer round trip", () => { + it("serves the same daily series from the folded DAY buckets as from the raw rows", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const since = new Date(Date.now() - 30 * DAY_MS); + const types = [ + "RESTING_HEART_RATE", + "WEIGHT", + "BLOOD_GLUCOSE", + ] as const satisfies readonly string[]; + + await prisma.measurement.createMany({ + data: [ + // RHR — two readings on one day (day-mean collapse), one on another. + { + userId: user.id, + type: "RESTING_HEART_RATE", + value: 55, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: at(10, 8), + }, + { + userId: user.id, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: at(10, 18), + }, + { + userId: user.id, + type: "RESTING_HEART_RATE", + value: 58, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: at(8, 9), + }, + // WEIGHT — a dual-source day: the discovery series is the ALL-source + // mean (81.0), which the reader must compose across the two + // per-source rollup rows the writer mints. + { + userId: user.id, + type: "WEIGHT", + value: 80.4, + unit: "kg", + source: "APPLE_HEALTH", + measuredAt: at(7, 7), + }, + { + userId: user.id, + type: "WEIGHT", + value: 81.6, + unit: "kg", + source: "WITHINGS", + measuredAt: at(7, 7, 30), + }, + // GLUCOSE — several readings across a day. + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 92, + unit: "mg/dL", + source: "MANUAL", + measuredAt: at(5, 8), + }, + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 118, + unit: "mg/dL", + source: "MANUAL", + measuredAt: at(5, 13), + }, + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 101, + unit: "mg/dL", + source: "MANUAL", + measuredAt: at(5, 19), + }, + ], + }); + + // BEFORE the fold: the coverage probe finds no DAY buckets, so every + // channel takes the raw path — this is the pre-swap behaviour baseline. + const viaRaw = await fetchMeasurementDailySeriesTiered( + user.id, + "Europe/Berlin", + since, + [...types], + ); + expect(viaRaw.rollupTypes).toEqual([]); + + // Fold with the REAL writer (per-source DAY rows via Postgres AVG/SUM). + await recomputeUserRollups(user.id, { granularities: ["DAY"] }); + + const viaRollup = await fetchMeasurementDailySeriesTiered( + user.id, + "Europe/Berlin", + since, + [...types], + ); + expect(viaRollup.rollupTypes.sort()).toEqual([...types].sort()); + + for (const type of types) { + const rawSeries = viaRaw.byType.get(type)!; + const rollupSeries = viaRollup.byType.get(type)!; + expect(rollupSeries.map((p) => p.day)).toEqual( + rawSeries.map((p) => p.day), + ); + rollupSeries.forEach((p, i) => + expect(p.value).toBeCloseTo(rawSeries[i].value, 10), + ); + } + + // Anchor the dual-source WEIGHT day explicitly: all-source mean, never + // a ladder-collapsed single source's value. + const weightDay = viaRollup.byType.get("WEIGHT")!; + expect(weightDay).toHaveLength(1); + expect(weightDay[0].value).toBeCloseTo(81.0, 10); + }); +});