Files
HealthLog/tests/integration/routes/medication-inventory-item.test.ts
T
Marc-André Bombeck bc3522aabb test(integration): move medication sub-route tests onto Postgres
Phase config, cadence, side effects, inventory, intake purge, bulk delete and
the import status run through the real handlers against the database. The
advisory-lock ordering the mocked tests read off call order is shown with a
real concurrent transaction holding the inventory lock, cache eviction through
the next list read with a control that the cached list would otherwise stay
stale, and soft deletes on the tombstone and sync counter they leave.
2026-10-22 05:32:22 +02:00

571 lines
17 KiB
TypeScript

/**
* `/api/medications/{id}/inventory/{itemId}` — correct and remove one supply
* container, against the real database and through the real API kit.
*
* Pinned here:
* - an opening date flips an unopened pen to IN_USE and starts its 30-day
* clock; one back-dated past that window lands at EXPIRED; the same
* back-dated date on a blister stays IN_USE with no deadline, because a
* blister carries no post-opening clock; a null opening date returns the
* container to ACTIVE and drops its deadline;
* - `markAsUsedUp` zeros the count and is terminal; a stock correction
* sets the count absolutely, clamps to the capacity, derives USED_UP at
* zero, and a negative count is refused;
* - carton labelling is written when sent, untouched when absent, cleared
* on an explicit null;
* - the correction and the delete wait for the per-medication inventory
* lock the intake consumption holds, and the correction reads the row
* only once it holds the lock, so a concurrent decrement is not undone;
* - the delete is a hard delete whose audit row keeps what the container
* was; each write shows on the medications list's very next read;
* - another account's container, or one filed under another medication,
* is 404 and stays as it was; malformed bodies answer the multi-issue
* 422 and change nothing; anonymous is 401.
*/
import { beforeEach, describe, expect, it } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { GET as LIST_MEDICATIONS } from "@/app/api/medications/route";
import {
DELETE,
PATCH,
} from "@/app/api/medications/[id]/inventory/[itemId]/route";
import { lockMedicationInventory } from "@/lib/medications/inventory/consumption";
import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route";
import { makeMedication, makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/medications/{id}/inventory/{itemId}";
const DAY_MS = 24 * 60 * 60 * 1000;
interface InventoryItem {
id: string;
state: string;
unitsTotal: number;
unitsRemaining: number;
firstUseAt: string | null;
expiresAt: string | null;
manufacturer: string | null;
doseStrength: string | null;
}
interface ValidationBody {
details: { issues: Array<Record<string, unknown>> };
}
beforeEach(async () => {
await truncateAllTables(prisma);
});
async function addContainer(
userId: string,
medicationId: string,
overrides: Partial<
Omit<
Prisma.MedicationInventoryItemUncheckedCreateInput,
"userId" | "medicationId"
>
> = {},
) {
return prisma.medicationInventoryItem.create({
data: {
userId,
medicationId,
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 4,
...overrides,
},
});
}
async function stored(itemId: string) {
const row = await prisma.medicationInventoryItem.findUnique({
where: { id: itemId },
});
return row
? {
state: row.state,
unitsRemaining: Number(row.unitsRemaining),
firstUseAt: row.firstUseAt,
expiresAt: row.expiresAt,
manufacturer: row.manufacturer,
doseStrength: row.doseStrength,
}
: null;
}
/** A whole-second instant `days` from now, so it round-trips the wire. */
function daysFromNow(days: number): Date {
const at = new Date(Date.now() + days * DAY_MS);
at.setUTCMilliseconds(0);
return at;
}
async function listedStock(
auth: RouteAuth,
medicationId: string,
): Promise<number | null | undefined> {
const res = await callRoute<
Array<{ id: string; stockUnitsRemaining: number | null }>
>(LIST_MEDICATIONS, { method: "GET", route: "/api/medications", auth });
expect(res.status).toBe(200);
return res.data.find((m) => m.id === medicationId)?.stockUnitsRemaining;
}
async function owned() {
const owner = await makeUser();
const med = await makeMedication(owner);
return { owner, med, session: await makeSession(owner) };
}
function patch(
auth: RouteAuth,
medicationId: string,
itemId: string,
body: unknown,
) {
return callRoute<InventoryItem>(PATCH, {
method: "PATCH",
route: ROUTE,
params: { id: medicationId, itemId },
body,
auth,
});
}
describe("PATCH — the opening date", () => {
it("flips an unopened pen to IN_USE and starts its 30-day clock", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id);
const openedAt = daysFromNow(-1);
const res = await patch(session, med.id, item.id, {
markAsFirstUseAt: openedAt.toISOString(),
});
expect(res.status).toBe(200);
expect(res.data.state).toBe("IN_USE");
expect(await stored(item.id)).toMatchObject({
state: "IN_USE",
firstUseAt: openedAt,
expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS),
unitsRemaining: 4,
});
const audit = await prisma.auditLog.findMany({
where: { userId: owner.id, action: "medication.inventory.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "{}")).toMatchObject({
medicationId: med.id,
itemId: item.id,
prevState: "ACTIVE",
nextState: "IN_USE",
});
});
it("lands a pen opened more than 30 days ago at EXPIRED", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id);
const openedAt = daysFromNow(-45);
const res = await patch(session, med.id, item.id, {
markAsFirstUseAt: openedAt.toISOString(),
});
expect(res.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
state: "EXPIRED",
firstUseAt: openedAt,
});
});
it("keeps a blister opened 45 days ago IN_USE with no deadline", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, {
containerType: "BLISTER",
unitsTotal: 60,
unitsRemaining: 54,
});
const openedAt = daysFromNow(-45);
const res = await patch(session, med.id, item.id, {
markAsFirstUseAt: openedAt.toISOString(),
});
expect(res.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
state: "IN_USE",
firstUseAt: openedAt,
expiresAt: null,
unitsRemaining: 54,
});
});
it("clears the opening date on null, back to ACTIVE with no deadline", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, {
state: "IN_USE",
firstUseAt: daysFromNow(-5),
expiresAt: daysFromNow(25),
});
const res = await patch(session, med.id, item.id, {
markAsFirstUseAt: null,
});
expect(res.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
state: "ACTIVE",
firstUseAt: null,
expiresAt: null,
});
});
});
describe("PATCH — used up and stock corrections", () => {
it("zeros the count and marks the container USED_UP, and the list shows it next read", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, {
state: "IN_USE",
firstUseAt: daysFromNow(-2),
unitsRemaining: 2,
});
expect(await listedStock(session, med.id)).toBe(2);
const res = await patch(session, med.id, item.id, { markAsUsedUp: true });
expect(res.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
state: "USED_UP",
unitsRemaining: 0,
});
expect(await listedStock(session, med.id)).toBe(0);
});
it.each([
[1, 1, "IN_USE"],
[0, 0, "USED_UP"],
[99, 4, "IN_USE"],
])(
"sets unitsRemaining %s as %s units, state %s",
async (sent, expected, state) => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, {
state: "IN_USE",
firstUseAt: daysFromNow(-1),
unitsRemaining: 3,
});
const res = await patch(session, med.id, item.id, {
unitsRemaining: sent,
});
expect(res.status).toBe(200);
expect(res.data.unitsRemaining).toBe(expected);
expect(await stored(item.id)).toMatchObject({
unitsRemaining: expected,
state,
});
},
);
it("refuses a negative correction with 422 and leaves the count", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, { unitsRemaining: 3 });
const res = await patch(session, med.id, item.id, { unitsRemaining: -1 });
expect(res.status).toBe(422);
expect((await stored(item.id))?.unitsRemaining).toBe(3);
});
it.each([
[
"two",
{ markAsFirstUseAt: "not-iso", markAsUsedUp: "string" },
["markAsFirstUseAt", "markAsUsedUp"],
],
[
"three",
{
markAsFirstUseAt: "not-iso",
markAsUsedUp: "string",
printedExpiry: "also-bad",
notes: 123,
},
["markAsFirstUseAt", "markAsUsedUp", "notes", "printedExpiry"],
],
])(
"answers %s or more simultaneous body errors at once and changes nothing",
async (_n, body, paths) => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id);
const before = await stored(item.id);
const res = await patch(session, med.id, item.id, body);
expect(res.status).toBe(422);
expect(res.error).toBe("Validation failed");
const issues = (res.body as ValidationBody).details.issues;
expect(issues.map((i) => i.path).sort()).toEqual(paths);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
expect(await stored(item.id)).toEqual(before);
},
);
});
describe("PATCH — carton labelling", () => {
it("writes manufacturer and strength when sent", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id);
const res = await patch(session, med.id, item.id, {
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
});
expect(res.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
});
});
it("leaves both untouched when the request omits them, and clears only the one sent as null", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, {
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
});
const untouched = await patch(session, med.id, item.id, {
unitsRemaining: 2,
});
expect(untouched.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
unitsRemaining: 2,
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
});
const cleared = await patch(session, med.id, item.id, {
manufacturer: null,
});
expect(cleared.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
manufacturer: null,
doseStrength: "5 mg/0.5 ml",
});
});
});
describe("PATCH / DELETE — the inventory lock", () => {
/**
* Hold the per-medication inventory lock the intake consumption takes,
* optionally doing work inside it, until `release` is called.
*/
async function holdLock(
medicationId: string,
inside: (tx: Prisma.TransactionClient) => Promise<void> = async () => {},
) {
let release = () => {};
const released = new Promise<void>((resolve) => {
release = resolve;
});
let signalHeld = () => {};
const held = new Promise<void>((resolve) => {
signalHeld = resolve;
});
const holder = prisma.$transaction(
async (tx) => {
await lockMedicationInventory(tx, medicationId);
await inside(tx);
signalHeld();
await released;
},
{ timeout: 15_000 },
);
await held;
return { release, holder };
}
async function stillPending(promise: Promise<unknown>): Promise<boolean> {
let settled = false;
void promise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await new Promise((resolve) => setTimeout(resolve, 400));
return !settled;
}
it("waits for a concurrent decrement and does not write the count back over it", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id, {
state: "IN_USE",
firstUseAt: daysFromNow(-1),
unitsRemaining: 3,
});
// A dose being consumed: the decrement is made under the lock and is
// not committed yet when the correction arrives.
const { release, holder } = await holdLock(med.id, async (tx) => {
await tx.medicationInventoryItem.update({
where: { id: item.id },
data: { unitsRemaining: 2 },
});
});
const correction = patch(session, med.id, item.id, {
manufacturer: "Example Pharma",
});
expect(await stillPending(correction)).toBe(true);
release();
await holder;
const res = await correction;
expect(res.status).toBe(200);
expect(await stored(item.id)).toMatchObject({
unitsRemaining: 2,
manufacturer: "Example Pharma",
});
});
it("makes a delete wait for the lock before removing the container", async () => {
const { owner, med, session } = await owned();
const item = await addContainer(owner.id, med.id);
const { release, holder } = await holdLock(med.id);
const removal = callRoute(DELETE, {
method: "DELETE",
route: ROUTE,
params: { id: med.id, itemId: item.id },
auth: session,
});
expect(await stillPending(removal)).toBe(true);
expect(await stored(item.id)).not.toBeNull();
release();
await holder;
expect((await removal).status).toBe(200);
expect(await stored(item.id)).toBeNull();
});
});
describe("DELETE /api/medications/{id}/inventory/{itemId}", () => {
it("removes the row, audits what the container was, and the list drops its stock next read", async () => {
const { owner, med, session } = await owned();
const expiry = daysFromNow(10);
const item = await addContainer(owner.id, med.id, {
state: "IN_USE",
firstUseAt: daysFromNow(-1),
unitsRemaining: 2,
doseStrength: "5 mg/0.5 ml",
printedExpiry: expiry,
expiresAt: expiry,
});
expect(await listedStock(session, med.id)).toBe(2);
const res = await callRoute<{ id: string; deleted: boolean }>(DELETE, {
method: "DELETE",
route: ROUTE,
params: { id: med.id, itemId: item.id },
auth: session,
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ id: item.id, deleted: true });
expect(await stored(item.id)).toBeNull();
const audit = await prisma.auditLog.findMany({
where: { userId: owner.id, action: "medication.inventory.delete" },
});
expect(audit).toHaveLength(1);
const details = JSON.parse(audit[0]?.details ?? "{}");
expect(details).toMatchObject({
medicationId: med.id,
itemId: item.id,
finalState: "IN_USE",
unitsRemaining: 2,
});
expect(JSON.stringify(details)).toContain("5 mg/0.5 ml");
// The medication has no container left, so it tracks no stock at all.
expect(await listedStock(session, med.id)).toBeNull();
});
});
describe("ownership", () => {
it("answers 404 for another account's container and for one under another medication, and both stay", async () => {
const { owner, med, session } = await owned();
const sibling = await makeMedication(owner);
const other = await makeUser();
const theirMed = await makeMedication(other);
const theirs = await addContainer(other.id, theirMed.id, {
unitsRemaining: 3,
});
const siblings = await addContainer(owner.id, sibling.id, {
unitsRemaining: 3,
});
const results = [
await patch(session, theirMed.id, theirs.id, { markAsUsedUp: true }),
await patch(session, med.id, siblings.id, { markAsUsedUp: true }),
await callRoute(DELETE, {
method: "DELETE",
route: ROUTE,
params: { id: theirMed.id, itemId: theirs.id },
auth: session,
}),
await callRoute(DELETE, {
method: "DELETE",
route: ROUTE,
params: { id: med.id, itemId: siblings.id },
auth: session,
}),
];
expect(results.map((r) => r.status)).toEqual([404, 404, 404, 404]);
expect(await stored(theirs.id)).toMatchObject({
state: "ACTIVE",
unitsRemaining: 3,
});
expect(await stored(siblings.id)).toMatchObject({
state: "ACTIVE",
unitsRemaining: 3,
});
});
it("answers 401 to an anonymous caller and changes nothing", async () => {
const { owner, med } = await owned();
const item = await addContainer(owner.id, med.id);
const res = await patch(anonymous, med.id, item.id, {
markAsUsedUp: true,
});
const removal = await callRoute(DELETE, {
method: "DELETE",
route: ROUTE,
params: { id: med.id, itemId: item.id },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(removal.status).toBe(401);
expect(await stored(item.id)).toMatchObject({
state: "ACTIVE",
unitsRemaining: 4,
});
});
});