diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts index bc90748af..c1dde3885 100644 --- a/tests/integration/workout-batch-create.test.ts +++ b/tests/integration/workout-batch-create.test.ts @@ -290,9 +290,15 @@ describe("POST /api/workouts/batch (real Postgres)", () => { expect(stored).toBe(0); }); - it("dedupes a re-posted batch into duplicate status without inserting twice", async () => { + it("collapses same-instant cross-source twins via write-time dedup, then surfaces re-posts as duplicates", async () => { const { POST } = await import("@/app/api/workouts/batch/route"); + // Two entries share `sportType`, `startedAt`, and `source`. The + // v1.4.42 write-time cross-source picker (`dedupeWorkoutBatch`) + // groups by `(userId, activityType, startedAt ± 90 s)` and keeps + // ONE canonical row — the other lands as `duplicate` BEFORE + // reaching the DB. This is the Apple Watch + Withings ScanWatch + // case the picker exists to handle. const body = { workouts: [ baseWorkout("hk-uuid-dup-1"), @@ -302,8 +308,15 @@ describe("POST /api/workouts/batch (real Postgres)", () => { const first = await POST(makeRequest(body)); expect(first.status).toBe(200); - const firstJson = (await first.json()) as { data: { inserted: number } }; - expect(firstJson.data.inserted).toBe(2); + const firstJson = (await first.json()) as { + data: { + inserted: number; + duplicates: number; + entries: Array<{ status: string }>; + }; + }; + expect(firstJson.data.inserted).toBe(1); + expect(firstJson.data.duplicates).toBe(1); const second = await POST(makeRequest(body)); expect(second.status).toBe(200); @@ -314,6 +327,9 @@ describe("POST /api/workouts/batch (real Postgres)", () => { entries: Array<{ status: string }>; }; }; + // The write-time dedup still fires on the second post (one twin + // collapses), and the surviving twin now collides with the row + // persisted by the first post — so both end up as `duplicate`. expect(secondJson.data.inserted).toBe(0); expect(secondJson.data.duplicates).toBe(2); expect(secondJson.data.entries.every((e) => e.status === "duplicate")).toBe( @@ -323,7 +339,9 @@ describe("POST /api/workouts/batch (real Postgres)", () => { const stored = await getPrismaClient().workout.count({ where: { userId: TEST_USER_ID }, }); - expect(stored).toBe(2); + // Only the first-post survivor lives in the DB — write-time dedup + // dropped the other twin before any DB write. + expect(stored).toBe(1); }); it("replays a cached response when the same Idempotency-Key is reused", async () => { diff --git a/tests/integration/workout-batch-race.test.ts b/tests/integration/workout-batch-race.test.ts index 5a6cd6857..7f22f88f9 100644 --- a/tests/integration/workout-batch-race.test.ts +++ b/tests/integration/workout-batch-race.test.ts @@ -177,13 +177,17 @@ describe("POST /api/workouts/batch — concurrent-write race", () => { it("two batches with one overlapping externalId resolve to a single DB row each", async () => { const { POST } = await import("@/app/api/workouts/batch/route"); - // Batch A — entries 0..3 unique-to-A plus one shared key. + // Batch A — two entries one minute apart plus one shared key. The + // v1.4.42 `dedupeWorkoutBatch` picker collapses the minute-1 + + // minute-2 pair (same activity, within the 90 s window) to one + // surviving row, leaving 2 effective rows per batch. const batchA: WorkoutFixture[] = [ workout("hk-uuid-only-a-0", 1), workout("hk-uuid-only-a-1", 2), workout("hk-uuid-shared", 10), ]; - // Batch B — entries 0..3 unique-to-B plus the SAME shared key. + // Batch B — same shape, different externalIds for the unique pair + // but the SAME shared key. const batchB: WorkoutFixture[] = [ workout("hk-uuid-only-b-0", 21), workout("hk-uuid-only-b-1", 22), @@ -202,16 +206,17 @@ describe("POST /api/workouts/batch — concurrent-write race", () => { }; const bJson = (await resB.json()) as typeof aJson; - // Five unique tuples total across both batches; one shared. The - // composite unique index means the shared key lands exactly once, - // either as A's "inserted" or B's "inserted" — never both. The - // sum of the two batches' inserted counts is exactly 5. - expect(aJson.data.inserted + bJson.data.inserted).toBe(5); + // Three unique tuples land in the DB after write-time dedup + the + // composite unique index: one survivor from A's minute-1/2 pair, + // one survivor from B's minute-21/22 pair, and one shared-key row + // (either as A's "inserted" or B's "inserted" — never both). The + // sum of the two batches' inserted counts equals the row count. + expect(aJson.data.inserted + bJson.data.inserted).toBe(3); const stored = await getPrismaClient().workout.findMany({ where: { userId: TEST_USER_ID }, }); - expect(stored).toHaveLength(5); + expect(stored).toHaveLength(3); const sharedRows = stored.filter( (r) => r.externalId === "hk-uuid-shared", );