/** * Unit-level pin for the slim summaries slice. Heavier integration * coverage lives in `tests/integration/analytics-summaries-slice.test.ts` * (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so * the slope/round/empty/path-selection contracts are pinned without * a container. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn(), // v1.11.1 — the data-aggregate queries inside `computeFromRollups` // and `computeFromLiveAggregate` now splice a whitelisted // source-rank CASE and bind `userId` as `$1`, so they run via // `$queryRawUnsafe(sql, userId)` rather than the tagged-template // `$queryRaw`. The coverage probe stays on `$queryRaw`. $queryRawUnsafe: vi.fn(), // v1.11.1 — `loadUserSourcePriority` reads the user's // `sourcePriorityJson` to build the rank ladders. `null` here → // default ladders. user: { findUnique: vi.fn() }, measurement: { findFirst: vi.fn() }, // v1.4.36 — slim slice reads DAY buckets from `measurement_rollups` // on the happy path. The freshness watermark inside // `ensureUserRollupsFresh` also pokes `measurementRollup.findFirst`; // mock both so the helper runs without a container. measurementRollup: { findMany: vi.fn(), findFirst: vi.fn(), }, }, })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); // v1.11.1 — `computeFromRollups` / `computeFromLiveAggregate` build a // source-rank CASE via `@/lib/analytics/source-rank-sql` and splice it // into the data-aggregate SQL. The builder's own correctness is pinned // in its dedicated suite (and the integration suite runs the real SQL); // here we stub it to deterministic, side-effect-free fragments so the // slice's plumbing — path selection, slope/round/empty contracts, the // 90-day FILTER caps the slice itself writes — is exercised without // coupling the slice unit test to the rank builder's enum-whitelist // internals. vi.mock("@/lib/analytics/source-rank-sql", () => ({ buildSourceRankCase: vi.fn(() => "90"), canonicalMeasurementsFrom: vi.fn( (_rank: string, sinceInterval?: string) => `( SELECT mm.* FROM measurements mm WHERE mm."user_id" = $1 AND mm."deleted_at" IS NULL ${ sinceInterval ? `AND mm."measured_at" >= NOW() - INTERVAL '${sinceInterval}'` : "" } ) m`, ), })); import { prisma } from "@/lib/db"; import { canonicalMeasurementsFrom } from "@/lib/analytics/source-rank-sql"; import { computeSummariesSlice } from "../summaries-slice"; const RAW = prisma.$queryRaw as unknown as ReturnType; const UNSAFE = prisma.$queryRawUnsafe as unknown as ReturnType; const USER_FIND_UNIQUE = prisma.user.findUnique as unknown as ReturnType< typeof vi.fn >; const MEASUREMENT_FIND_FIRST = prisma.measurement .findFirst as unknown as ReturnType; const ROLLUP_FIND_MANY = prisma.measurementRollup .findMany as unknown as ReturnType; const ROLLUP_FIND_FIRST = prisma.measurementRollup .findFirst as unknown as ReturnType; const CANONICAL_FROM = canonicalMeasurementsFrom as unknown as ReturnType< typeof vi.fn >; beforeEach(() => { RAW.mockReset(); UNSAFE.mockReset(); USER_FIND_UNIQUE.mockReset(); MEASUREMENT_FIND_FIRST.mockReset(); ROLLUP_FIND_MANY.mockReset(); ROLLUP_FIND_FIRST.mockReset(); // clear (not reset) — preserve the FROM-clause stub implementation, // drop cross-test call history so the cap assertion only sees this // test's calls. CANONICAL_FROM.mockClear(); // null → loadUserSourcePriority returns null → default rank ladders. USER_FIND_UNIQUE.mockResolvedValue(null); ROLLUP_FIND_MANY.mockResolvedValue([]); ROLLUP_FIND_FIRST.mockResolvedValue(null); MEASUREMENT_FIND_FIRST.mockResolvedValue(null); }); afterEach(() => { vi.restoreAllMocks(); }); describe("computeSummariesSlice", () => { describe("cold fallback — empty rollup table", () => { it("returns the empty-summary skeleton when the user has no rows", async () => { // v1.11.1 — the coverage probe stays on `$queryRaw` (1 RAW call); // the three data-aggregate queries moved to `$queryRawUnsafe`: // 1. per-type coverage probe ($queryRaw) — empty ⇒ cold path. // 2. all-time aggregate ($queryRawUnsafe) — empty. // 3. windowed aggregate ($queryRawUnsafe, 90-day cap) — empty. // 4. latests ($queryRawUnsafe) — empty. RAW.mockResolvedValueOnce([]); UNSAFE.mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]); const result = await computeSummariesSlice("user-1"); expect(result.summaries.WEIGHT).toEqual({ count: 0, latest: null, min: null, max: null, mean: null, median: null, avg7: null, avg30: null, slope7: null, slope30: null, slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, }); expect(result.bmi).toBeNull(); // 1 RAW coverage probe + 3 UNSAFE data queries. expect(RAW).toHaveBeenCalledTimes(1); expect(UNSAFE).toHaveBeenCalledTimes(3); }); it("maps a populated heavy aggregate row into the DataSummary shape on cold path", async () => { // v1.4.48 M0 — cold path now splits the heavy aggregate into // all-time + 90-day-capped windowed queries. Mock order: // 1. coverage probe (WEIGHT uncovered → cold path) // 2. all-time aggregate (count / min / max / mean) // 3. windowed aggregate (avg7/30 + slope/r²) // 4. latests RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }]); UNSAFE.mockResolvedValueOnce([ { type: "WEIGHT", count: BigInt(42), min_value: 79.2, max_value: 84.1, mean_value: 82.05, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", avg7: 81.9, avg30: 82.1, 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: new Date() }, ]); const result = await computeSummariesSlice("user-1"); 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.anomalyCount).toBe(0); expect(weight.avg30LastMonth).toBeNull(); expect(weight.avg30LastYear).toBeNull(); 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, }); }); it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => { RAW.mockResolvedValueOnce([{ type: "PULSE", has_buckets: false }]); UNSAFE.mockResolvedValueOnce([ { type: "PULSE", count: BigInt(1), min_value: 72, max_value: 72, mean_value: 72, }, ]) .mockResolvedValueOnce([ { type: "PULSE", avg7: 72, avg30: 72, slope7: null, r2_7: null, slope30: null, r2_30: null, slope90: null, r2_90: null, }, ]) .mockResolvedValueOnce([ { type: "PULSE", value: 72, measured_at: new Date() }, ]); const result = await computeSummariesSlice("user-1"); expect(result.summaries.PULSE.slope7).toBeNull(); expect(result.summaries.PULSE.slope30).toBeNull(); expect(result.summaries.PULSE.slope90).toBeNull(); }); it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => { const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }]); UNSAFE.mockResolvedValueOnce([ { type: "WEIGHT", count: BigInt(5), min_value: 80, max_value: 84, mean_value: 82, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", avg7: null, avg30: 82, slope7: null, r2_7: null, slope30: 0.005, r2_30: 0.2, slope90: null, r2_90: null, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.3, measured_at: tenDaysAgo }, ]); const result = await computeSummariesSlice("user-1"); const ws = result.lastSeenByType.WEIGHT; expect(ws).not.toBeNull(); expect(ws?.daysAgo).toBeGreaterThanOrEqual(9); expect(ws?.daysAgo).toBeLessThanOrEqual(11); expect(ws?.lastSeenAt).toBe(tenDaysAgo.toISOString()); expect(result.lastSeenByType.PULSE).toBeNull(); }); it("seeds the latest value from the DISTINCT ON pass per type", async () => { RAW.mockResolvedValueOnce([{ type: "PULSE", has_buckets: false }]); UNSAFE.mockResolvedValueOnce([ { type: "PULSE", count: BigInt(3), min_value: 60, max_value: 95, mean_value: 77, }, ]) .mockResolvedValueOnce([ { type: "PULSE", avg7: 77, avg30: 77, slope7: 0, r2_7: 0, slope30: 0, r2_30: 0, slope90: 0, r2_90: 0, }, ]) .mockResolvedValueOnce([ { type: "PULSE", value: 88, measured_at: new Date() }, ]); const result = await computeSummariesSlice("user-1"); expect(result.summaries.PULSE.latest).toBe(88); expect(result.summaries.PULSE.max).toBe(95); }); }); describe("rollup-fresh happy path", () => { it("composes count/min/max/mean from the per-type rollup GROUP BY without running the heavy aggregate", async () => { // v1.11.1 — the per-type coverage probe stays on `$queryRaw`; the // three rollup-path data queries moved to `$queryRawUnsafe`: // 1. per-type coverage probe ($queryRaw) — WEIGHT fully covered // ⇒ happy path. // 2. narrow aggregate ($queryRawUnsafe) — windowed/regression only. // 3. latests ($queryRawUnsafe). // 4. rollup GROUP BY ($queryRawUnsafe) — one row per type with // count/min/max/mean already composed server-side. RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); UNSAFE.mockResolvedValueOnce([ { type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82.1, slope7: 0.02, r2_7: 0.5, slope30: 0.01, r2_30: 0.4, slope90: 0.005, r2_90: 0.2, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.7, measured_at: new Date() }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", // pre-aggregated server-side: SUM(count), MIN(min), // MAX(max), weighted mean — equivalent to the two-bucket // fixture below. // bucket A: count=10, mean=81.0, min=79.5, max=82.0 // bucket B: count=10, mean=83.0, min=81.5, max=84.0 // ⇒ count=20, min=79.5, max=84.0, mean=82 count: 20, min: 79.5, max: 84.0, mean: 82.0, }, ]); const result = await computeSummariesSlice("user-rollup"); const weight = result.summaries.WEIGHT; expect(weight.count).toBe(20); expect(weight.min).toBe(79.5); expect(weight.max).toBe(84.0); expect(weight.mean).toBe(82); // v1.8.5 — the windowed median flows through from the narrow // `PERCENTILE_CONT` column onto the slim summary. expect(weight.median).toBe(82.1); expect(weight.latest).toBe(82.7); expect(weight.avg7).toBe(82); expect(weight.slope7).toEqual({ slope: 0.02, direction: "up", confidence: 0.5, }); // 1 RAW coverage probe + 3 UNSAFE data queries (narrow aggregate // + latests + rollup GROUP BY; v1.4.37.2 — the prior `findMany` // is gone). No heavy aggregate. expect(RAW).toHaveBeenCalledTimes(1); expect(UNSAFE).toHaveBeenCalledTimes(3); // v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs // `readBestGranularityRollups(userId, type, 395)` per // type-with-data via `prisma.measurementRollup.findMany`. The // 395-day window skips the YEAR floor (731 d) and walks MONTH // → WEEK → DAY on full coverage miss; the default mock returns // `[]` so all three reachable tiers probe. expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); }); }); /** * v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the * slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to * `null`; we now populate it from `readBestGranularityRollups` when * the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap * the `[now-395d, now-365d)` slice. */ describe("year-over-year wiring (avg30LastYear)", () => { it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => { // Coverage probe shows WEIGHT covered → rollup happy path. RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); UNSAFE.mockResolvedValueOnce([ { type: "WEIGHT", avg7: 82, avg30: 82.5, slope7: 0, r2_7: 0, slope30: 0, r2_30: 0, slope90: 0, r2_90: 0, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.7, measured_at: new Date() }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, ]); // Year-ago slice MONTH bucket — `bucketStart` placed 380 days // ago so it falls inside `[now-395d, now-365d)`. Single bucket // (count=10, mean=85) → weighted mean = 85. const yearAgoBucketStart = new Date( Date.now() - 380 * 24 * 60 * 60 * 1000, ); ROLLUP_FIND_MANY.mockResolvedValueOnce([ { bucketStart: yearAgoBucketStart, count: 10, mean: 85, sd: 1, slope: 0, r2: 0, sumValue: null, minValue: 83, maxValue: 87, }, ]); const result = await computeSummariesSlice("user-yoy"); expect(result.summaries.WEIGHT.avg30LastYear).toBe(85); // Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR). expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH"); }); it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => { RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); UNSAFE.mockResolvedValueOnce([ { type: "WEIGHT", avg7: 82, avg30: 82.5, slope7: 0, r2_7: 0, slope30: 0, r2_30: 0, slope90: 0, r2_90: 0, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.7, measured_at: new Date() }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, ]); // MONTH bucket placed 30 days ago — inside the YEAR / MONTH / // WEEK 395-day window the router asks for, but outside the // `[now-395d, now-365d)` slice. The helper returns null. const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); ROLLUP_FIND_MANY.mockResolvedValueOnce([ { bucketStart: recentBucket, count: 10, mean: 82, sd: 1, slope: 0, r2: 0, sumValue: null, minValue: 80, maxValue: 84, }, ]); const result = await computeSummariesSlice("user-recent"); expect(result.summaries.WEIGHT.avg30LastYear).toBeNull(); }); it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => { RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); UNSAFE.mockResolvedValueOnce([ { type: "WEIGHT", avg7: 82, avg30: 82.5, slope7: 0, r2_7: 0, slope30: 0, r2_30: 0, slope90: 0, r2_90: 0, }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.7, measured_at: new Date() }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, ]); // Default mock returns `[]` for every findMany call → router // walks YEAR → MONTH → WEEK → DAY and gives up. ROLLUP_FIND_MANY.mockResolvedValue([]); const result = await computeSummariesSlice("user-empty-yoy"); expect(result.summaries.WEIGHT.avg30LastYear).toBeNull(); // 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY, // three reachable tiers all probed on full coverage miss. expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); }); it("only probes types that actually have data in the current window", async () => { // v1.4.48 M0 — empty coverage map ⇒ `isFullyCovered` returns // false ⇒ cold-fallback path. v1.11.1 — coverage probe stays on // `$queryRaw`; all-time + windowed + latests run via // `$queryRawUnsafe`. All return empty arrays here ⇒ no // types-with-data ⇒ the year-ago probe must NOT fan out. RAW.mockResolvedValueOnce([]); UNSAFE.mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]); await computeSummariesSlice("user-no-data"); expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); }); /** * v1.4.48 M0 — pins the 90-day outer `measured_at` cap on the two * windowed measurements scans: the `narrows` query inside the * rollup-fresh happy path and the `windowed` query inside the * cold-fallback path. Both must constrain the outer WHERE to the * 90-day suffix so the planner does an index range scan on * `(user_id, type, measured_at)` instead of a full-partition scan. * If a future refactor drops the cap, this assertion fails before * the perf regression reaches main. */ describe("90-day outer measured_at cap (v1.4.48 M0)", () => { it("applies the 90-day cap to narrows (rollup-fresh path) and windowed (cold-fallback path)", async () => { // v1.11.1 — the cap-bearing data queries (narrows / windowed) // now run via `$queryRawUnsafe(sql, userId)`, so the SQL is a // plain string arg[0] rather than a tagged-template // strings array. Capture from the UNSAFE mock. The coverage probe // (still `$queryRaw`) drives path selection per call. const queries: string[] = []; UNSAFE.mockImplementation((sql: string) => { queries.push(sql); return Promise.resolve([]); }); // Trigger the rollup-fresh path so we capture the `narrows` SQL. // Coverage probe returns one covered type ⇒ `isFullyCovered` // is true ⇒ `computeFromRollups` runs. // Then the cold-fallback path (empty coverage) so we capture the // `windowed` SQL. RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); await computeSummariesSlice("user-rollup-pin"); RAW.mockResolvedValueOnce([]); await computeSummariesSlice("user-cold-pin"); const joined = queries.join("\n---\n"); // v1.11.1 — the rollup-fresh `narrows` query still writes its // outer 90-day cap inline, now on the canonical-source subquery's // raw alias (`mm.`) rather than the outer `m.`. Pin that the cap // survives the source-rank refactor. const narrowsCap = joined.match( /AND mm\."measured_at" >= NOW\(\) - INTERVAL '90 days'/g, ); expect(narrowsCap).not.toBeNull(); expect(narrowsCap?.length).toBeGreaterThanOrEqual(1); // v1.11.1 — the cold-fallback `windowed` scan delegates its outer // 90-day cap to `canonicalMeasurementsFrom(rank, "90 days")`. The // helper lives in `@/lib/analytics/source-rank-sql` (stubbed // above), so pin the contract at the call boundary: the slice // must ask for the 90-day window. The `allTime` aggregate // deliberately calls it WITHOUT an interval (no cap — all-time // count/min/max/mean must scan every row). expect(canonicalMeasurementsFrom).toHaveBeenCalledWith( expect.any(String), "90 days", ); expect(canonicalMeasurementsFrom).toHaveBeenCalledWith( expect.any(String), ); }); }); });