Files
HealthLog/tests/integration/dense-intraday-hourly-rebuild.test.ts
T
Marc-André Bombeck 4f198ec25f feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.

Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
2026-07-17 14:29:07 +02:00

366 lines
12 KiB
TypeScript

/**
* v1.28.31 — real-Postgres integration coverage for
* `runDenseIntradayHourlyRebuild()`: converts a pre-hourly folded day
* (live daily `stats:` row + tombstoned raw rows) to the hourly grain.
*
* Pins against live unique indexes: the hourly mints coexist with the
* daily row (different instants, different externalIds), the daily row is
* retired in the SAME transaction as the hourly mint (no live overlap →
* no double count for an AVG-over-live-rows reader), a re-run converges
* to zero work, days without tombstones keep their daily row, and days
* inside the retention window are untouched.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runDenseIntradayHourlyRebuild } from "@/lib/measurements/dense-intraday-hourly-rebuild";
import { findHourlyRebuildCandidateUserIds } from "@/lib/jobs/dense-intraday-hourly-rebuild";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
const TEST_USER_ID = "user-hourly-rebuild";
const TZ = "Europe/Berlin";
const DAY_KEY = "2026-05-01";
const CANONICAL = canonicalDailyTimestamp(DAY_KEY, TZ);
const HRV_HK = "HKQuantityTypeIdentifierHeartRateVariabilitySDNN";
const HRV_DAILY_STATS_ID = `stats:${HRV_HK}:${DAY_KEY}`;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "hourly-rebuild",
email: "hourly-rebuild@example.test",
timezone: TZ,
},
});
});
/** Seed the exact shape the pre-hourly fold left behind for one HRV day. */
async function seedFoldedDay(): Promise<void> {
const prisma = getPrismaClient();
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 50, // the old daily mean of (40, 60)
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: CANONICAL,
externalId: HRV_DAILY_STATS_ID,
},
});
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 40,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T08:00:00.000Z"), // local hour 10
externalId: "hk-hrv-1",
deletedAt: new Date("2026-05-15T02:00:00.000Z"), // fold tombstone
},
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 60,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T09:00:00.000Z"), // local hour 11
externalId: "hk-hrv-2",
deletedAt: new Date("2026-05-15T02:00:00.000Z"),
},
],
});
}
describe("runDenseIntradayHourlyRebuild (real Postgres)", () => {
it("rebuilds hourly means from the tombstones and retires the daily row atomically", async () => {
const prisma = getPrismaClient();
await seedFoldedDay();
const summary = await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysRebuilt).toBe(1);
expect(summary.totals.hourlyRowsUpserted).toBe(2);
expect(summary.totals.dailyRowsRetired).toBe(1);
expect(summary.totals.daysFailed).toBe(0);
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
});
// ONLY the hourly rows are live — never the daily row alongside them.
expect(live).toHaveLength(2);
expect(live.map((r) => r.externalId)).toEqual([
`stats:${HRV_HK}:${DAY_KEY}T10`,
`stats:${HRV_HK}:${DAY_KEY}T11`,
]);
expect(live.map((r) => r.value)).toEqual([40, 60]);
expect(live.map((r) => r.measuredAt.toISOString())).toEqual([
"2026-05-01T08:30:00.000Z",
"2026-05-01T09:30:00.000Z",
]);
// The daily row is tombstoned, and the raw tombstones stayed tombstoned
// (the rebuild reads them, never resurrects them).
const daily = await prisma.measurement.findFirst({
where: { userId: TEST_USER_ID, externalId: HRV_DAILY_STATS_ID },
});
expect(daily?.deletedAt).not.toBeNull();
const rawTombstones = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
externalId: { in: ["hk-hrv-1", "hk-hrv-2"] },
},
});
expect(rawTombstones.every((r) => r.deletedAt !== null)).toBe(true);
});
it("re-run converges to zero work (the retired daily row IS the marker)", async () => {
const prisma = getPrismaClient();
await seedFoldedDay();
const first = await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(first.totals.daysRebuilt).toBe(1);
const second = await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(second.totals.daysRebuilt).toBe(0);
expect(second.totals.hourlyRowsUpserted).toBe(0);
expect(second.totals.dailyRowsRetired).toBe(0);
expect(second.totals.daysSkippedNoTombstones).toBe(0);
// The hourly rows are unchanged by the second pass.
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
});
expect(live.map((r) => r.value)).toEqual([40, 60]);
});
it("keeps the daily row for days whose tombstones were pruned (nothing to reconstruct)", async () => {
const prisma = getPrismaClient();
// Daily row only — the raw tombstones were hard-deleted by the
// tombstone-retention prune long ago.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 50,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: CANONICAL,
externalId: HRV_DAILY_STATS_ID,
},
});
const summary = await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysRebuilt).toBe(0);
expect(summary.totals.daysSkippedNoTombstones).toBe(1);
const daily = await prisma.measurement.findFirst({
where: { userId: TEST_USER_ID, externalId: HRV_DAILY_STATS_ID },
});
// Untouched and still live — it is the day's only representation.
expect(daily?.deletedAt).toBeNull();
expect(daily?.value).toBeCloseTo(50, 6);
});
it("never touches days inside the retention window", async () => {
const prisma = getPrismaClient();
// A daily row anchored 2 days ago — inside the default 90-day window.
// (Synthetic: the real fold never produces one this recent, but the
// window bound must hold regardless of how the row got there.)
const recentNoon = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
const recentDayKey = recentNoon.toISOString().slice(0, 10);
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 50,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: recentNoon,
externalId: `stats:${HRV_HK}:${recentDayKey}`,
},
});
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 40,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date(recentNoon.getTime() - 60 * 60 * 1000),
externalId: "hk-hrv-recent",
deletedAt: new Date(),
},
});
const summary = await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
// Default retention window.
log: () => {},
});
expect(summary.totals.daysRebuilt).toBe(0);
const daily = await prisma.measurement.findFirst({
where: {
userId: TEST_USER_ID,
externalId: `stats:${HRV_HK}:${recentDayKey}`,
},
});
expect(daily?.deletedAt).toBeNull();
});
it("never touches non-APPLE_HEALTH sources", async () => {
const prisma = getPrismaClient();
// A Withings daily-shaped row + a tombstoned Withings raw row must both
// survive untouched — every rebuild predicate is source-scoped.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 48,
unit: "ms",
source: "WITHINGS",
measuredAt: CANONICAL,
externalId: HRV_DAILY_STATS_ID,
},
});
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 44,
unit: "ms",
source: "WITHINGS",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
externalId: "withings-hrv-1",
deletedAt: new Date(),
},
});
const summary = await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysRebuilt).toBe(0);
const withingsDaily = await prisma.measurement.findFirst({
where: { userId: TEST_USER_ID, source: "WITHINGS", deletedAt: null },
});
expect(withingsDaily?.value).toBeCloseTo(48, 6);
});
});
describe("findHourlyRebuildCandidateUserIds (boot-discovery SQL, real Postgres)", () => {
// Discovery bound "older than now" so the seeded 2026-05-01 day is in scope.
const WINDOW_START = new Date();
it("finds a user with a folded day (live daily row + paired tombstones)", async () => {
await seedFoldedDay();
const users = await findHourlyRebuildCandidateUserIds(
getPrismaClient(),
WINDOW_START,
);
expect(users).toEqual([TEST_USER_ID]);
});
it("converges: after the rebuild the pairing no longer matches", async () => {
const prisma = getPrismaClient();
await seedFoldedDay();
await runDenseIntradayHourlyRebuild(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
const users = await findHourlyRebuildCandidateUserIds(prisma, WINDOW_START);
// The retired daily row is the durable marker — zero candidates left,
// so the boot discovery enqueues nothing on every later boot.
expect(users).toEqual([]);
});
it("ignores a daily row without tombstoned raws, and hourly-shaped rows", async () => {
const prisma = getPrismaClient();
// Daily row alone (tombstones pruned) — no pair.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 50,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: CANONICAL,
externalId: HRV_DAILY_STATS_ID,
},
});
// Hourly-grain row + a tombstoned raw in its window — the hourly
// externalId shape must not qualify as a daily candidate.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "PULSE",
value: 60,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-02T08:30:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-05-02T10",
},
});
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "PULSE",
value: 62,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-02T08:00:00.000Z"),
externalId: "hk-pulse-raw",
deletedAt: new Date(),
},
});
const users = await findHourlyRebuildCandidateUserIds(prisma, WINDOW_START);
expect(users).toEqual([]);
});
it("ignores days inside the retention window (windowStart bound)", async () => {
await seedFoldedDay();
// A window start BEFORE the seeded day excludes it.
const users = await findHourlyRebuildCandidateUserIds(
getPrismaClient(),
new Date("2026-04-01T00:00:00.000Z"),
);
expect(users).toEqual([]);
});
});