mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
250 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|