mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Migration 0263 added three columns beside the Apple Health `stats:*` authority ladder. Only `aggregation_provenance` is ever read back: the external-measurement reconciler selects it and refuses a lower-authority write against a higher-authority canonical row, and the cumulative drain reads it too. That column stays. `aggregation_contributor_count` and `aggregation_selected_source_hash` are written by three call sites and read by none. No select, no raw SQL, no export, no backup, no API contract, no repair path. The import-time selection keeps its deterministic tiebreak, which compares an in-memory map key computed per flush rather than a value read back from the table. Both are plain nullable scalars with no index, unique, check or foreign key, so the drop is a catalog-only operation that touches no measured value.
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
reconcileExternalMeasurement,
|
|
type ExternalMeasurementWrite,
|
|
} from "@/lib/measurements/reconcile-external-measurement";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
async function createUser(id: string) {
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
id,
|
|
username: id,
|
|
email: `${id}@example.test`,
|
|
role: "USER",
|
|
},
|
|
});
|
|
}
|
|
|
|
const measuredAt = new Date("2026-07-20T06:30:00.000Z");
|
|
|
|
function desired(
|
|
userId: string,
|
|
externalId: string,
|
|
overrides: Partial<ExternalMeasurementWrite> = {},
|
|
): ExternalMeasurementWrite {
|
|
return {
|
|
userId,
|
|
type: "SLEEP_DURATION" as const,
|
|
value: 42,
|
|
unit: "minutes",
|
|
source: "OURA" as const,
|
|
measuredAt,
|
|
sleepStage: "DEEP" as const,
|
|
externalId,
|
|
externalSourceVersion: "v2",
|
|
deviceType: "ring",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function reconcile(
|
|
input: ExternalMeasurementWrite,
|
|
options?: { exactExternalMatch?: "update" | "duplicate" },
|
|
) {
|
|
const prisma = getPrismaClient();
|
|
return prisma.$transaction((tx) =>
|
|
reconcileExternalMeasurement(tx, input, options),
|
|
);
|
|
}
|
|
|
|
describe("external measurement identity reconciliation (real Postgres)", () => {
|
|
it("moves an external-id hit onto an occupied natural key and retires the redundant row", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("identity-external-move");
|
|
const external = await prisma.measurement.create({
|
|
data: {
|
|
...desired(user.id, "sleep:new"),
|
|
value: 30,
|
|
measuredAt: new Date("2026-07-20T06:00:00.000Z"),
|
|
externalSourceVersion: null,
|
|
},
|
|
});
|
|
const collision = await prisma.measurement.create({
|
|
data: {
|
|
...desired(user.id, "sleep:legacy"),
|
|
value: 35,
|
|
},
|
|
});
|
|
|
|
const verdict = await reconcile(desired(user.id, "sleep:new"));
|
|
|
|
expect(verdict).toMatchObject({
|
|
status: "updated",
|
|
row: { id: external.id, externalId: "sleep:new" },
|
|
retiredCollisionId: collision.id,
|
|
});
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.find((row) => row.id === external.id)).toMatchObject({
|
|
value: 42,
|
|
measuredAt,
|
|
externalId: "sleep:new",
|
|
externalSourceVersion: "v2",
|
|
deviceType: "ring",
|
|
deletedAt: null,
|
|
});
|
|
expect(rows.find((row) => row.id === collision.id)).toMatchObject({
|
|
externalId: "sleep:legacy",
|
|
deletedAt: expect.any(Date),
|
|
});
|
|
});
|
|
|
|
it("retires a lower-authority XML collision while preserving the native aggregate", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("identity-native-authority");
|
|
const nativeMeasuredAt = new Date("2026-07-20T10:00:00.000Z");
|
|
const xmlExternal = await prisma.measurement.create({
|
|
data: {
|
|
...desired(
|
|
user.id,
|
|
"stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
{
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_526,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: new Date("2026-07-20T09:00:00.000Z"),
|
|
sleepStage: null,
|
|
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
|
|
},
|
|
),
|
|
},
|
|
});
|
|
const native = await prisma.measurement.create({
|
|
data: {
|
|
...desired(user.id, "stats:legacy-native-id", {
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_600,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: nativeMeasuredAt,
|
|
sleepStage: null,
|
|
externalSourceVersion: "native-v1",
|
|
deviceType: "Apple Watch",
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
}),
|
|
},
|
|
});
|
|
|
|
const verdict = await reconcile(
|
|
desired(user.id, "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", {
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_526,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: nativeMeasuredAt,
|
|
sleepStage: null,
|
|
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
|
|
}),
|
|
{ exactExternalMatch: "update" },
|
|
);
|
|
|
|
expect(verdict).toMatchObject({
|
|
status: "duplicate",
|
|
row: { id: native.id, externalId: "stats:legacy-native-id" },
|
|
retiredCollisionId: xmlExternal.id,
|
|
});
|
|
const liveRows = await prisma.measurement.findMany({
|
|
where: { userId: user.id, deletedAt: null },
|
|
});
|
|
expect(liveRows).toHaveLength(1);
|
|
expect(liveRows[0]).toMatchObject({
|
|
id: native.id,
|
|
value: 8_600,
|
|
unit: "steps",
|
|
measuredAt: nativeMeasuredAt,
|
|
externalId: "stats:legacy-native-id",
|
|
externalSourceVersion: "native-v1",
|
|
deviceType: "Apple Watch",
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
});
|
|
expect(
|
|
await prisma.measurement.findUniqueOrThrow({
|
|
where: { id: xmlExternal.id },
|
|
}),
|
|
).toMatchObject({
|
|
externalId: `retired:${xmlExternal.id}:stats:HKQuantityTypeIdentifierStepCount:2026-07-20`,
|
|
deletedAt: expect.any(Date),
|
|
});
|
|
|
|
const nativeUpdate = await reconcile(
|
|
desired(user.id, "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", {
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8_700,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: nativeMeasuredAt,
|
|
sleepStage: null,
|
|
externalSourceVersion: "native-v2",
|
|
deviceType: "Apple Watch",
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
}),
|
|
{ exactExternalMatch: "update" },
|
|
);
|
|
|
|
expect(nativeUpdate).toMatchObject({
|
|
status: "updated",
|
|
row: {
|
|
id: native.id,
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
});
|
|
expect(
|
|
await prisma.measurement.findMany({
|
|
where: { userId: user.id, deletedAt: null },
|
|
}),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
id: native.id,
|
|
value: 8_700,
|
|
externalSourceVersion: "native-v2",
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("adopts a live natural-key row when a new external id collides", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("identity-natural-live");
|
|
const occupied = await prisma.measurement.create({
|
|
data: { ...desired(user.id, "sleep:legacy"), value: 30 },
|
|
});
|
|
|
|
const verdict = await reconcile(desired(user.id, "sleep:new"));
|
|
|
|
expect(verdict).toMatchObject({
|
|
status: "updated",
|
|
row: { id: occupied.id, externalId: "sleep:new" },
|
|
});
|
|
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it("resurrects and re-keys a tombstone occupying the natural key", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("identity-natural-tombstone");
|
|
const occupied = await prisma.measurement.create({
|
|
data: {
|
|
...desired(user.id, "sleep:legacy"),
|
|
value: 30,
|
|
deletedAt: new Date("2026-07-20T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const verdict = await reconcile(desired(user.id, "sleep:new"));
|
|
|
|
expect(verdict).toMatchObject({
|
|
status: "resurrected",
|
|
row: { id: occupied.id, externalId: "sleep:new" },
|
|
});
|
|
const stored = await prisma.measurement.findUniqueOrThrow({
|
|
where: { id: occupied.id },
|
|
});
|
|
expect(stored.deletedAt).toBeNull();
|
|
expect(stored.value).toBe(42);
|
|
});
|
|
|
|
it("never merges a row owned by another user", async () => {
|
|
const prisma = getPrismaClient();
|
|
const first = await createUser("identity-owner-first");
|
|
const second = await createUser("identity-owner-second");
|
|
const other = await prisma.measurement.create({
|
|
data: desired(first.id, "sleep:shared"),
|
|
});
|
|
|
|
const verdict = await reconcile(desired(second.id, "sleep:shared"));
|
|
|
|
expect(verdict.status).toBe("inserted");
|
|
expect(await prisma.measurement.count()).toBe(2);
|
|
expect(
|
|
await prisma.measurement.findUniqueOrThrow({ where: { id: other.id } }),
|
|
).toMatchObject({ userId: first.id, value: 42 });
|
|
});
|
|
|
|
it("serializes concurrent attempts that share the same natural identity", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("identity-concurrent");
|
|
|
|
const verdicts = await Promise.all([
|
|
reconcile(desired(user.id, "sleep:race-a", { value: 41 })),
|
|
reconcile(desired(user.id, "sleep:race-b", { value: 43 })),
|
|
]);
|
|
|
|
expect(verdicts.every((verdict) => verdict.status !== "failed")).toBe(true);
|
|
expect(verdicts.map((verdict) => verdict.status).sort()).toEqual([
|
|
"inserted",
|
|
"updated",
|
|
]);
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(["sleep:race-a", "sleep:race-b"]).toContain(rows[0]?.externalId);
|
|
expect([41, 43]).toContain(rows[0]?.value);
|
|
});
|
|
|
|
it("serializes crossing external and natural identity moves", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("identity-crossing-race");
|
|
const firstTime = new Date("2026-07-20T06:00:00.000Z");
|
|
const secondTime = new Date("2026-07-20T07:00:00.000Z");
|
|
await prisma.measurement.create({
|
|
data: desired(user.id, "sleep:race-a", { measuredAt: firstTime }),
|
|
});
|
|
|
|
const verdicts = await Promise.all([
|
|
reconcile(
|
|
desired(user.id, "sleep:race-a", {
|
|
measuredAt: secondTime,
|
|
value: 41,
|
|
}),
|
|
),
|
|
reconcile(
|
|
desired(user.id, "sleep:race-b", {
|
|
measuredAt: firstTime,
|
|
value: 43,
|
|
}),
|
|
),
|
|
]);
|
|
|
|
expect(verdicts.every((verdict) => verdict.status !== "failed")).toBe(true);
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: user.id, deletedAt: null },
|
|
orderBy: { measuredAt: "asc" },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows).toEqual([
|
|
expect.objectContaining({
|
|
externalId: "sleep:race-b",
|
|
measuredAt: firstTime,
|
|
value: 43,
|
|
}),
|
|
expect.objectContaining({
|
|
externalId: "sleep:race-a",
|
|
measuredAt: secondTime,
|
|
value: 41,
|
|
}),
|
|
]);
|
|
});
|
|
});
|