mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
db08962d41
commit
3ccc991851
3 files changed
+223
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user