mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
21a14ea679
commit
4f3a790ce7
3 files changed
+91
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")] });
|
||||
|
||||
|
||||
Reference in new issue
Block a user