mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Complete the source-aware rollup read path: the slim summaries slice (rollup + live), the comprehensive aggregator (both paths, including the dailyByType correlation feed), and the /api/measurements route (rollup read + the live date_trunc fallback) now resolve overlapping sources to the source-priority ladder's canonical reading per (type, day) before aggregating. A dual-source vital (e.g. WHOOP + Apple Watch resting heart rate) surfaces one canonical series instead of a blend or a doubled count; cumulative metrics keep summing per canonical source. The collapse runs in SQL where the aggregate must stay a single round-trip (buildSourceRankCase emits a per-user ladder-rank CASE; canonicalMeasurementsFrom restricts a live read to canonical-source rows) and in application code (collapseRollupRowsBySource) for the row readers, both driven by the same resolved ladders so live and rollup agree. Also widens the enum-literal SQL whitelist to accept digits so VO2_MAX no longer throws on the typed write-hook recompute or the rank CASE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
607 lines
21 KiB
TypeScript
607 lines
21 KiB
TypeScript
/**
|
|
* 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<typeof vi.fn>;
|
|
const UNSAFE = prisma.$queryRawUnsafe as unknown as ReturnType<typeof vi.fn>;
|
|
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<typeof vi.fn>;
|
|
const ROLLUP_FIND_MANY =
|
|
prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
|
const ROLLUP_FIND_FIRST =
|
|
prisma.measurementRollup.findFirst as unknown as ReturnType<typeof vi.fn>;
|
|
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),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|