diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 9b127601b..d1b5a758a 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -477,16 +477,21 @@ async function postBatch(request: NextRequest): Promise { workoutId: string; samples: NonNullable; }> = []; + /** Workouts already claimed by an earlier entry of THIS batch. */ + const enrichClaimed = new Set(); for (const p of survivors) { const keyTuple = p.dedupKey ? `${p.dedupKey.source}::${p.dedupKey.externalId}` : null; const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined; if (known) { - if (p.samples && !known.hasSeries) { + if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) { + enrichClaimed.add(known.id); // Enrichment. The status is assigned after the write, because // a concurrent writer may get the series in first — in which - // case this entry is the plain duplicate it always was. + // case this entry is the plain duplicate it always was. A + // second entry of this batch naming the same workout takes the + // duplicate arm above: one workout, one series, one `enriched`. toEnrich.push({ index: p.index, workoutId: known.id, diff --git a/tests/integration/workout-batch-enrich.test.ts b/tests/integration/workout-batch-enrich.test.ts index ff9d98125..b63bb4b0e 100644 --- a/tests/integration/workout-batch-enrich.test.ts +++ b/tests/integration/workout-batch-enrich.test.ts @@ -213,6 +213,33 @@ describe("POST /api/workouts/batch — HR series enrichment (real Postgres)", () ).toBe(1); }); + it("reports one enrichment when two entries in one batch aim at the same workout", async () => { + await post({ workouts: [baseWorkout("hk-uuid-enrich-007")] }); + + // Same external id twice, far enough apart in time that the + // write-time twin dedup keeps both entries. Only one series can land. + const res = await post({ + workouts: [ + baseWorkout("hk-uuid-enrich-007", { samples: SERIES }), + baseWorkout("hk-uuid-enrich-007", { + startedAt: "2026-05-14T09:00:00.000Z", + endedAt: "2026-05-14T09:45:00.000Z", + samples: [{ t: "2026-05-14T09:00:00.000Z", hr: 155 }], + }), + ], + }); + + expect(res.status).toBe(200); + expect( + res.data.entries.filter((e) => e.status === "enriched"), + ).toHaveLength(1); + expect( + await getPrismaClient().workoutSamples.count({ + where: { workout: { userId: TEST_USER_ID } }, + }), + ).toBe(1); + }); + it("leaves every workout column byte-identical across an enrichment", async () => { await post({ workouts: [