Files
HealthLog/tests/integration/consolidate-legacy-steps.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

229 lines
7.1 KiB
TypeScript

/**
* v1.5.6 — real-Postgres integration coverage for
* `consolidateLegacySteps()`. Mirrors the
* `drain-per-sample-cumulative` fixture: the testcontainer is hot
* before this file loads, so the body truncates + seeds + consolidates.
*
* Pins the contracts the unit mocks can't:
* - legacy granular rows collapse into one daily total with the
* correct sum;
* - the originals are SOFT-deleted (tombstoned, not gone);
* - a day already holding a `stats:` total is not double-counted;
* - the discovery query is idempotent (second run = no-op).
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { consolidateLegacySteps } from "@/lib/measurements/consolidate-legacy-steps";
const TEST_USER_ID = "user-step-consolidation";
const STEP_TOTAL_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-05-16";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "step-consolidation",
email: "step-consolidation@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("consolidateLegacySteps (real Postgres)", () => {
it("collapses legacy granular step rows into one daily total and soft-deletes the originals", 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-legacy-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-legacy-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-legacy-3",
},
],
});
const summary = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
expect(summary.totals.legacyRowsSoftDeleted).toBe(3);
expect(summary.totals.dailyRowsUpserted).toBe(1);
expect(summary.totals.daysFoldedIntoExisting).toBe(0);
// One live daily total carrying the canonical externalId + sum.
const live = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
});
expect(live).toHaveLength(1);
expect(live[0].value).toBe(5400);
expect(live[0].externalId).toBe(STEP_TOTAL_ID);
// Originals are tombstoned, NOT gone — audit trail preserved.
const tombstoned = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
deletedAt: { not: null },
},
});
expect(tombstoned).toHaveLength(3);
expect(
tombstoned.every((r) => r.externalId?.startsWith("hk-legacy-")),
).toBe(true);
});
it("does not double-count when a stats: daily total already exists for the day", async () => {
const prisma = getPrismaClient();
// Post-v1.5.0 daily total already written by iOS.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 9000,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: STEP_TOTAL_ID,
},
});
// Legacy granular rows for the same day.
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-legacy-a",
},
{
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-legacy-b",
},
],
});
const summary = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
expect(summary.totals.daysFoldedIntoExisting).toBe(1);
// No new daily row minted — the existing total stands.
expect(summary.totals.dailyRowsUpserted).toBe(0);
expect(summary.totals.legacyRowsSoftDeleted).toBe(2);
const live = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
});
expect(live).toHaveLength(1);
// The existing total is untouched — NOT 9000 + 1200 + 800.
expect(live[0].value).toBe(9000);
expect(live[0].externalId).toBe(STEP_TOTAL_ID);
// Both legacy rows tombstoned.
const tombstoned = await prisma.measurement.count({
where: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
deletedAt: { not: null },
},
});
expect(tombstoned).toBe(2);
});
it("is idempotent — a second run is a no-op", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 2000,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T09:00:00.000Z"),
externalId: "hk-legacy-x",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 3000,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T18:00:00.000Z"),
externalId: "hk-legacy-y",
},
],
});
const first = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(first.totals.daysConsolidated).toBe(1);
expect(first.totals.legacyRowsSoftDeleted).toBe(2);
const second = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(second.totals.daysConsolidated).toBe(0);
expect(second.totals.legacyRowsSoftDeleted).toBe(0);
expect(second.totals.dailyRowsUpserted).toBe(0);
// State is stable: one live total (5000), two tombstones.
const live = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
});
expect(live).toHaveLength(1);
expect(live[0].value).toBe(5000);
const tombstoned = await prisma.measurement.count({
where: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
deletedAt: { not: null },
},
});
expect(tombstoned).toBe(2);
});
});