mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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({
|
||||
|
||||
Reference in new issue
Block a user