From 3ccc9918512f69c61bc6a50279fcf799295f059f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:08:20 +0200 Subject: [PATCH] test: pin the rollup window, the intra-batch stats: winner, and the resting merge Each case fails on the pre-fix code: the rollup tests on an unaligned recompute window, the batch tests on the older stats: snapshot winning, and the resting test on a series that stops at the fold horizon. --- .../__tests__/aggregated-hr-wire.test.ts | 86 +++++++++++++++++++ .../analytics/__tests__/resting-pulse.test.ts | 73 ++++++++++++++++ .../__tests__/measurement-rollups.test.ts | 64 ++++++++++++++ 3 files changed, 223 insertions(+) diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 79f28b182..3b6bde237 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -409,3 +409,89 @@ describe("POST /api/measurements/batch — stats tombstone resurrection", () => expect(prisma.measurement.createMany).not.toHaveBeenCalled(); }); }); + +/** + * Two entries carrying the SAME `stats:` key inside ONE batch. Neither is in + * the DB yet, so before the fix both missed the existing-row probe, both + * landed in the bulk insert, and `skipDuplicates` kept whichever the unique + * index saw first — the OLDER snapshot won and the newer, larger figure was + * dropped silently while both entries still reported `inserted`. + * + * `stats:` rows are overwrite-by-contract, so the LAST entry for a key wins. + */ +describe("POST /api/measurements/batch — intra-batch `stats:` supersession", () => { + it("keeps only the LAST entry when one batch carries the same stats: key twice", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry(HR_BUCKET_ID, 70), // earlier snapshot of a filling bucket + hrBucketEntry(HR_BUCKET_ID, 78), // later, authoritative snapshot + ], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + + // Exactly ONE row reaches the insert, carrying the NEWER value. + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + const rows = (createArg as { data: { value: number }[] }).data; + expect(rows).toHaveLength(1); + expect(rows[0].value).toBe(78); + + // The superseded entry is reported honestly, not as a phantom insert. + expect(data.entries[0].status).toBe("duplicate"); + expect(data.entries[0].reason).toBe("superseded_in_batch"); + expect(data.entries[1].status).toBe("inserted"); + expect(data.inserted).toBe(1); + expect(data.duplicates).toBe(1); + }); + + it("overwrites an existing row exactly once, with the LAST value in the batch", async () => { + // The key is already stored, so both entries take the overwrite branch. + // Only the authoritative last snapshot may reach the DB. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID }, + ] as never); + + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry(HR_BUCKET_ID, 70), + hrBucketEntry(HR_BUCKET_ID, 78), + ], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + + expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1); + const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0]; + expect((updateArg as { data: { value: number } }).data.value).toBe(78); + + expect(data.entries[0].status).toBe("duplicate"); + expect(data.entries[0].reason).toBe("superseded_in_batch"); + expect(data.entries[1].status).toBe("updated"); + expect(data.updated).toBe(1); + }); + + it("leaves DISTINCT stats: keys in one batch untouched", async () => { + const OTHER = + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:10:00.000Z"; + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 2 }); + + const res = await POST( + makeRequest({ + entries: [hrBucketEntry(HR_BUCKET_ID, 70), hrBucketEntry(OTHER, 78)], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + expect((createArg as { data: unknown[] }).data).toHaveLength(2); + expect(data.inserted).toBe(2); + expect(data.duplicates).toBe(0); + }); +}); diff --git a/src/lib/analytics/__tests__/resting-pulse.test.ts b/src/lib/analytics/__tests__/resting-pulse.test.ts index af41b0e4d..af7e7fc6b 100644 --- a/src/lib/analytics/__tests__/resting-pulse.test.ts +++ b/src/lib/analytics/__tests__/resting-pulse.test.ts @@ -187,6 +187,79 @@ describe("resolveRestingPulseSeries", () => { resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }), ).toEqual({ series: [], which: "none" }); }); + + /** + * The shape a proxy account actually has once the retention fold has run + * for a while: derived RESTING_HEART_RATE rows for the OLD days the fold + * has already processed, and nothing but raw PULSE for the RECENT days it + * has not reached yet. The all-or-nothing resolver saw the derived rows, + * took the resting branch, and dropped the proxy — so the series the user + * saw stopped dead at the fold horizon. + */ + it("carries the series past the fold horizon — derived rows for old days, PULSE for recent ones", () => { + // Days 01-05: folded, one derived resting row each. No PULSE left — + // the fold tombstoned the raw readings it derived them from. + const restingSamples: PulseSample[] = [ + day("2026-06-01T04:00:00", 61), + day("2026-06-02T04:00:00", 62), + day("2026-06-03T04:00:00", 63), + day("2026-06-04T04:00:00", 64), + day("2026-06-05T04:00:00", 65), + ]; + // Days 06-08: not yet folded. Raw PULSE only, no resting row at all. + const pulseSamples: PulseSample[] = []; + for (const d of ["06", "07", "08"]) { + pulseSamples.push( + day(`2026-06-${d}T07:00:00`, 66), + day(`2026-06-${d}T07:10:00`, 68), + day(`2026-06-${d}T07:20:00`, 70), + day(`2026-06-${d}T18:00:00`, 155), // workout burst, must not win + ); + } + + const { series, which } = resolveRestingPulseSeries({ + restingSamples, + pulseSamples, + }); + + // Five folded days + three proxy days — the recent days MUST appear. + expect(series).toHaveLength(8); + const days = series.map((p) => + p.measuredAt.toLocaleDateString("en-CA", { timeZone: PROXY_DAY_ZONE }), + ); + expect(days).toEqual([ + "2026-06-01", + "2026-06-02", + "2026-06-03", + "2026-06-04", + "2026-06-05", + "2026-06-06", + "2026-06-07", + "2026-06-08", + ]); + // The proxy days track the resting floor, not the workout burst. + for (const point of series.slice(5)) { + expect(point.value).toBeLessThan(100); + } + // Mixed series — hedge the label rather than call an estimate native. + expect(which).toBe("proxy"); + }); + + it("keeps the resting row on a day that has both a resting row and PULSE", () => { + // A native day must not be second-guessed by the proxy, and a native + // account with no gap days keeps the honest 'resting' label. + const { series, which } = resolveRestingPulseSeries({ + restingSamples: [day("2026-06-01T04:00:00", 58)], + pulseSamples: [ + day("2026-06-01T07:00:00", 80), + day("2026-06-01T07:10:00", 82), + day("2026-06-01T07:20:00", 84), + ], + }); + expect(series).toHaveLength(1); + expect(series[0].value).toBe(58); + expect(which).toBe("resting"); + }); }); describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", () => { diff --git a/src/lib/rollups/__tests__/measurement-rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts index 21d23ab4b..d8a7a2747 100644 --- a/src/lib/rollups/__tests__/measurement-rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -487,6 +487,70 @@ describe("persistRollupRows — large path (via recomputeUserRollups)", () => { expect(deleteMany).toHaveBeenCalledTimes(1); }); + /** + * The aggregate filters measurements by `measured_at` but GROUPS them by + * `date_trunc`, and `persistRollupRows` then deletes every rollup row in + * the bucket range it writes. A `from` that lands mid-bucket therefore + * replaced a COMPLETE bucket with an aggregate built only from the + * readings after that instant — and because every caller passes a moving + * instant, a different oldest bucket was corrupted on each run and nothing + * repaired it. The window must be snapped out to whole buckets first. + */ + describe("bucket alignment of the recompute window", () => { + it("snaps a mid-day `from` down to the start of its DAY bucket", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1)); + txExecuteRawUnsafe.mockResolvedValueOnce(1); + + // 14:37:12Z on 2024-03-10 — squarely inside a day bucket. + const midDay = new Date(Date.UTC(2024, 2, 10, 14, 37, 12, 500)); + await recomputeUserRollups("user-1", { + granularities: ["DAY"], + from: midDay, + to: new Date(Date.UTC(2024, 2, 20, 9, 5, 0)), + }); + + // runRollupAggregate → $queryRawUnsafe(sql, userId, from, to) + const [, userId, fromArg, toArg] = queryRawUnsafe.mock.calls[0]; + expect(userId).toBe("user-1"); + // Whole-bucket lower edge — the morning of the 10th must be included. + expect((fromArg as Date).toISOString()).toBe("2024-03-10T00:00:00.000Z"); + // Upper edge snaps out to the end of the closing bucket. + expect((toArg as Date).toISOString()).toBe("2024-03-21T00:00:00.000Z"); + }); + + it("snaps a mid-month `from` back to the 1st for MONTH granularity", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1)); + txExecuteRawUnsafe.mockResolvedValueOnce(1); + + await recomputeUserRollups("user-1", { + granularities: ["MONTH"], + from: new Date(Date.UTC(2024, 4, 17, 8, 0, 0)), + to: new Date(Date.UTC(2024, 6, 3, 0, 0, 0)), + }); + + const [, , fromArg, toArg] = queryRawUnsafe.mock.calls[0]; + expect((fromArg as Date).toISOString()).toBe("2024-05-01T00:00:00.000Z"); + expect((toArg as Date).toISOString()).toBe("2024-08-01T00:00:00.000Z"); + }); + + it("leaves an already-aligned window untouched", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1)); + txExecuteRawUnsafe.mockResolvedValueOnce(1); + + const from = new Date(Date.UTC(2024, 2, 10)); + const to = new Date(Date.UTC(2024, 2, 20)); + await recomputeUserRollups("user-1", { + granularities: ["DAY"], + from, + to, + }); + + const [, , fromArg, toArg] = queryRawUnsafe.mock.calls[0]; + expect((fromArg as Date).getTime()).toBe(from.getTime()); + expect((toArg as Date).getTime()).toBe(to.getTime()); + }); + }); + it("the ≤500-row path runs through the same interactive upsert transaction", async () => { queryRawUnsafe.mockResolvedValueOnce(syntheticRows(2)); txExecuteRawUnsafe.mockResolvedValueOnce(2);