Files
HealthLog/tests/integration/drain-per-sample-cumulative.test.ts
T
Marc-André Bombeck 0e92f96e72 feat(scripts): drain per-sample cumulative HealthKit rows into daily-aggregated rows
Idempotent maintenance tool that collapses pre-Option-A per-sample
APPLE_HEALTH cumulative rows (steps, active energy, flights climbed,
walking + running distance, time in daylight) into one row per day
per type, keyed by the locked dailyStatsExternalId shape. Per
user × type × calendar-day (anchored to User.timezone): SUM the values,
UPSERT a daily row at canonical midday-local (matching the Withings
activity-sync convention), DELETE the per-sample rows.

Three entry points share src/lib/measurements/drain-per-sample-cumulative.ts:
  - Library helper for unit + integration tests
  - CLI at scripts/drain-per-sample-cumulative.ts with --user / --all
    + --confirm flags (default dry-run)
  - Admin endpoint POST /api/admin/drain-per-sample-cumulative gated
    by requireAdmin() with dryRun defaulting to true

Operator runs the drain once after the iOS TestFlight build adopts
HealthKitStatisticsService.swift. Re-running on a fully-collapsed
account reports zero buckets touched.

Tests cover bucketing semantics, the canonical-timestamp math across
DST + half-hour zones, idempotent re-runs, dry-run preview, spot-metric
exclusion, and already-collapsed-row skip via real-Postgres.
2026-05-16 11:56:39 +02:00

250 lines
7.3 KiB
TypeScript

/**
* v1.4.30 — real-Postgres integration coverage for
* `drainPerSampleCumulative()`. Mirrors the
* `measurements-aggregate-daily` fixture: the testcontainer is hot
* before this file loads, so the body just truncates + seeds + drains.
*
* The drain logic operates against `prisma.$transaction` with an upsert
* inside; mocking that surface would re-introduce the v1.4.29 lesson
* (mocks hide real-SQL behaviour). The container test pins the
* idempotent-re-run contract end-to-end.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
const TEST_USER_ID = "user-drain-cumulative";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "drain-cumulative",
email: "drain-cumulative@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("drainPerSampleCumulative (real Postgres)", () => {
it("collapses three per-sample step rows on the same day into one daily row", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 1200,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 3400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-uuid-2",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
externalId: "hk-uuid-3",
},
],
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.dryRun).toBe(false);
expect(summary.totals.bucketsCollapsed).toBe(1);
expect(summary.totals.perSampleRowsDeleted).toBe(3);
expect(summary.totals.dailyRowsUpserted).toBe(1);
const remaining = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
},
orderBy: { measuredAt: "asc" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0].value).toBe(5400);
expect(remaining[0].externalId).toBe(
"stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
);
// 12:00 Berlin during DST = 10:00 UTC.
expect(remaining[0].measuredAt.toISOString()).toBe(
"2026-05-16T10:00:00.000Z",
);
});
it("is idempotent — a second run after a successful drain is a no-op", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 1200,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-uuid-2",
},
],
});
await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
const second = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(second.totals.bucketsCollapsed).toBe(0);
expect(second.totals.perSampleRowsDeleted).toBe(0);
expect(second.totals.dailyRowsUpserted).toBe(0);
});
it("dry-run reports the plan without touching the DB", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "FLIGHTS_CLIMBED",
value: 5,
unit: "flights",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-flights-1",
},
{
userId: TEST_USER_ID,
type: "FLIGHTS_CLIMBED",
value: 3,
unit: "flights",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-flights-2",
},
],
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: true,
log: () => {},
});
expect(summary.dryRun).toBe(true);
expect(summary.totals.bucketsCollapsed).toBe(1);
expect(summary.buckets[0].sumValue).toBe(8);
expect(summary.buckets[0].externalId).toBe(
"stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16",
);
// DB unchanged.
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "FLIGHTS_CLIMBED" },
});
expect(remaining).toHaveLength(2);
});
it("ignores spot metrics (PULSE) — only cumulative types collapse", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "PULSE",
value: 64,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-pulse-1",
},
{
userId: TEST_USER_ID,
type: "PULSE",
value: 72,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-pulse-2",
},
],
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.bucketsCollapsed).toBe(0);
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "PULSE" },
});
expect(remaining).toHaveLength(2);
});
it("skips rows already in stats:... shape (re-running after an earlier drain)", async () => {
const prisma = getPrismaClient();
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 5400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
},
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.bucketsCollapsed).toBe(0);
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0].value).toBe(5400);
});
});