mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
|
* `/api/medications/{id}/inventory` — list and register supply containers,
|
|
* against the real database and through the real API kit.
|
|
*
|
|
* Pinned here:
|
|
* - the list carries this medication's containers of this record only,
|
|
* with a server-computed supply summary: ACTIVE and IN_USE units pool
|
|
* into the available stock, divided by the medication's units per dose;
|
|
* an EXPIRED container is reported apart and never counts as available;
|
|
* - a registration stores an ACTIVE container holding its full capacity,
|
|
* unopened, with the printed expiry as its deadline, the carton
|
|
* labelling when sent and null when not, and an audit row;
|
|
* - the medications list shows the new stock on the very next read rather
|
|
* than serving the cached pre-write figure;
|
|
* - 30 registrations a minute per account, the 31st refused with 429 and
|
|
* nothing stored;
|
|
* - malformed bodies answer the multi-issue 422 and store nothing; another
|
|
* account's medication is 404 and gains 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 { GET, POST } from "@/app/api/medications/[id]/inventory/route";
|
|
|
|
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";
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
interface InventoryItem {
|
|
id: string;
|
|
state: string;
|
|
containerType: string;
|
|
unitsTotal: number;
|
|
unitsRemaining: number;
|
|
firstUseAt: string | null;
|
|
expiresAt: string | null;
|
|
printedExpiry: string | null;
|
|
manufacturer: string | null;
|
|
doseStrength: string | null;
|
|
notes: string | null;
|
|
}
|
|
|
|
interface ListBody {
|
|
items: InventoryItem[];
|
|
summary: {
|
|
unitsRemaining: number;
|
|
unitsTotal: number;
|
|
dosesRemaining: number;
|
|
dosesTotal: number;
|
|
expiredUnits: number;
|
|
};
|
|
meta: { total: number };
|
|
}
|
|
|
|
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,
|
|
unitsTotal: 4,
|
|
unitsRemaining: 4,
|
|
...overrides,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** The stock the medications list reports for one medication. */
|
|
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;
|
|
}
|
|
|
|
describe("GET /api/medications/{id}/inventory", () => {
|
|
it("lists this medication's containers with the dose-derived supply summary", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const med = await makeMedication(owner, { unitsPerDose: 2 });
|
|
const sibling = await makeMedication(owner);
|
|
const theirMed = await makeMedication(other);
|
|
await addContainer(owner.id, med.id, {
|
|
state: "IN_USE",
|
|
unitsRemaining: 3,
|
|
firstUseAt: new Date(Date.now() - DAY_MS),
|
|
});
|
|
await addContainer(owner.id, med.id, { state: "ACTIVE" });
|
|
await addContainer(owner.id, med.id, { state: "EXPIRED" });
|
|
await addContainer(owner.id, med.id, {
|
|
state: "USED_UP",
|
|
unitsRemaining: 0,
|
|
});
|
|
await addContainer(owner.id, sibling.id);
|
|
await addContainer(other.id, theirMed.id);
|
|
|
|
const res = await callRoute<ListBody>(GET, {
|
|
method: "GET",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.items.map((i) => i.state).sort()).toEqual([
|
|
"ACTIVE",
|
|
"EXPIRED",
|
|
"IN_USE",
|
|
"USED_UP",
|
|
]);
|
|
expect(res.data.meta.total).toBe(4);
|
|
// 7 available units at 2 per dose is 3 whole doses; the 8-unit capacity
|
|
// is 4; the expired container's 4 units are visible and never available.
|
|
expect(res.data.summary).toEqual({
|
|
unitsRemaining: 7,
|
|
unitsTotal: 8,
|
|
dosesRemaining: 3,
|
|
dosesTotal: 4,
|
|
expiredUnits: 4,
|
|
});
|
|
});
|
|
|
|
it("answers 404 for another account's medication and 401 to an anonymous caller", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const theirs = await makeMedication(other);
|
|
await addContainer(other.id, theirs.id);
|
|
|
|
const foreign = await callRoute(GET, {
|
|
method: "GET",
|
|
route: ROUTE,
|
|
params: { id: theirs.id },
|
|
auth: await makeSession(owner),
|
|
});
|
|
const anon = await callRoute(GET, {
|
|
method: "GET",
|
|
route: ROUTE,
|
|
params: { id: theirs.id },
|
|
auth: anonymous,
|
|
});
|
|
|
|
expect(foreign.status).toBe(404);
|
|
expect(anon.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/medications/{id}/inventory", () => {
|
|
it("registers an unopened ACTIVE container whose deadline is the printed expiry", async () => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
const printed = new Date(Date.now() + 300 * DAY_MS);
|
|
printed.setUTCMilliseconds(0);
|
|
|
|
const res = await callRoute<InventoryItem>(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 4, printedExpiry: printed.toISOString() },
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.data).toMatchObject({
|
|
state: "ACTIVE",
|
|
containerType: "OTHER",
|
|
unitsTotal: 4,
|
|
unitsRemaining: 4,
|
|
firstUseAt: null,
|
|
expiresAt: printed.toISOString(),
|
|
});
|
|
const row = await prisma.medicationInventoryItem.findUniqueOrThrow({
|
|
where: { id: res.data.id },
|
|
});
|
|
expect(row).toMatchObject({
|
|
userId: owner.id,
|
|
medicationId: med.id,
|
|
state: "ACTIVE",
|
|
containerType: "OTHER",
|
|
firstUseAt: null,
|
|
printedExpiry: printed,
|
|
expiresAt: printed,
|
|
manufacturer: null,
|
|
doseStrength: null,
|
|
});
|
|
expect(Number(row.unitsTotal)).toBe(4);
|
|
expect(Number(row.unitsRemaining)).toBe(4);
|
|
|
|
const audit = await prisma.auditLog.findMany({
|
|
where: { userId: owner.id, action: "medication.inventory.create" },
|
|
});
|
|
expect(audit.map((a) => JSON.parse(a.details ?? "{}"))).toEqual([
|
|
{
|
|
medicationId: med.id,
|
|
itemId: row.id,
|
|
unitsTotal: 4,
|
|
containerType: "OTHER",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("stores the carton labelling when sent, and null for both when not", async () => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
const session = await makeSession(owner);
|
|
|
|
const pen = await callRoute<InventoryItem>(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: {
|
|
unitsTotal: 4,
|
|
containerType: "PEN",
|
|
manufacturer: "Example Pharma",
|
|
doseStrength: "5 mg/0.5 ml",
|
|
},
|
|
auth: session,
|
|
});
|
|
const blister = await callRoute<InventoryItem>(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 30, containerType: "BLISTER" },
|
|
auth: session,
|
|
});
|
|
|
|
expect(pen.status).toBe(201);
|
|
expect(blister.status).toBe(201);
|
|
const rows = await prisma.medicationInventoryItem.findMany({
|
|
where: { id: { in: [pen.data.id, blister.data.id] } },
|
|
select: {
|
|
id: true,
|
|
containerType: true,
|
|
manufacturer: true,
|
|
doseStrength: true,
|
|
},
|
|
});
|
|
expect(rows).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
id: pen.data.id,
|
|
containerType: "PEN",
|
|
manufacturer: "Example Pharma",
|
|
doseStrength: "5 mg/0.5 ml",
|
|
},
|
|
{
|
|
id: blister.data.id,
|
|
containerType: "BLISTER",
|
|
manufacturer: null,
|
|
doseStrength: null,
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("shows the new stock on the medications list's very next read", async () => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
const session = await makeSession(owner);
|
|
// Warm the cached list: no container yet, so no stock is tracked.
|
|
expect(await listedStock(session, med.id)).toBeNull();
|
|
// The control: a container written behind the route's back is not seen,
|
|
// because the list is served from its cache.
|
|
await addContainer(owner.id, med.id, { unitsTotal: 2, unitsRemaining: 2 });
|
|
expect(await listedStock(session, med.id)).toBeNull();
|
|
|
|
const res = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 4 },
|
|
auth: session,
|
|
});
|
|
expect(res.status).toBe(201);
|
|
|
|
expect(await listedStock(session, med.id)).toBe(6);
|
|
});
|
|
|
|
it("refuses unitsTotal 0 with 422 and stores nothing", async () => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
|
|
const res = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 0 },
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await prisma.medicationInventoryItem.count()).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"two",
|
|
{ unitsTotal: "string", printedExpiry: "not-iso" },
|
|
["printedExpiry", "unitsTotal"],
|
|
],
|
|
[
|
|
"three",
|
|
{
|
|
unitsTotal: "string",
|
|
printedExpiry: "not-iso",
|
|
purchasedAt: "also-not-iso",
|
|
notes: 123,
|
|
},
|
|
["notes", "printedExpiry", "purchasedAt", "unitsTotal"],
|
|
],
|
|
])(
|
|
"answers %s or more simultaneous body errors at once",
|
|
async (_n, body, paths) => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
|
|
const res = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body,
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
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 prisma.medicationInventoryItem.count()).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("allows 30 registrations a minute per account and refuses the 31st with 429", async () => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
const session = await makeSession(owner);
|
|
for (let i = 0; i < 30; i++) {
|
|
const ok = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 1 },
|
|
auth: session,
|
|
});
|
|
expect(ok.status).toBe(201);
|
|
}
|
|
|
|
const refused = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 1 },
|
|
auth: session,
|
|
});
|
|
|
|
expect(refused.status).toBe(429);
|
|
expect(
|
|
await prisma.medicationInventoryItem.count({
|
|
where: { medicationId: med.id },
|
|
}),
|
|
).toBe(30);
|
|
});
|
|
|
|
it("answers 404 for another account's medication and registers nothing", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const theirs = await makeMedication(other);
|
|
|
|
const res = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: theirs.id },
|
|
body: { unitsTotal: 4 },
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(await prisma.medicationInventoryItem.count()).toBe(0);
|
|
});
|
|
|
|
it("answers 401 to an anonymous caller", async () => {
|
|
const owner = await makeUser();
|
|
const med = await makeMedication(owner);
|
|
|
|
const res = await callRoute(POST, {
|
|
method: "POST",
|
|
route: ROUTE,
|
|
params: { id: med.id },
|
|
body: { unitsTotal: 4 },
|
|
auth: anonymous,
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await prisma.medicationInventoryItem.count()).toBe(0);
|
|
});
|
|
});
|