fix(medications): never snap a taken intake onto a future dose slot

A taken intake whose write named (or snapped near) a slot still in the
future was attributed to that slot and rendered as taken before its time —
a late morning dose collapsing onto the evening slot of a twice-daily med.
The slot resolver now takes an isTakenWrite flag and a now reference: a
taken write skips any candidate slot more than a small clock-skew grace
into the future, so it snaps to a current/past slot or keeps its own row.
Pending sync echoes are unaffected and still map to future slots.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:22:07 +02:00
1 parent 212e8c9c04
commit 3ec80adbd2
7 files changed
+148 -2

No files matched your search

@@ -19,7 +19,7 @@
* cares about the Prisma `where` fragment derived from the new knob.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -391,6 +391,19 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
],
};
beforeEach(() => {
// Pin the clock past the 07:00 / 19:00 CEST slots of 2026-06-15 so the
// dose-safety future-slot guard treats these taken writes as landing on
// a current/past slot. Otherwise the fixtures sit days ahead of the
// real test clock and the guard refuses to snap a taken write forward.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
const { consumeOneDose } = await import(
"@/lib/medications/inventory/service"
@@ -159,6 +159,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// `takenAt`-only / defaulted-now write must not snap across the wide
// ±halfGap window onto a far slot (phantom morning dose).
instantIsExplicit: scheduledFor !== undefined,
// Dose-safety: a taken write (any non-skip on this route) must never
// snap forward onto a future slot — see resolve-slot-instant.ts.
isTakenWrite: isExplicitTaken,
});
// Idempotency check (explicit key or server-side dedup window)
@@ -2,7 +2,7 @@
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk.
* Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -186,6 +186,13 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
};
beforeEach(() => {
// Pin the clock past both fixture slots (07:00 / 19:00 CEST on
// 2026-06-15) so the dose-safety future-slot guard treats these taken
// writes as landing on a current/past slot, not a future one. Without
// the pin these fixtures are dated days ahead of the real test clock
// and the guard (correctly) refuses to snap a taken write forward.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-1" },
] as never);
@@ -194,6 +201,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
);
});
afterEach(() => {
vi.useRealTimers();
});
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
// echo (no takenAt, skipped false) — must NOT clear takenAt.
@@ -238,6 +238,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// `takenAt`-only / defaulted-now write must not snap across the
// wide ±halfGap window onto a far slot (phantom morning dose).
instantIsExplicit: entry.scheduledFor !== undefined,
// Dose-safety: a taken write (has `takenAt`, not skipped) must never
// snap forward onto a future slot. A pending sync echo (no `takenAt`)
// legitimately maps to a future slot, so the guard stays off for it.
isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
});
const scheduledFor = canonicalSlot ?? incomingScheduledFor;