Files
HealthLog/tests/integration/measurement-rollups.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

905 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.5.0 — integration coverage for the persistent measurement-
* rollup populator.
*
* Runs against the testcontainer Postgres so the real `STDDEV_POP` /
* `REGR_SLOPE` / `REGR_R2` functions and the `date_trunc` semantics
* are exercised end-to-end. Three contracts are pinned:
*
* 1. **Synchronous DAY recompute on write hook.** Calling
* `recomputeBucketsForMeasurement` after a measurement insert
* writes a `MeasurementRollup` row with the right
* `count / mean / min / max / sd` shape.
*
* 2. **Backfill cardinality.** `recomputeUserRollups` over a
* synthetic 3-user × 4-type × 50-row fixture produces the
* expected (user × type × granularity × bucket) cardinality
* with no duplicate rows.
*
* 3. **Re-aggregated DataSummary matches live SQL.** A populated
* rollup-DAY series re-aggregated through `aggregateBuckets`
* returns `count / min / max / mean` byte-identical to a
* parallel live `$queryRaw` over the same rows. Establishes
* the byte-shape parity contract before the v1.5.1 read-path
* swap.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
ROLLUP_FULL_BACKFILL_QUEUE,
enqueueBootTimeRollupBackfill,
recomputeBucketsForMeasurement,
recomputeUserRollups,
ALL_GRANULARITIES,
} from "@/lib/rollups/measurement-rollups";
import { aggregateBuckets } from "@/lib/rollups/measurement-read";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Stub boss-instance with a vi.fn so we can swap the return per test.
// The integration suite runs with `isolate: false`, so the mock state
// is shared with sibling files (e.g. admin-backups-audit.test.ts also
// mocks boss-instance). Re-setting the implementation inside
// beforeEach restores our default before each case.
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
const bossSend = vi.fn();
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
bossSend.mockReset();
// Default: pg-boss not attached. Individual tests that exercise the
// enqueue path override this with a stub that records sends.
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("measurement rollups — integration", () => {
it("writes a DAY rollup row that matches the inserted measurements", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-day-user",
email: "rollup-day-user@example.test",
role: "USER",
},
});
const measuredAt = new Date("2026-05-10T10:00:00.000Z");
await prisma.measurement.createMany({
data: [
{
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-05-10T07:00:00.000Z"),
},
{
userId: user.id,
type: "WEIGHT",
value: 82,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-05-10T15:00:00.000Z"),
},
{
userId: user.id,
type: "WEIGHT",
value: 84,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-05-10T22:00:00.000Z"),
},
],
});
await recomputeBucketsForMeasurement(user.id, "WEIGHT", measuredAt);
const dayRow = await prisma.measurementRollup.findFirst({
where: { userId: user.id, type: "WEIGHT", granularity: "DAY" },
});
expect(dayRow).not.toBeNull();
expect(dayRow!.count).toBe(3);
expect(dayRow!.mean).toBeCloseTo(82, 5);
expect(dayRow!.minValue).toBe(80);
expect(dayRow!.maxValue).toBe(84);
// STDDEV_POP across [80, 82, 84]: variance = (4+0+4)/3 ≈ 2.667;
// SD = sqrt ≈ 1.633.
expect(dayRow!.sd).toBeCloseTo(1.633, 2);
});
it("backfills with the expected (user × type × granularity × bucket) cardinality", async () => {
const prisma = getPrismaClient();
const userIds: string[] = [];
const types = [
"WEIGHT",
"PULSE",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
] as const;
// 3 users × 4 types × ~50 rows over ~7 weeks
for (let u = 0; u < 3; u++) {
const user = await prisma.user.create({
data: {
username: `rollup-backfill-user-${u}`,
email: `rollup-backfill-${u}@example.test`,
role: "USER",
},
});
userIds.push(user.id);
interface SeedRow {
userId: string;
type: (typeof types)[number];
value: number;
unit: string;
source: "MANUAL";
measuredAt: Date;
}
const rows: SeedRow[] = [];
const baseMs = new Date("2026-03-01T08:00:00.000Z").getTime();
for (const type of types) {
for (let i = 0; i < 50; i++) {
rows.push({
userId: user.id,
type,
value: 80 + (i % 5),
unit: type === "WEIGHT" ? "kg" : type === "PULSE" ? "bpm" : "mmHg",
source: "MANUAL",
measuredAt: new Date(baseMs + i * 24 * 60 * 60 * 1000),
});
}
}
await prisma.measurement.createMany({ data: rows });
}
for (const userId of userIds) {
await recomputeUserRollups(userId, {
granularities: ALL_GRANULARITIES,
});
}
// 50 distinct days per (user, type) → 50 DAY rollups.
const dayCount = await prisma.measurementRollup.count({
where: { granularity: "DAY" },
});
expect(dayCount).toBe(3 * 4 * 50);
// WEEK / MONTH / YEAR cardinalities should be > 0 and < dayCount.
const weekCount = await prisma.measurementRollup.count({
where: { granularity: "WEEK" },
});
const monthCount = await prisma.measurementRollup.count({
where: { granularity: "MONTH" },
});
const yearCount = await prisma.measurementRollup.count({
where: { granularity: "YEAR" },
});
expect(weekCount).toBeGreaterThan(0);
expect(weekCount).toBeLessThan(dayCount);
expect(monthCount).toBeGreaterThan(0);
expect(monthCount).toBeLessThan(weekCount);
expect(yearCount).toBeGreaterThan(0);
expect(yearCount).toBeLessThanOrEqual(monthCount);
// Re-running the backfill is idempotent — totals stay the same.
for (const userId of userIds) {
await recomputeUserRollups(userId, {
granularities: ALL_GRANULARITIES,
});
}
const dayCountAfter = await prisma.measurementRollup.count({
where: { granularity: "DAY" },
});
expect(dayCountAfter).toBe(dayCount);
});
it("re-aggregated DAY buckets match live SQL for count / min / max / mean (byte-shape parity)", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-parity-user",
email: "rollup-parity@example.test",
role: "USER",
},
});
// 14-day descending-weight fixture spread across multiple days
// so the DAY rollup has > 1 buckets to aggregate.
const baseTime = new Date("2026-05-01T08:00:00.000Z").getTime();
const N = 28;
const rows = Array.from({ length: N }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
value: 80 + (i * 4) / (N - 1), // 80 → 84
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(baseTime + i * 12 * 60 * 60 * 1000), // 2/day
}));
await prisma.measurement.createMany({ data: rows });
// Backfill rollups.
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
const buckets = await prisma.measurementRollup.findMany({
where: { userId: user.id, type: "WEIGHT", granularity: "DAY" },
orderBy: { bucketStart: "asc" },
});
// Re-aggregate the DAY buckets through the rollup-read helper.
const reAggregated = aggregateBuckets(
buckets.map((b) => ({
day: b.bucketStart,
count: b.count,
mean: b.mean,
minValue: b.minValue,
maxValue: b.maxValue,
})),
);
// Compare against a parallel live $queryRaw — the source of truth
// the v1.4.34.5 comprehensive aggregator uses.
const live = await prisma.$queryRaw<
Array<{
count: bigint;
min_value: number;
max_value: number;
mean_value: number;
}>
>`
SELECT
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
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = 'WEIGHT'
`;
expect(reAggregated.count).toBe(Number(live[0].count));
expect(reAggregated.min).toBeCloseTo(live[0].min_value, 5);
expect(reAggregated.max).toBeCloseTo(live[0].max_value, 5);
expect(reAggregated.mean).toBeCloseTo(live[0].mean_value, 5);
});
// ─── v1.4.35 read-swap parity ──────────────────────────────
//
// The aggregator + slim slice now source `count / min / max / mean`
// from the DAY rollup buckets and fall back to live SQL when the
// composed count disagrees with `COUNT(*)`. These tests pin the
// happy path (rollup-derived equals live byte-for-byte) and the
// post-write freshness path (a new measurement after a warm read
// is reflected on the next call).
it("comprehensive aggregator's count/min/max/mean matches live SQL byte-for-byte after warm rollup", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-readswap-user",
email: "rollup-readswap@example.test",
role: "USER",
},
});
// 50 measurements across 30 days for WEIGHT and PULSE so both
// type families exercise the per-type composition path. The
// values are deliberately fractional so the round2 contract gets
// a workout.
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const seedRows: Array<{
userId: string;
type: "WEIGHT" | "PULSE";
value: number;
unit: string;
source: "MANUAL";
measuredAt: Date;
}> = [];
for (let i = 0; i < 50; i++) {
seedRows.push({
userId: user.id,
type: "WEIGHT",
value: 80 + (i % 7) * 0.37,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(now - (29 - (i % 30)) * dayMs - i * 60 * 1000),
});
seedRows.push({
userId: user.id,
type: "PULSE",
value: 60 + (i % 11) * 1.13,
unit: "bpm",
source: "MANUAL",
measuredAt: new Date(now - (29 - (i % 30)) * dayMs - i * 90 * 1000),
});
}
await prisma.measurement.createMany({ data: seedRows });
// First call warms the rollup (ensureUserRollupsFresh fires the
// DAY recompute since no buckets exist yet).
const aggregate = await buildComprehensiveAggregate(user.id);
// Parallel live aggregation over the same 90-day window. This is
// the source of truth — the read-swap must produce the same
// count/min/max/mean byte-for-byte.
const ninetyDaysAgo = new Date(Date.now() - 90 * dayMs);
const live = await prisma.$queryRaw<
Array<{
type: string;
count: bigint;
min_value: number;
max_value: number;
mean_value: number;
}>
>`
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
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${ninetyDaysAgo}
GROUP BY m."type"
`;
const round2 = (n: number) => Math.round(n * 100) / 100;
expect(live.length).toBeGreaterThan(0);
for (const liveRow of live) {
const summary = aggregate.summaries[liveRow.type];
expect(summary, `summary for ${liveRow.type}`).toBeDefined();
expect(summary.count).toBe(Number(liveRow.count));
expect(summary.min).toBe(round2(liveRow.min_value));
expect(summary.max).toBe(round2(liveRow.max_value));
// The composed weighted mean equals AVG(value) over the same
// rows; both run through `round2`.
expect(summary.mean).toBe(round2(liveRow.mean_value));
}
});
it("dailyByType matches a parallel live SQL daily-mean aggregation", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-daily-user",
email: "rollup-daily@example.test",
role: "USER",
},
});
const dayMs = 24 * 60 * 60 * 1000;
const now = Date.now();
const rows: Array<{
userId: string;
type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "PULSE";
value: number;
unit: string;
source: "MANUAL";
measuredAt: Date;
}> = [];
// 3 readings per day for 20 days, three types.
for (let day = 0; day < 20; day++) {
const dayAnchor = now - day * dayMs;
for (let k = 0; k < 3; k++) {
rows.push({
userId: user.id,
type: "WEIGHT",
value: 81 + day * 0.05 + k * 0.13,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(dayAnchor - k * 60 * 60 * 1000),
});
rows.push({
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 120 + day * 0.21 + k * 0.07,
unit: "mmHg",
source: "MANUAL",
measuredAt: new Date(dayAnchor - k * 30 * 60 * 1000),
});
rows.push({
userId: user.id,
type: "PULSE",
value: 65 + day * 0.07 + k * 0.19,
unit: "bpm",
source: "MANUAL",
measuredAt: new Date(dayAnchor - k * 15 * 60 * 1000),
});
}
}
await prisma.measurement.createMany({ data: rows });
// v1.4.37.1 — the aggregator now fires `ensureUserRollupsFresh`
// and-forgets; on a cold testcontainer that means the rollups are
// not yet present when the aggregate reads. Force a synchronous
// recompute here so the rollup-driven `dailyByType` branch is
// exercised (matches the production warm-cache path).
await recomputeUserRollups(user.id);
const aggregate = await buildComprehensiveAggregate(user.id);
// Parallel live aggregation — same shape the legacy SQL emitted.
const ninetyDaysAgo = new Date(now - 90 * dayMs);
const live = await prisma.$queryRaw<
Array<{ type: string; day: string; mean_value: number }>
>`
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" = ${user.id}
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
`;
const liveByType = new Map<string, Array<{ day: string; value: number }>>();
for (const row of live) {
const list = liveByType.get(row.type) ?? [];
list.push({ day: row.day, value: Number(row.mean_value) });
liveByType.set(row.type, list);
}
for (const [type, liveDaily] of liveByType.entries()) {
const rollupDaily = aggregate.dailyByType[type];
expect(rollupDaily, `dailyByType[${type}]`).toBeDefined();
expect(rollupDaily!.length).toBe(liveDaily.length);
for (let i = 0; i < liveDaily.length; i++) {
expect(rollupDaily![i].day).toBe(liveDaily[i].day);
// The rollup rounds its bucket mean to two decimals; live SQL rounds
// the full-day AVG to two decimals. Both are 2-decimal, but rounding
// through different intermediate paths can land one cent apart on a
// half-cent boundary — so assert agreement to within a few hundredths,
// not bit-for-bit. min/max above stay exact.
expect(rollupDaily![i].value).toBeCloseTo(liveDaily[i].value, 1);
}
}
});
it("reflects a freshly written measurement on the next read after a warm cache", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-freshness-user",
email: "rollup-freshness@example.test",
role: "USER",
},
});
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
await prisma.measurement.createMany({
data: [
{
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(now - 5 * dayMs),
},
{
userId: user.id,
type: "WEIGHT",
value: 81,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(now - 4 * dayMs),
},
],
});
// Warm-up read populates the rollups via `ensureUserRollupsFresh`.
const before = await buildComprehensiveAggregate(user.id);
expect(before.summaries.WEIGHT.count).toBe(2);
expect(before.summaries.WEIGHT.mean).toBe(80.5);
// Insert a third row and synchronously refresh the DAY bucket the
// way the write-path hooks do.
const newMeasuredAt = new Date(now - 1 * dayMs);
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 84,
unit: "kg",
source: "MANUAL",
measuredAt: newMeasuredAt,
},
});
await recomputeBucketsForMeasurement(user.id, "WEIGHT", newMeasuredAt);
// Next read reflects the third measurement — the rollup-derived
// count moves from 2 to 3 and the weighted mean tracks live SQL.
const after = await buildComprehensiveAggregate(user.id);
expect(after.summaries.WEIGHT.count).toBe(3);
// Weighted mean across the 3 buckets (each count=1): (80+81+84)/3
// = 81.6667 → round2 = 81.67.
expect(after.summaries.WEIGHT.mean).toBe(81.67);
});
// ─── v1.4.35.1 boot-time backfill ──────────────────────────
//
// Self-hosted instances upgrading to v1.4.35 onwards must auto-
// converge on the persistent rollup tier without operator action.
// The discovery query matches users with measurements but zero
// rollup rows; one full-fold job is enqueued per such account.
it("enqueues one full-fold job per user with measurements but no rollups", async () => {
const prisma = getPrismaClient();
// Three users: two with measurements (one already-folded, one
// uncovered), one with no measurements at all (skipped entirely).
const covered = await prisma.user.create({
data: {
username: "boot-backfill-covered",
email: "boot-backfill-covered@example.test",
role: "USER",
},
});
const uncovered = await prisma.user.create({
data: {
username: "boot-backfill-uncovered",
email: "boot-backfill-uncovered@example.test",
role: "USER",
},
});
const empty = await prisma.user.create({
data: {
username: "boot-backfill-empty",
email: "boot-backfill-empty@example.test",
role: "USER",
},
});
void empty;
const measuredAt = new Date("2026-04-01T08:00:00.000Z");
await prisma.measurement.createMany({
data: [
{
userId: covered.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt,
},
{
userId: uncovered.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt,
},
],
});
// Pre-fold the covered user so the discovery query skips them.
await recomputeUserRollups(covered.id, { granularities: ["DAY"] });
bossSend.mockResolvedValue("job-id");
vi.mocked(getGlobalBoss).mockReturnValue({ send: bossSend } as never);
const result = await enqueueBootTimeRollupBackfill();
// Only the uncovered user lands on the queue. The covered user
// already has DAY rollups; the empty user has no measurements.
expect(result.enqueued).toBe(1);
expect(result.skipped).toBe(0);
expect(result.error).toBeNull();
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend.mock.calls[0][0]).toBe(ROLLUP_FULL_BACKFILL_QUEUE);
expect(bossSend.mock.calls[0][1]).toMatchObject({ userId: uncovered.id });
expect(bossSend.mock.calls[0][2].singletonKey).toBe(
`boot-backfill|${uncovered.id}`,
);
// Reset boss stub for downstream tests.
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
});
it("returns { enqueued: 0 } when every user is already covered", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "boot-backfill-allcovered",
email: "boot-backfill-allcovered@example.test",
role: "USER",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-04-01T08:00:00.000Z"),
},
});
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
bossSend.mockResolvedValue("job-id");
vi.mocked(getGlobalBoss).mockReturnValue({ send: bossSend } as never);
const result = await enqueueBootTimeRollupBackfill();
expect(result.enqueued).toBe(0);
expect(bossSend).not.toHaveBeenCalled();
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
});
// ─── v1.4.38.5 regression — partial coverage discovery ──────
//
// The v1.4.35.1 shape only matched users with ZERO rollup rows
// anywhere. That silently stranded users who logged a brand-new
// type after the initial fold: their existing WEIGHT bucket made
// them invisible to discovery, but the brand-new PULSE type still
// had no DAY bucket → `isFullyCovered` stayed `false` → every read
// fanned out to the live aggregator (10s of seconds on power-user
// accounts). v1.4.38.5 widens discovery to per-type-missing.
it("v1.4.38.5 — enqueues a user with rollups for one type but a brand-new uncovered type", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "boot-backfill-partial",
email: "boot-backfill-partial@example.test",
role: "USER",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-04-01T08:00:00.000Z"),
},
});
// Fold WEIGHT — this is the state a long-running account is in
// after the initial v1.4.35.1 boot-time backfill landed.
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
// Now log a brand-new type. The v1.4.35.1 discovery saw "user
// has at least one rollup row" and skipped them; v1.4.38.5
// discovery checks per-type coverage and surfaces them.
await prisma.measurement.create({
data: {
userId: user.id,
type: "PULSE",
value: 65,
unit: "bpm",
source: "MANUAL",
measuredAt: new Date("2026-04-02T08:00:00.000Z"),
},
});
bossSend.mockResolvedValue("job-id");
vi.mocked(getGlobalBoss).mockReturnValue({ send: bossSend } as never);
const result = await enqueueBootTimeRollupBackfill();
expect(result.enqueued).toBe(1);
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend.mock.calls[0][1]).toMatchObject({ userId: user.id });
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
});
// ─── v1.4.36 rollup-fresh skip-live contract ──────────────────
//
// The v1.4.35 read-swap kept the heavy live aggregate running in
// parallel with the rollup read. v1.4.36 drops that — when the
// rollup table has DAY buckets for the user, the heavy aggregate
// is bypassed entirely. This test pins that the rollup-derived
// response is still byte-identical to a parallel live aggregate
// over the same rows AND that the response shape matches the
// pre-swap contract for every field.
it("rollup-fresh response matches live SQL for every DataSummary field", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-skip-live-user",
email: "rollup-skip-live@example.test",
role: "USER",
},
});
// 60 readings over 30 days, two types, deliberately spread so
// anomalies + slopes + windowed avgs all have signal.
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const seedRows: Array<{
userId: string;
type: "WEIGHT" | "PULSE";
value: number;
unit: string;
source: "MANUAL";
measuredAt: Date;
}> = [];
for (let day = 0; day < 30; day++) {
seedRows.push({
userId: user.id,
type: "WEIGHT",
value: 80 + day * 0.05 + ((day * 7) % 5) * 0.1,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(now - (29 - day) * dayMs - day * 60 * 1000),
});
seedRows.push({
userId: user.id,
type: "PULSE",
value: 65 + ((day * 3) % 11) * 0.4,
unit: "bpm",
source: "MANUAL",
measuredAt: new Date(now - (29 - day) * dayMs - day * 90 * 1000),
});
}
await prisma.measurement.createMany({ data: seedRows });
// Pre-fold the rollup buckets so the rollup-fresh path engages.
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
// Parallel live aggregation over the same 90-day window using the
// exact heavy-aggregate shape the cold-mount fallback runs.
const ninetyDaysAgo = new Date(now - 90 * dayMs);
const live = await prisma.$queryRaw<
Array<{
type: string;
count: bigint;
min_value: number;
max_value: number;
mean_value: number;
}>
>`
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
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${ninetyDaysAgo}
GROUP BY m."type"
`;
const aggregate = await buildComprehensiveAggregate(user.id);
const round2 = (n: number) => Math.round(n * 100) / 100;
expect(live.length).toBeGreaterThan(0);
for (const liveRow of live) {
const summary = aggregate.summaries[liveRow.type];
expect(summary, `summary for ${liveRow.type}`).toBeDefined();
// count / min / max / mean compose from buckets — byte-identical
// to live SQL over the same rows.
expect(summary.count).toBe(Number(liveRow.count));
expect(summary.min).toBe(round2(liveRow.min_value));
expect(summary.max).toBe(round2(liveRow.max_value));
expect(summary.mean).toBe(round2(liveRow.mean_value));
// Narrow aggregate fills the non-composable windowed columns.
// Slope tuples and avg7/avg30 are sourced from live SQL even on
// the rollup-fresh path so the values are present (not null) as
// long as the windowed avg has rows to chew on.
expect(summary.avg30).not.toBeNull();
// Latest reading comes from the DISTINCT ON pass — never null
// when the user has rows for the type.
expect(summary.latest).not.toBeNull();
}
});
it("falls back to the live aggregate for a type without rollup coverage", async () => {
// v1.4.36 QA C1 — pre-fix the global rollup probe flipped to true
// as soon as ANY type had a DAY bucket, which made the comprehensive
// response collapse a brand-new type's all-time count to whatever
// the bucket-derived path could see (often 1 — the freshly-written
// bucket). This guards the per-type probe so adding a first WEIGHT
// measurement to a BP-rolled-up account doesn't truncate the WEIGHT
// summary.
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "rollup-coverage-mix",
email: "rollup-coverage-mix@example.test",
role: "USER",
},
});
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
// Seed BP — multiple days within the 90-day window so the rollup
// path has something to compose against.
const bpRows = [];
for (let day = 0; day < 10; day += 1) {
bpRows.push({
userId: user.id,
type: "BLOOD_PRESSURE_SYS" as const,
value: 120 + day,
unit: "mmHg",
source: "MANUAL" as const,
measuredAt: new Date(now - day * dayMs),
});
}
await prisma.measurement.createMany({ data: bpRows });
// Pre-fold BP DAY buckets only. WEIGHT will be added below WITHOUT
// a populator pass so its coverage probe returns false.
await recomputeUserRollups(user.id, {
granularities: ["DAY"],
types: ["BLOOD_PRESSURE_SYS"],
});
// Insert WEIGHT measurements directly via Prisma — no rollup
// populator runs, no DAY bucket exists for WEIGHT.
const weightRows = [];
for (let day = 0; day < 5; day += 1) {
weightRows.push({
userId: user.id,
type: "WEIGHT" as const,
value: 80 + day,
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - day * dayMs - 1000),
});
}
await prisma.measurement.createMany({ data: weightRows });
// Confirm setup: BP has rollups, WEIGHT does not.
const bpRollups = await prisma.measurementRollup.count({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
granularity: "DAY",
},
});
const weightRollups = await prisma.measurementRollup.count({
where: { userId: user.id, type: "WEIGHT", granularity: "DAY" },
});
expect(bpRollups).toBeGreaterThan(0);
expect(weightRollups).toBe(0);
const aggregate = await buildComprehensiveAggregate(user.id);
// Parallel live SQL — the contract is that the aggregator surfaces
// a WEIGHT summary whose count matches the actual measurement
// count, not zero (pre-fix) and not a bucket-derived partial.
const liveCounts = await prisma.$queryRaw<
Array<{ type: string; count: bigint }>
>`
SELECT m."type"::text AS type, COUNT(*) AS count
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = 'WEIGHT'
GROUP BY m."type"
`;
expect(liveCounts).toHaveLength(1);
const expectedWeightCount = Number(liveCounts[0].count);
expect(aggregate.summaries.WEIGHT).toBeDefined();
expect(aggregate.summaries.WEIGHT.count).toBe(expectedWeightCount);
expect(aggregate.summaries.BLOOD_PRESSURE_SYS).toBeDefined();
});
});