diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 72915fbc3..4da4df1da 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4834,6 +4834,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -4855,6 +4856,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": description: More than 30 side-effect writes in the trailing minute for this ACTOR. @@ -5213,6 +5215,8 @@ paths: medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated. + parameters: + - *a6 requestBody: required: true content: @@ -5228,6 +5232,7 @@ paths: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 "403": *a8 + "409": *a7 "422": *a4 "429": *a5 /api/medications/layout: @@ -5563,6 +5568,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -5584,6 +5590,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/inventory/{itemId}: diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index e15529cb7..16b880d7b 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -37,6 +37,7 @@ import { import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; +import { withIdempotency } from "@/lib/idempotency"; type RouteParams = { params: Promise<{ id: string }> }; @@ -90,102 +91,113 @@ export const GET = apiHandler( }, ); +/** + * Wrapped in `withIdempotency`: registering a container is replayed out of the + * client's offline outbox under the same `Idempotency-Key`, and a replay after + * a lost success response would otherwise register the same pen twice and + * double the stock the low-stock notification reads. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.37.0 — MANAGE. Recording the stock the low-stock notification reads; - // that notification rings the record's own phone. - const { user, actor } = await requireRecordAuth("manage", "medications"); - const { id } = await params; - - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - // Per-caller POST rate-limit — 30/min is generous for normal pen - // registrations but cuts off the spam case. - // - // v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their - // own allowance rather than the owner's and cannot collect a fresh one by - // switching records. - const rl = await checkRateLimit( - `medication-inventory:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createInventoryItemSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const { - unitsTotal, - containerType, - printedExpiry, - purchasedAt, - manufacturer, - doseStrength, - notes, - } = parsed.data; - - const created = await prisma.medicationInventoryItem.create({ - // The wire field `unitsTotal` counts UNITS (tablets / ampoules / - // puffs) — v1.16.10 renamed the request field to match the - // response side. - data: buildCreateInventoryInput({ - userId: user.id, - medicationId: id, - unitsTotal, - containerType: containerType ?? "OTHER", - printedExpiry: printedExpiry ?? null, - purchasedAt: purchasedAt ?? null, - manufacturer: manufacturer ?? null, - doseStrength: doseStrength ?? null, - notes: notes ?? null, - }), - }); - - await auditLog("medication.inventory.create", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - itemId: created.id, - unitsTotal, - containerType: created.containerType, - }, - }); - - annotate({ - action: { - name: "medication.inventory.create", - entity_type: "inventory_item", - entity_id: created.id, - }, - meta: { medication_id: id }, - }); - - // A registered container changes the dose-derived stock the - // medications-list payload carries (`stockUnitsRemaining` / - // `stockDosesRemaining`), which the card and table render. Hard-evict - // the per-user medications + compliance buckets so the supply shows on - // the very next read — a mark-stale would let the `cachedSwr` list - // serve the pre-write stock for the rest of the stale window. - invalidateUserMedications(user.id, { evict: true }); - - return apiSuccess( - serializeInventoryItem(shapeInventoryItemNotes(created)), - 201, - ); - }, + withIdempotency<[NextRequest, RouteParams]>(postInventoryItem), ); + +async function postInventoryItem( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.37.0 — MANAGE. Recording the stock the low-stock notification reads; + // that notification rings the record's own phone. + const { user, actor } = await requireRecordAuth("manage", "medications"); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + // Per-caller POST rate-limit — 30/min is generous for normal pen + // registrations but cuts off the spam case. + // + // v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their + // own allowance rather than the owner's and cannot collect a fresh one by + // switching records. + const rl = await checkRateLimit( + `medication-inventory:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createInventoryItemSchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } + + const { + unitsTotal, + containerType, + printedExpiry, + purchasedAt, + manufacturer, + doseStrength, + notes, + } = parsed.data; + + const created = await prisma.medicationInventoryItem.create({ + // The wire field `unitsTotal` counts UNITS (tablets / ampoules / + // puffs) — v1.16.10 renamed the request field to match the + // response side. + data: buildCreateInventoryInput({ + userId: user.id, + medicationId: id, + unitsTotal, + containerType: containerType ?? "OTHER", + printedExpiry: printedExpiry ?? null, + purchasedAt: purchasedAt ?? null, + manufacturer: manufacturer ?? null, + doseStrength: doseStrength ?? null, + notes: notes ?? null, + }), + }); + + await auditLog("medication.inventory.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + itemId: created.id, + unitsTotal, + containerType: created.containerType, + }, + }); + + annotate({ + action: { + name: "medication.inventory.create", + entity_type: "inventory_item", + entity_id: created.id, + }, + meta: { medication_id: id }, + }); + + // A registered container changes the dose-derived stock the + // medications-list payload carries (`stockUnitsRemaining` / + // `stockDosesRemaining`), which the card and table render. Hard-evict + // the per-user medications + compliance buckets so the supply shows on + // the very next read — a mark-stale would let the `cachedSwr` list + // serve the pre-write stock for the rest of the stale window. + invalidateUserMedications(user.id, { evict: true }); + + return apiSuccess( + serializeInventoryItem(shapeInventoryItemNotes(created)), + 201, + ); +} diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index b3f32e032..440184ebc 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -38,6 +38,7 @@ import { import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; +import { withIdempotency } from "@/lib/idempotency"; type RouteParams = { params: Promise<{ id: string }> }; @@ -96,93 +97,104 @@ export const GET = apiHandler( }, ); +/** + * Wrapped in `withIdempotency`: the client replays this write out of its + * offline outbox with the same `Idempotency-Key`, and a replay after a lost + * success response must not log the same symptom twice. Nothing else on this + * row is unique, so the wrapper is the only thing that can collapse a retry. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.36.x — a delegated write. `user` is the record the entry lands under; - // `actor` is whoever is typing, and the two differ only under a switch. - const { user, actor } = await requireRecordAuth("write", "medications"); - const { id } = await params; - - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - // Per-caller POST rate-limit — 30/min comfortably absorbs a session - // of bulk back-fill (e.g. "log yesterday's symptoms") while cutting - // off automated abuse. - // - // v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and - // this is the one condition on which delegating this verb was admitted. On - // the record key a delegate could exhaust the owner's allowance and lock - // them out of their own log, and could collect a fresh one by switching to - // another record. On the actor key they burn their own, once, wherever - // they are. `medications/compliance` set the same precedent on the read - // side. `actor.id` equals `user.id` for everyone who has not switched, so - // the bucket a self-writer lands in is byte-identical to before. - const rl = await checkRateLimit( - `medication-side-effect:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createSideEffectSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const { entry, severity, occurredAt, notes } = parsed.data; - - // v1.4.25 W21 Fix-N (code-M6) — category is derived server-side - // from the entry via the authoritative taxonomy mapping. The wire - // schema no longer accepts `category`; older clients that still - // send it now have it ignored by Zod's strict drop, and the row - // lands with the correct (entry-derived) category every time. - const category = categoryForEntry(entry); - - const created = await prisma.medicationSideEffect.create({ - data: { - userId: user.id, - medicationId: id, - category, - entry, - severity, - occurredAt: occurredAt ?? new Date(), - // Encrypt the free-text note at rest; the plaintext column stays null. - notesEncrypted: encryptNote(notes), - notes: null, - }, - }); - - await auditLog("medication.sideEffect.create", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - sideEffectId: created.id, - entry, - severity, - }, - }); - - annotate({ - action: { - name: "medication.sideEffect.create", - entity_type: "medication_side_effect", - entity_id: created.id, - }, - meta: { medication_id: id, entry, severity }, - }); - - return apiSuccess(shapeSideEffectNotes(created), 201); - }, + withIdempotency<[NextRequest, RouteParams]>(postSideEffect), ); + +async function postSideEffect( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.36.x — a delegated write. `user` is the record the entry lands under; + // `actor` is whoever is typing, and the two differ only under a switch. + const { user, actor } = await requireRecordAuth("write", "medications"); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + // Per-caller POST rate-limit — 30/min comfortably absorbs a session + // of bulk back-fill (e.g. "log yesterday's symptoms") while cutting + // off automated abuse. + // + // v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and + // this is the one condition on which delegating this verb was admitted. On + // the record key a delegate could exhaust the owner's allowance and lock + // them out of their own log, and could collect a fresh one by switching to + // another record. On the actor key they burn their own, once, wherever + // they are. `medications/compliance` set the same precedent on the read + // side. `actor.id` equals `user.id` for everyone who has not switched, so + // the bucket a self-writer lands in is byte-identical to before. + const rl = await checkRateLimit( + `medication-side-effect:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createSideEffectSchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } + + const { entry, severity, occurredAt, notes } = parsed.data; + + // v1.4.25 W21 Fix-N (code-M6) — category is derived server-side + // from the entry via the authoritative taxonomy mapping. The wire + // schema no longer accepts `category`; older clients that still + // send it now have it ignored by Zod's strict drop, and the row + // lands with the correct (entry-derived) category every time. + const category = categoryForEntry(entry); + + const created = await prisma.medicationSideEffect.create({ + data: { + userId: user.id, + medicationId: id, + category, + entry, + severity, + occurredAt: occurredAt ?? new Date(), + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(notes), + notes: null, + }, + }); + + await auditLog("medication.sideEffect.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + sideEffectId: created.id, + entry, + severity, + }, + }); + + annotate({ + action: { + name: "medication.sideEffect.create", + entity_type: "medication_side_effect", + entity_id: created.id, + }, + meta: { medication_id: id, entry, severity }, + }); + + return apiSuccess(shapeSideEffectNotes(created), 201); +} diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 1c51f2860..61ca2fccd 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -23,6 +23,7 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { readMedicationsListCached } from "@/lib/medications/list-read"; import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; +import { withIdempotency } from "@/lib/idempotency"; import { NextRequest } from "next/server"; // v1.32.25 — blast-radius cap on externally-mirrored medications per user. @@ -103,7 +104,16 @@ async function respondWithExistingMirror( }); } -export const POST = apiHandler(async (request: NextRequest) => { +/** + * The create is wrapped in `withIdempotency` so a client replaying its offline + * outbox after a lost success response replays the first answer instead of + * minting a second medication. The `(externalSource, externalId)` dedupe below + * only ever covered a MIRRORED create; a manually entered medication carries + * neither field and had nothing to collapse a retry onto. + */ +export const POST = apiHandler(withIdempotency<[NextRequest]>(postMedication)); + +async function postMedication(request: NextRequest): Promise { // v1.36.x — a delegated write, and the asymmetric one: a delegate can ADD a // medication with its whole nested schedule, and can never edit or delete // it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay @@ -424,4 +434,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }, 201, ); -}); +} diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 2ead8a4a4..bdf31f54c 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -575,6 +575,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Log a side effect", description: @@ -585,6 +586,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: createSideEffectRequest } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "The created entry, with its note decrypted.", @@ -912,6 +914,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Create a medication with at least one schedule", description: @@ -921,6 +924,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: createMedicationSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Created medication with its schedules.", @@ -1220,6 +1224,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Register a new supply container", description: @@ -1234,6 +1239,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Created inventory item.", diff --git a/tests/integration/medication-write-idempotency.test.ts b/tests/integration/medication-write-idempotency.test.ts new file mode 100644 index 000000000..9f3277c4a --- /dev/null +++ b/tests/integration/medication-write-idempotency.test.ts @@ -0,0 +1,230 @@ +/** + * The three medication create routes the native client replays out of its + * offline outbox, driven as routes against a real Postgres. + * + * Each case imports the shipped `POST` export and calls it twice with the same + * `Idempotency-Key` — the real `apiHandler`, the real `requireRecordAuth`, the + * real `withIdempotency`, the real Prisma writes. Nothing is reimplemented, + * because the thing under test happens ABOVE the handler body: a test that + * rebuilt the handler would prove its own copy of the wrapper works and would + * keep passing after a route dropped it. + * + * The client sends the header on every replay, so a lost success response used + * to cost the user a duplicate medication, a duplicate symptom entry, or a + * duplicate pen in the supply count. `POST /api/medications` had a dedupe of + * its own, but only for a MIRRORED create keyed on + * `(externalSource, externalId)`; a manually entered medication carries + * neither field and had nothing to collapse a retry onto — so the manual shape + * is what this file posts. + * + * Two assertions per route, and the second is the one that matters: the replay + * carries `X-Idempotent-Replay: true`, and the table holds exactly one row. A + * status-only check would pass against a handler that wrote twice and answered + * 201 twice. The medication case adds a third: a DIFFERENT key does create a + * second row, so the single row above is the key's doing and not some other + * uniqueness the fixture stumbled into. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-medication-idempotency-integration-1234567890abcdef"; +// The side-effect note is encrypted at rest on the way in. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-medication-idempotency"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "medication-idempotency", + email: "medication-idempotency@example.test", + timezone: "UTC", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function post(path: string, body: unknown, idempotencyKey: string) { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": idempotencyKey, + }, + body: JSON.stringify(body), + }); +} + +/** A route whose params shape is its own business — see `sharing-delegable-routes`. */ +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +/** A medication to hang the side-effect and inventory writes off. */ +async function seedMedication(): Promise { + const medication = await getPrismaClient().medication.create({ + data: { userId: USER_ID, name: "Fixture", dose: "1 tab" }, + }); + return medication.id; +} + +const MANUAL_MEDICATION = { + name: "Manually entered", + dose: "10 mg", + schedules: [ + { windowStart: "08:00", windowEnd: "08:30", timesOfDay: ["08:00"] }, + ], +}; + +describe("POST /api/medications — replayed manual create", () => { + it("replays the first response and creates exactly one medication", async () => { + const { POST } = await import("@/app/api/medications/route"); + + const first = await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-1"), + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-1"), + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + const rows = await getPrismaClient().medication.findMany({ + where: { userId: USER_ID }, + }); + expect(rows).toHaveLength(1); + }); + + it("still creates a second medication under a different key", async () => { + // The control on the case above: without it, a single row would also be + // the reading if the create had silently failed the second time for an + // unrelated reason. + const { POST } = await import("@/app/api/medications/route"); + + await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-a"), + ); + const second = await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-b"), + ); + expect(second.status).toBe(201); + expect(second.headers.get("X-Idempotent-Replay")).toBeNull(); + + const rows = await getPrismaClient().medication.findMany({ + where: { userId: USER_ID }, + }); + expect(rows).toHaveLength(2); + }); +}); + +describe("POST /api/medications/[id]/side-effects — replayed entry", () => { + it("replays the first response and logs exactly one entry", async () => { + const medicationId = await seedMedication(); + const { POST } = + await import("@/app/api/medications/[id]/side-effects/route"); + const body = { entry: "NAUSEA", severity: 3, notes: "after breakfast" }; + + const first = await (POST as Handler)( + post( + `/api/medications/${medicationId}/side-effects`, + body, + "outbox-side-effect-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post( + `/api/medications/${medicationId}/side-effects`, + body, + "outbox-side-effect-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + const rows = await getPrismaClient().medicationSideEffect.findMany({ + where: { userId: USER_ID, medicationId }, + }); + expect(rows).toHaveLength(1); + }); +}); + +describe("POST /api/medications/[id]/inventory — replayed container", () => { + it("replays the first response and registers exactly one container", async () => { + const medicationId = await seedMedication(); + const { POST } = await import("@/app/api/medications/[id]/inventory/route"); + const body = { unitsTotal: 30, containerType: "BLISTER" }; + + const first = await (POST as Handler)( + post( + `/api/medications/${medicationId}/inventory`, + body, + "outbox-inventory-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post( + `/api/medications/${medicationId}/inventory`, + body, + "outbox-inventory-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + const rows = await getPrismaClient().medicationInventoryItem.findMany({ + where: { userId: USER_ID, medicationId }, + }); + expect(rows).toHaveLength(1); + }); +});