Files
HealthLog/tests/integration/consolidate-legacy-steps.test.ts
T
Marc-André Bombeck 0ba5334b57 fix(measurements): legacy step consolidation no longer sweeps provider daily totals
WIP-commit (worktree safeguard; final assembly + version bump happen in the main tree after v1.28.36 merges).
2026-07-15 22:49:07 +02:00

360 lines
12 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("leaves Google Health AND Fitbit daily-total rows untouched (the data-loss bug, pinned for both providers)", async () => {
const prisma = getPrismaClient();
// Provider daily totals, keyed `stats:steps:<day>`, server-owned.
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 8123,
unit: "steps",
source: "GOOGLE_HEALTH",
measuredAt: new Date("2026-05-16T12:00:00.000Z"),
externalId: "stats:steps:2026-05-16",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 9456,
unit: "steps",
source: "FITBIT",
measuredAt: new Date("2026-05-17T12:00:00.000Z"),
externalId: "stats:steps:2026-05-17",
},
],
});
const summary = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
// Nothing discovered, nothing bucketed, nothing minted — the canary
// stays 0 (no provider row ever reached the scan).
expect(summary.totals.daysConsolidated).toBe(0);
expect(summary.totals.legacyRowsSoftDeleted).toBe(0);
expect(summary.totals.dailyRowsUpserted).toBe(0);
expect(summary.totals.providerRowsSkipped).toBe(0);
// Both provider rows are still live, unchanged; no MANUAL mint created.
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" },
orderBy: { measuredAt: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows.every((r) => r.deletedAt === null)).toBe(true);
expect(rows.map((r) => r.source)).toEqual(["GOOGLE_HEALTH", "FITBIT"]);
expect(rows.map((r) => r.value)).toEqual([8123, 9456]);
const manual = await prisma.measurement.count({
where: { userId: TEST_USER_ID, source: "MANUAL" },
});
expect(manual).toBe(0);
});
it("leaves a Withings daily-total row untouched (source pin, not stats:-keyed)", async () => {
const prisma = getPrismaClient();
// Withings keys `withings:activity:…` — NOT `stats:`. Only the source
// pin protects it; the prefix skip alone would miss it.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 7000,
unit: "steps",
source: "WITHINGS",
measuredAt: new Date("2026-05-16T12:00:00.000Z"),
externalId: `withings:activity:${TEST_USER_ID}:2026-05-16:steps`,
},
});
const summary = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(0);
expect(summary.totals.providerRowsSkipped).toBe(0);
const live = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null },
});
expect(live).toHaveLength(1);
expect(live[0].source).toBe("WITHINGS");
expect(live[0].value).toBe(7000);
});
it("still consolidates genuine legacy IMPORT and MANUAL raw rows (the drain is not disabled)", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 1000,
unit: "steps",
source: "IMPORT",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "import-legacy-1",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 500,
unit: "steps",
source: "MANUAL",
measuredAt: new Date("2026-05-16T18:00:00.000Z"),
externalId: "manual-legacy-1",
},
],
});
const summary = await consolidateLegacySteps(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
expect(summary.totals.legacyRowsSoftDeleted).toBe(2);
expect(summary.totals.dailyRowsUpserted).toBe(1);
expect(summary.totals.providerRowsSkipped).toBe(0);
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(1500);
expect(live[0].externalId).toBe(STEP_TOTAL_ID);
expect(live[0].source).toBe("MANUAL");
});
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);
});
});