diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index a0c2a7af1..16aea90fe 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -41,6 +41,13 @@ import { } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; +import { + MEASURED_AT_TOLERANCE_MS, + isMergeableSource, + isSameReadingAcrossSource, + oppositeMergeSource, + type MergeCandidate, +} from "@/lib/measurements/cross-source-merge"; import { validateMeasurementRange } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -287,6 +294,11 @@ async function postBatch(request: NextRequest): Promise { let insertedCount = 0; let updatedCount = 0; let duplicateCount = 0; + // v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the + // MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain + // composite-key duplicate). Surfaced as a dedicated wide-event count so + // an operator can see how often the standalone-pair mirror collapses. + let crossSourceMergedCount = 0; if (prepared.length > 0) { // Pre-flight duplicate detection so we can return per-entry status @@ -320,6 +332,78 @@ async function postBatch(request: NextRequest): Promise { existing.map((row) => `${row.type}::${row.source}::${row.externalId}`), ); + // v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH + // mirror duplicate. When the iOS app logs a manual reading offline it + // mirrors that reading into HealthKit; on pairing, adopt-on-pair + // uploads it as MANUAL and HealthKit background sync independently + // re-ingests the mirrored sample as APPLE_HEALTH. The two carry + // different externalIds + sources, so the composite unique index lets + // both land — duplicating the whole hand-logged history on first pair. + // We collapse them at ingest: an incoming MANUAL / APPLE_HEALTH row is + // dropped as a `duplicate` when a same-reading row of the OPPOSITE + // source already exists (same type + value + measuredAt within a tight + // ±2 s window). `stats:*` cumulative rows are excluded — those are + // per-day aggregates the observer overwrites in place, never + // hand-entered point readings. See `cross-source-merge.ts` for the + // full rule + why first-physical-reading-wins (the value is identical + // between the two rows, so only the source label differs). + // + // Pull the opposite-source candidate rows in one widened query: for + // every mergeable, non-`stats:*` incoming row, look for an opposite- + // source row of the same type whose measuredAt falls inside the + // tolerance window. Compared in JS because the value match needs a + // float epsilon the SQL `IN` can't express. + const mergeProbes = prepared.filter( + (p) => + isMergeableSource(p.row.source as string) && + !isStatsExternalId(p.row.externalId as string), + ); + const crossSourceCandidates: MergeCandidate[] = []; + if (mergeProbes.length > 0) { + const candidateRows = await prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + OR: mergeProbes.map((p) => { + const measuredAt = p.row.measuredAt as Date; + return { + type: p.row.type as MeasurementType, + source: oppositeMergeSource( + p.row.source as "MANUAL" | "APPLE_HEALTH", + ), + measuredAt: { + gte: new Date(measuredAt.getTime() - MEASURED_AT_TOLERANCE_MS), + lte: new Date(measuredAt.getTime() + MEASURED_AT_TOLERANCE_MS), + }, + }; + }), + }, + select: { type: true, source: true, value: true, measuredAt: true }, + }); + crossSourceCandidates.push(...candidateRows); + } + + // Rows already chosen for insert earlier in THIS batch — so a MANUAL + // and an APPLE_HEALTH same-reading arriving in the SAME batch also + // collapse (the DB probe above only sees rows from prior batches). + const inBatchCandidates: MergeCandidate[] = []; + function hasSameReadingSibling(p: Prepared): boolean { + const incoming = { + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }; + if (!isMergeableSource(incoming.source)) return false; + for (const candidate of crossSourceCandidates) { + if (isSameReadingAcrossSource(incoming, candidate)) return true; + } + for (const candidate of inBatchCandidates) { + if (isSameReadingAcrossSource(incoming, candidate)) return true; + } + return false; + } + const toInsert: Prisma.MeasurementCreateManyInput[] = []; const toOverwrite: Prepared[] = []; for (const p of prepared) { @@ -336,9 +420,35 @@ async function postBatch(request: NextRequest): Promise { results[p.index] = { index: p.index, status: "duplicate" }; duplicateCount += 1; } + } else if ( + !isStatsExternalId(p.row.externalId as string) && + hasSameReadingSibling(p) + ) { + // v1.11.4 (iOS #2) — cross-source same-reading collapse. The + // physical reading already exists under the opposite client + // source; drop this mirror copy. Status stays `duplicate` so the + // iOS sync cursor checkpoints past it exactly as it does for a + // composite-key duplicate; the `reason` distinguishes it for ops. + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "cross_source_merge", + }; + duplicateCount += 1; + crossSourceMergedCount += 1; } else { results[p.index] = { index: p.index, status: "inserted" }; toInsert.push(p.row); + // Track this row so a same-reading sibling later in the SAME + // batch collapses against it. + if (isMergeableSource(p.row.source as string)) { + inBatchCandidates.push({ + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }); + } } } @@ -463,6 +573,22 @@ async function postBatch(request: NextRequest): Promise { }); } + // v1.11.4 (iOS #2) — dedicated annotation for the MANUAL↔APPLE_HEALTH + // same-reading collapse so an operator can grep + // `measurement.batch.cross-source-merge` to confirm the standalone-pair + // mirror duplicate is being absorbed (a healthy first-pair flow). Only + // fires when at least one row was merged so the baseline ingest trace + // is unchanged for the common case. + if (crossSourceMergedCount > 0) { + annotate({ + action: { name: "measurement.batch.cross-source-merge" }, + meta: { + merged: crossSourceMergedCount, + processed: entries.length, + }, + }); + } + // v1.4.25 W16c — kick off PR detection for this user. We always // enqueue when at least one row was written (or the batch had any // measurements to consider) so a single off-day reading still gets diff --git a/src/lib/measurements/__tests__/cross-source-merge.test.ts b/src/lib/measurements/__tests__/cross-source-merge.test.ts new file mode 100644 index 000000000..1ba162671 --- /dev/null +++ b/src/lib/measurements/__tests__/cross-source-merge.test.ts @@ -0,0 +1,185 @@ +/** + * v1.11.4 (iOS #2) — unit coverage for the MANUAL↔APPLE_HEALTH + * same-reading merge predicate. The route integration test exercises the + * end-to-end ingest path; this suite pins the pure matching rule: + * - cross-source same reading → match + * - same-source / server-source pair → no match (no over-merge) + * - different value or out-of-tolerance timestamp → no match + */ +import { describe, expect, it } from "vitest"; + +import { + MEASURED_AT_TOLERANCE_MS, + isMergeableSource, + isSameReadingAcrossSource, + measuredAtMatch, + oppositeMergeSource, + valuesMatch, + type MergeCandidate, +} from "@/lib/measurements/cross-source-merge"; +import type { MeasurementType } from "@/generated/prisma/client"; + +const T0 = new Date("2026-06-04T07:30:00.000Z"); + +function candidate(over: Partial = {}): MergeCandidate { + return { + type: "WEIGHT" as MeasurementType, + source: "MANUAL", + value: 81.4, + measuredAt: T0, + ...over, + }; +} + +function incoming( + over: Partial<{ + type: MeasurementType; + source: string; + value: number; + measuredAt: Date; + }> = {}, +) { + return { + type: "WEIGHT" as MeasurementType, + source: "APPLE_HEALTH", + value: 81.4, + measuredAt: T0, + ...over, + }; +} + +describe("isMergeableSource", () => { + it("accepts only the two client-facing mirror sources", () => { + expect(isMergeableSource("MANUAL")).toBe(true); + expect(isMergeableSource("APPLE_HEALTH")).toBe(true); + expect(isMergeableSource("WITHINGS")).toBe(false); + expect(isMergeableSource("IMPORT")).toBe(false); + expect(isMergeableSource("COMPUTED")).toBe(false); + expect(isMergeableSource("WHOOP")).toBe(false); + }); +}); + +describe("oppositeMergeSource", () => { + it("flips MANUAL ↔ APPLE_HEALTH", () => { + expect(oppositeMergeSource("MANUAL")).toBe("APPLE_HEALTH"); + expect(oppositeMergeSource("APPLE_HEALTH")).toBe("MANUAL"); + }); +}); + +describe("valuesMatch", () => { + it("matches identical and sub-epsilon values", () => { + expect(valuesMatch(81.4, 81.4)).toBe(true); + expect(valuesMatch(81.4, 81.4 + 1e-12)).toBe(true); + expect(valuesMatch(0.97, 0.97)).toBe(true); + }); + + it("rejects genuinely different values", () => { + expect(valuesMatch(81.4, 81.5)).toBe(false); + expect(valuesMatch(120, 121)).toBe(false); + }); +}); + +describe("measuredAtMatch", () => { + it("matches inside the ±tolerance window", () => { + expect(measuredAtMatch(T0, T0)).toBe(true); + expect( + measuredAtMatch(T0, new Date(T0.getTime() + MEASURED_AT_TOLERANCE_MS)), + ).toBe(true); + expect( + measuredAtMatch(T0, new Date(T0.getTime() - MEASURED_AT_TOLERANCE_MS)), + ).toBe(true); + }); + + it("rejects timestamps beyond the tolerance window", () => { + expect( + measuredAtMatch( + T0, + new Date(T0.getTime() + MEASURED_AT_TOLERANCE_MS + 1), + ), + ).toBe(false); + // A whole minute apart — two distinct readings, never the mirror pair. + expect(measuredAtMatch(T0, new Date(T0.getTime() + 60_000))).toBe(false); + }); +}); + +describe("isSameReadingAcrossSource", () => { + it("matches a MANUAL candidate against an incoming APPLE_HEALTH mirror", () => { + expect(isSameReadingAcrossSource(incoming(), candidate())).toBe(true); + }); + + it("matches an APPLE_HEALTH candidate against an incoming MANUAL mirror", () => { + expect( + isSameReadingAcrossSource( + incoming({ source: "MANUAL" }), + candidate({ source: "APPLE_HEALTH" }), + ), + ).toBe(true); + }); + + it("matches within the timestamp tolerance", () => { + expect( + isSameReadingAcrossSource( + incoming({ measuredAt: new Date(T0.getTime() + 1_500) }), + candidate(), + ), + ).toBe(true); + }); + + it("does NOT match a same-source pair (no over-merge)", () => { + // Two APPLE_HEALTH readings — keep the existing per-source contract. + expect( + isSameReadingAcrossSource( + incoming({ source: "APPLE_HEALTH" }), + candidate({ source: "APPLE_HEALTH" }), + ), + ).toBe(false); + // Two MANUAL readings. + expect( + isSameReadingAcrossSource( + incoming({ source: "MANUAL" }), + candidate({ source: "MANUAL" }), + ), + ).toBe(false); + }); + + it("does NOT match when a server-owned source is involved", () => { + expect( + isSameReadingAcrossSource( + incoming({ source: "APPLE_HEALTH" }), + candidate({ source: "WITHINGS" }), + ), + ).toBe(false); + // Incoming WITHINGS could never reach this route, but the predicate + // must still refuse it. + expect( + isSameReadingAcrossSource( + incoming({ source: "WITHINGS" }), + candidate({ source: "APPLE_HEALTH" }), + ), + ).toBe(false); + }); + + it("does NOT match a different measurement type", () => { + expect( + isSameReadingAcrossSource( + incoming({ type: "PULSE" as MeasurementType }), + candidate({ type: "WEIGHT" as MeasurementType }), + ), + ).toBe(false); + }); + + it("does NOT match a different value (two real readings, same minute)", () => { + expect( + isSameReadingAcrossSource(incoming({ value: 81.4 }), candidate({ value: 82.0 })), + ).toBe(false); + }); + + it("does NOT match a timestamp beyond the tolerance window", () => { + expect( + isSameReadingAcrossSource( + incoming({ measuredAt: new Date(T0.getTime() + 60_000) }), + candidate(), + ), + ).toBe(false); + }); +}); diff --git a/src/lib/measurements/cross-source-merge.ts b/src/lib/measurements/cross-source-merge.ts new file mode 100644 index 000000000..00dbe612d --- /dev/null +++ b/src/lib/measurements/cross-source-merge.ts @@ -0,0 +1,151 @@ +/** + * v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH + * mirror duplicate. + * + * ## The problem + * When the iOS app runs standalone/offline and the user logs a MANUAL + * measurement, iOS mirrors that reading into HealthKit too. On pairing a + * server, two independent upload paths then reach + * `POST /api/measurements/batch` for the SAME physical reading: + * 1. adopt-on-pair backfill uploads it as `source = MANUAL` + * (externalId = the app's local id), and + * 2. HealthKit background sync re-ingests the mirrored sample and + * uploads it as `source = APPLE_HEALTH` (externalId = HKSample.uuid). + * + * The two carry different `externalId`s and different `source`s, so the + * `(userId, type, source, externalId)` unique index treats them as two + * distinct rows — duplicating the whole manually-logged history on first + * pair. + * + * ## The rule + * Treat a MANUAL row and an APPLE_HEALTH row as the SAME physical + * reading when they share: + * - the same `userId` (multi-tenant scoping — always narrowed), AND + * - the same `type`, AND + * - the same `value` (within a tiny float epsilon — both rows are the + * same hand-entered number run through the identical + * `mapAppleHealthEntry` conversion, so equality is exact in practice; + * the epsilon only guards against sub-ulp drift), AND + * - a `measuredAt` within `MEASURED_AT_TOLERANCE_MS` of each other. + * + * The merge is deliberately ONE-SIDED in scope: it only fires for the + * MANUAL ↔ APPLE_HEALTH cross-source pair. MANUAL ↔ MANUAL, + * APPLE_HEALTH ↔ APPLE_HEALTH, and any pair involving a server-owned + * source (WITHINGS / IMPORT / COMPUTED / WHOOP) keep the existing + * first-write-wins / per-source contracts untouched. Two genuinely + * distinct readings at the same minute from two devices therefore never + * collapse — they are same-source or involve a server source, both of + * which fall outside this rule. + * + * ## Ingest-time, first-physical-reading-wins + * The collapse runs at INGEST time, not read time: read-time dedup would + * have to live in the rollup tier and every analytics path forever. + * Ingest-time keeps the storage clean and the rule contained. + * + * Because the two uploads can arrive in either order and minutes apart + * in SEPARATE batches, the collapse compares each incoming row against + * the rows already PERSISTED for the user (cross-batch) as well as the + * rows already chosen for insert earlier in the SAME batch. + * + * The winner is whichever physical reading landed first. The `value` is + * identical between the two rows by construction, so the only difference + * the surviving row carries is its `source` label — the numeric series + * the user sees is the same either way. A source-priority-aware REPLACE + * (overwrite the loser's source/row with the higher-ranked source) was + * considered and rejected for a patch: it would require mutating or + * re-sourcing an existing row (rollup re-source + sync-version churn) for + * zero change to the displayed value. First-physical-reading-wins is the + * natural extension of the endpoint's existing first-write-wins + * philosophy across the MANUAL↔APPLE_HEALTH boundary. + * + * `stats:*` cumulative externalIds are explicitly OUT of scope — those + * are per-day aggregate rows the iOS observer overwrites in place + * (issue #213), never hand-entered point readings, so the cross-source + * mirror duplicate cannot arise for them. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * Timestamp tolerance for the same-reading match. A reading the user + * hand-enters and the app mirrors to HealthKit shares the same instant; + * the HealthKit re-ingest may round to the second or carry a sub-second + * drift, so a tight ±2 s window absorbs that without any chance of + * catching a separate genuine reading — a user does not hand-enter two + * distinct readings of the same type within two seconds. + */ +export const MEASURED_AT_TOLERANCE_MS = 2_000; + +/** + * Relative epsilon for the value match. Both rows run the same + * `convertToDbUnit` over the same source number, so the stored floats + * are bit-identical in practice; the epsilon is a defensive guard + * against any future conversion that introduces sub-ulp rounding. Scaled + * to the magnitude of the value so it stays meaningful for both a + * 0.97-fraction SpO2 and a 12 000-step count. + */ +const VALUE_RELATIVE_EPSILON = 1e-9; + +/** The two client-facing sources that can mirror the same reading. */ +type MergeableSource = "MANUAL" | "APPLE_HEALTH"; + +/** The opposite member of the MANUAL ↔ APPLE_HEALTH pair. */ +export function oppositeMergeSource(source: MergeableSource): MergeableSource { + return source === "MANUAL" ? "APPLE_HEALTH" : "MANUAL"; +} + +/** + * True when `source` participates in the cross-source merge. Only the + * two client-facing mirror sources qualify; server-owned sources are + * untouched. + */ +export function isMergeableSource(source: string): source is MergeableSource { + return source === "MANUAL" || source === "APPLE_HEALTH"; +} + +/** Are two values the same reading within the relative epsilon? */ +export function valuesMatch(a: number, b: number): boolean { + if (a === b) return true; + const scale = Math.max(Math.abs(a), Math.abs(b), 1); + return Math.abs(a - b) <= VALUE_RELATIVE_EPSILON * scale; +} + +/** Are two instants within the same-reading tolerance window? */ +export function measuredAtMatch(a: Date, b: Date): boolean { + return Math.abs(a.getTime() - b.getTime()) <= MEASURED_AT_TOLERANCE_MS; +} + +/** + * A candidate same-reading row — either already persisted for the user + * or already chosen for insert earlier in the same batch. Carries only + * the fields the match needs. + */ +export interface MergeCandidate { + type: MeasurementType; + source: string; + value: number; + measuredAt: Date; +} + +/** + * Does `candidate` represent the same physical reading as an incoming + * row of `(type, incomingSource, value, measuredAt)`, under the + * cross-source MANUAL↔APPLE_HEALTH rule? Requires: + * - the incoming row's source is mergeable, + * - the candidate's source is the OPPOSITE mergeable source, + * - same type, value (±epsilon), measuredAt (±tolerance). + */ +export function isSameReadingAcrossSource( + incoming: { + type: MeasurementType; + source: string; + value: number; + measuredAt: Date; + }, + candidate: MergeCandidate, +): boolean { + if (!isMergeableSource(incoming.source)) return false; + if (candidate.source !== oppositeMergeSource(incoming.source)) return false; + if (candidate.type !== incoming.type) return false; + if (!valuesMatch(candidate.value, incoming.value)) return false; + return measuredAtMatch(candidate.measuredAt, incoming.measuredAt); +} diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index 81fabf5a8..eb498ad16 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -764,6 +764,11 @@ describe("POST /api/measurements/batch (real Postgres)", () => { it("treats the same externalId under different sources as two distinct rows", async () => { const { POST } = await import("@/app/api/measurements/batch/route"); + // The two rows share an externalId but carry DIFFERENT values + + // timestamps so they are genuinely distinct readings — this asserts + // that `source` is part of the composite dedup key, without tripping + // the v1.11.4 cross-source same-reading merge (which only collapses a + // MANUAL + APPLE_HEALTH pair that share value + measuredAt). const sharedExternalId = "uuid-source-collision-001"; const response = await POST( makeRequest({ @@ -779,10 +784,10 @@ describe("POST /api/measurements/batch (real Postgres)", () => { }, { hkIdentifier: "HKQuantityTypeIdentifierBodyMass", - value: 81.0, + value: 79.5, unit: "kg", - startDate: "2026-05-09T07:30:00.000Z", - endDate: "2026-05-09T07:30:00.000Z", + startDate: "2026-05-09T19:30:00.000Z", + endDate: "2026-05-09T19:30:00.000Z", externalId: sharedExternalId, source: "MANUAL", }, @@ -806,6 +811,301 @@ describe("POST /api/measurements/batch (real Postgres)", () => { ); }); + // v1.11.4 (iOS #2) — same-reading merge for the MANUAL↔APPLE_HEALTH + // mirror duplicate. A standalone manual reading mirrors into HealthKit; + // on pair, adopt-on-pair uploads it as MANUAL and HealthKit background + // sync re-ingests the mirror as APPLE_HEALTH with a different + // externalId. The composite unique index would let both land; the + // cross-source collapse drops the second copy as a `duplicate`. + describe("cross-source same-reading merge (iOS #2)", () => { + const weightEntry = (over: Partial): BatchEntryFixture => ({ + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.4, + unit: "kg", + startDate: "2026-06-04T07:30:00.000Z", + endDate: "2026-06-04T07:30:00.000Z", + externalId: "uuid-merge-default", + ...over, + }); + + it("collapses MANUAL then APPLE_HEALTH same reading into one row (separate batches)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + // Adopt-on-pair MANUAL upload first. + const first = await POST( + makeRequest({ + entries: [ + weightEntry({ externalId: "local-manual-001", source: "MANUAL" }), + ], + }), + ); + expect(first.status).toBe(200); + expect(((await first.json()) as { data: { inserted: number } }).data.inserted).toBe(1); + + // HealthKit background sync re-ingests the same physical reading + // as APPLE_HEALTH with a different (HKSample.uuid) externalId. + const second = await POST( + makeRequest({ + entries: [ + weightEntry({ externalId: "hk-uuid-001", source: "APPLE_HEALTH" }), + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { + inserted: number; + duplicates: number; + entries: Array<{ status: string; reason?: string }>; + }; + }; + expect(secondJson.data.inserted).toBe(0); + expect(secondJson.data.duplicates).toBe(1); + expect(secondJson.data.entries[0]?.status).toBe("duplicate"); + expect(secondJson.data.entries[0]?.reason).toBe("cross_source_merge"); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0]!.source).toBe("MANUAL"); + }); + + it("collapses regardless of arrival order (APPLE_HEALTH then MANUAL)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const first = await POST( + makeRequest({ + entries: [ + weightEntry({ externalId: "hk-uuid-002", source: "APPLE_HEALTH" }), + ], + }), + ); + expect(first.status).toBe(200); + + const second = await POST( + makeRequest({ + entries: [ + weightEntry({ externalId: "local-manual-002", source: "MANUAL" }), + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(secondJson.data.inserted).toBe(0); + expect(secondJson.data.duplicates).toBe(1); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0]!.source).toBe("APPLE_HEALTH"); + }); + + it("collapses a MANUAL + APPLE_HEALTH mirror arriving in the SAME batch", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + weightEntry({ externalId: "local-manual-003", source: "MANUAL" }), + weightEntry({ externalId: "hk-uuid-003", source: "APPLE_HEALTH" }), + ], + }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(json.data.inserted).toBe(1); + expect(json.data.duplicates).toBe(1); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(stored).toHaveLength(1); + }); + + it("matches within the ±2s tolerance window", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + await POST( + makeRequest({ + entries: [ + weightEntry({ + externalId: "local-manual-tol", + source: "MANUAL", + startDate: "2026-06-04T07:30:00.000Z", + endDate: "2026-06-04T07:30:00.000Z", + }), + ], + }), + ); + + // HK mirror ingested with a 1s sub-second drift on the same reading. + const second = await POST( + makeRequest({ + entries: [ + weightEntry({ + externalId: "hk-uuid-tol", + source: "APPLE_HEALTH", + startDate: "2026-06-04T07:30:01.000Z", + endDate: "2026-06-04T07:30:01.000Z", + }), + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(secondJson.data.inserted).toBe(0); + expect(secondJson.data.duplicates).toBe(1); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(stored).toHaveLength(1); + }); + + it("does NOT merge different values (two real readings same minute)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + await POST( + makeRequest({ + entries: [ + weightEntry({ + externalId: "local-manual-distinct", + source: "MANUAL", + value: 81.4, + }), + ], + }), + ); + + const second = await POST( + makeRequest({ + entries: [ + weightEntry({ + externalId: "hk-uuid-distinct", + source: "APPLE_HEALTH", + value: 82.0, + }), + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + // Different value → genuinely distinct reading, both rows survive. + expect(secondJson.data.inserted).toBe(1); + expect(secondJson.data.duplicates).toBe(0); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(stored).toHaveLength(2); + }); + + it("does NOT merge timestamps beyond the tolerance window", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + await POST( + makeRequest({ + entries: [ + weightEntry({ + externalId: "local-manual-far", + source: "MANUAL", + startDate: "2026-06-04T07:30:00.000Z", + endDate: "2026-06-04T07:30:00.000Z", + }), + ], + }), + ); + + // A full minute later — distinct reading, not the mirror pair. + const second = await POST( + makeRequest({ + entries: [ + weightEntry({ + externalId: "hk-uuid-far", + source: "APPLE_HEALTH", + startDate: "2026-06-04T07:31:00.000Z", + endDate: "2026-06-04T07:31:00.000Z", + }), + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(secondJson.data.inserted).toBe(1); + expect(secondJson.data.duplicates).toBe(0); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(stored).toHaveLength(2); + }); + + it("leaves the stats:* overwrite contract untouched (no cross-source collapse)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const externalId = "stats:HKQuantityTypeIdentifierStepCount:2026-06-04"; + // A MANUAL same-day-total then an APPLE_HEALTH re-post must NOT + // collapse — stats:* rows are per-day aggregates, excluded from the + // merge. They are distinct (type, source) rows and each keeps its + // own overwrite lane. + await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value: 4000, + unit: "count", + startDate: "2026-06-04T00:00:00.000Z", + endDate: "2026-06-04T12:00:00.000Z", + externalId, + source: "MANUAL", + }, + ], + }), + ); + + const second = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value: 4000, + unit: "count", + startDate: "2026-06-04T00:00:00.000Z", + endDate: "2026-06-04T12:00:00.000Z", + externalId, + source: "APPLE_HEALTH", + }, + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + // Different source on a stats:* externalId → distinct row, inserted. + expect(secondJson.data.inserted).toBe(1); + expect(secondJson.data.duplicates).toBe(0); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId }, + }); + expect(stored).toHaveLength(2); + }); + }); + it("rejects an out-of-range source value with 422", async () => { const { POST } = await import("@/app/api/measurements/batch/route");