Files
HealthLog/tests/integration/drain-per-sample-cumulative.test.ts
T

341 lines
9.9 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",
);
expect(remaining[0]).toMatchObject({
aggregationProvenance: "LEGACY_UNKNOWN",
syncVersion: 1,
});
});
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.each(["HEALTHKIT_STATISTICS", "EXPORT_XML_SOURCE_MAX"] as const)(
"drains source rows without mutating an authoritative %s total",
async (aggregationProvenance) => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 5_400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
aggregationProvenance,
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 500,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
externalId: `late-${aggregationProvenance}`,
},
],
});
await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
log: () => {},
});
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({
value: 5_400,
aggregationProvenance,
syncVersion: 1,
});
},
);
it("keeps legacy late-increment semantics and bumps syncVersion", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 5_400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
aggregationProvenance: "LEGACY_UNKNOWN",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 500,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
externalId: "late-legacy",
},
],
});
await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
log: () => {},
});
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({
value: 5_900,
aggregationProvenance: "LEGACY_UNKNOWN",
syncVersion: 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);
});
});