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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 09:53:09 +02:00
1 parent 21a14ea679
commit 4f3a790ce7
3 files changed
+91 -6

No files matched your search

+11
View File
@@ -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
+34 -6
View File
@@ -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(),
@@ -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")] });