From 4f3a790ce73c334aad14408aa9ab6b6efdcd873b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 09:53:09 +0200 Subject: [PATCH] test(workouts): pin the losing side of a concurrent enrichment Two requests can probe the same workout, both find no heart-rate series, and both mean to attach one. The unique index on `workout_id` picks a winner; the loser has to report the duplicate it turned out to be rather than claim an attach the database never made. Nothing could catch that arm going wrong: the unit fake echoes every row back as landed, and the race suite carries no series at all, so replacing the status with a constant `enriched` left the whole suite green. Two concurrent posts of the same enrichment now assert one `enriched` against one `duplicate` and a single stored series. The published contract also says now what the counters mean, instead of leaving it in a comment: an entry reported as `enriched` counts towards `duplicates`, because the top-level counters count workout rows and an enrichment writes none. Field descriptions on the response and on the per-entry status and reason, OpenAPI regenerated. --- docs/api/openapi.yaml | 11 +++++ src/lib/openapi/routes/workouts.ts | 40 +++++++++++++--- .../integration/workout-batch-enrich.test.ts | 46 +++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a4f47bd59..e56ba78b6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21679,14 +21679,19 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + description: Entries in the request. Equals `inserted + duplicates + skipped.length`. inserted: type: integer minimum: 0 maximum: 9007199254740991 + description: Workout rows this request wrote. duplicates: type: integer minimum: 0 maximum: 9007199254740991 + description: Entries that wrote no workout row because the workout was already stored. The top-level counters count + workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was + attached. skipped: type: array items: @@ -21727,7 +21732,13 @@ components: - duplicate - enriched - skipped + description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. + `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; + the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. + `skipped`: the server refused the entry, see `reason`." reason: + description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client + restart, `invalid_samples` for a heart-rate series that failed validation." type: string required: - index diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index cf3861371..42b312b27 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -27,20 +27,48 @@ import { // `enriched` says the entry matched a stored workout that had no // heart-rate series and the series it carried was attached. The // workout row itself was not written, so such an entry also counts -// towards `duplicates`; the top-level counters count rows. +// towards `duplicates`. That rule is part of the published contract, +// not just a note here: it rides the field descriptions below, because +// a client summing the counters has to know which bucket an +// enrichment lands in. const workoutBatchEntryResult = z .object({ index: z.number().int().nonnegative(), - status: z.enum(["inserted", "duplicate", "enriched", "skipped"]), - reason: z.string().optional(), + status: z + .enum(["inserted", "duplicate", "enriched", "skipped"]) + .describe( + "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `skipped`: the server refused the entry, see `reason`.", + ), + reason: z + .string() + .optional() + .describe( + "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client restart, `invalid_samples` for a heart-rate series that failed validation.", + ), }) .meta({ id: "WorkoutBatchEntryResult" }); const workoutBatchResponse = z .object({ - processed: z.number().int().nonnegative(), - inserted: z.number().int().nonnegative(), - duplicates: z.number().int().nonnegative(), + processed: z + .number() + .int() + .nonnegative() + .describe( + "Entries in the request. Equals `inserted + duplicates + skipped.length`.", + ), + inserted: z + .number() + .int() + .nonnegative() + .describe("Workout rows this request wrote."), + duplicates: z + .number() + .int() + .nonnegative() + .describe( + "Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was attached.", + ), skipped: z.array( z.object({ index: z.number().int().nonnegative(), diff --git a/tests/integration/workout-batch-enrich.test.ts b/tests/integration/workout-batch-enrich.test.ts index b63bb4b0e..3b80f526e 100644 --- a/tests/integration/workout-batch-enrich.test.ts +++ b/tests/integration/workout-batch-enrich.test.ts @@ -213,6 +213,52 @@ describe("POST /api/workouts/batch — HR series enrichment (real Postgres)", () ).toBe(1); }); + it("hands the attach to exactly one of two concurrent enrichments", async () => { + await post({ workouts: [baseWorkout("hk-uuid-enrich-008")] }); + + // Both requests probe a workout with no series, so both mean to + // attach one. The unique index on `workout_id` decides: one write + // lands, the other conflicts and is skipped. The loser must report + // the duplicate it turned out to be rather than claim an attach the + // database never made — that is the arm this case exists for, and + // the outcome is deterministic even though the winner is not. + const { POST } = await import("@/app/api/workouts/batch/route"); + const body = { + workouts: [baseWorkout("hk-uuid-enrich-008", { samples: SERIES })], + }; + const [resA, resB] = await Promise.all([ + POST(makeRequest(body)), + POST(makeRequest(body)), + ]); + expect(resA.status).toBe(200); + expect(resB.status).toBe(200); + + const [jsonA, jsonB] = (await Promise.all([resA.json(), resB.json()])) as [ + { data: BatchResponse }, + { data: BatchResponse }, + ]; + const statuses = [ + jsonA.data.entries[0]?.status, + jsonB.data.entries[0]?.status, + ].sort(); + expect(statuses).toEqual(["duplicate", "enriched"]); + // Both responses still count the entry as a duplicate row. + expect(jsonA.data.duplicates).toBe(1); + expect(jsonB.data.duplicates).toBe(1); + expect(jsonA.data.inserted + jsonB.data.inserted).toBe(0); + + const series = await getPrismaClient().workoutSamples.findMany({ + where: { workout: { userId: TEST_USER_ID } }, + }); + expect(series).toHaveLength(1); + expect(series[0]?.sampleCount).toBe(3); + expect( + await getPrismaClient().workout.count({ + where: { userId: TEST_USER_ID }, + }), + ).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")] });