fix(inventory): re-run state machine on markAsFirstUseAt PATCH

A back-dated first-use stamp whose 30-day in-use window has already
lapsed must land at EXPIRED, not IN_USE. The hand-rolled state
transitions in the PATCH route composed the next-row view but never
re-evaluated through the canonical state machine, so the route could
emit an IN_USE row whose expiresAt was already in the past. Wire
`computeInventoryState` over the composed view so every PATCH lands
on the same decision tree the intake hook and the daily expire cron
already share.

Test: ACTIVE pen + markAsFirstUseAt 45 days ago ⇒ EXPIRED.

Fix-M / code-H1.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:24:54 +02:00
1 parent 44e8229ff3
commit 1b5906a504
2 files changed
+61 -1

No files matched your search

@@ -26,7 +26,10 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateInventoryItemSchema } from "@/lib/validations/medication";
import { computeExpiresAt } from "@/lib/medications/inventory/state-machine";
import {
computeExpiresAt,
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -88,6 +91,27 @@ export const PATCH = apiHandler(
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
dosesTotal: existing.dosesTotal,
dosesRemaining: nextDosesRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
@@ -268,6 +268,42 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
});
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
// Regression for the W19b state-machine bypass: composing the next
// state by hand sent ACTIVE → IN_USE on any first-use stamp, but a
// back-dated stamp whose 30-day window already lapsed must land at
// EXPIRED. Re-running `computeInventoryState` after the composed
// view closes the gap.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "EXPIRED",
} as never);
// 45 days in the past — well past the 30-day in-use window.
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "EXPIRED",
firstUseAt: new Date(backdated),
});
});
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({