mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): accept an Idempotency-Key on the three replayed creates
The native client replays its offline outbox with the same Idempotency-Key on every attempt, and `POST /api/medications`, the side-effect log and the inventory register ran the handler again on the replay: after a lost success response the account was left with a duplicate medication, a duplicate symptom entry or a second pen in the supply count. The dedupe on `(externalSource, externalId)` only ever covered a MIRRORED create; a manually entered medication carries neither field and had nothing to collapse a retry onto. All three wrap in `withIdempotency`, the composition the batch ingest already ships, and the three operations publish the header so a client generated against the contract can tell that a retry is safe. Zod parsing and `requireRecordAuth` are untouched. An integration test drives each route twice against real Postgres and asserts what a status check cannot: one row in the table, and a replayed 201 carrying `X-Idempotent-Replay: true`.
This commit is contained in:
1 parent
1d372df3a0
commit
6fe1897941
6 files changed
+464
-187
No files matched your search
@@ -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}:
|
||||
|
||||
@@ -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<Response> {
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
@@ -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<Response> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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<Response> {
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -575,6 +575,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "Log a side effect",
|
||||
description:
|
||||
@@ -585,6 +586,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "Create a medication with at least one schedule",
|
||||
description:
|
||||
@@ -921,6 +924,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: createMedicationSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Created medication with its schedules.",
|
||||
@@ -1220,6 +1224,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "Register a new supply container",
|
||||
description:
|
||||
@@ -1234,6 +1239,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Created inventory item.",
|
||||
|
||||
@@ -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<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
/** A medication to hang the side-effect and inventory writes off. */
|
||||
async function seedMedication(): Promise<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user