mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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.
This commit is contained in:
1 parent
43b86e46da
commit
bc3522aabb
16 files changed
+2850
-2346
No files matched your search
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* `GET /api/medications/{id}/cadence` — the expected-vs-actual dose timeline
|
||||
* and the four compliance chips, computed from the stored intake events,
|
||||
* against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the timeline and the chips are computed from the record's own stored
|
||||
* doses: a daily dose taken on every past day but one shows that day as
|
||||
* the single miss, the streak since it, and the matching adherence;
|
||||
* - a tombstoned dose counts as not taken;
|
||||
* - the window defaults to 30 days, honours `days`, and refuses a value
|
||||
* outside 1..180 (or not a number) with the multi-issue 422;
|
||||
* - a medication with no schedule has no next dose; one with a daily
|
||||
* schedule has one;
|
||||
* - another account's medication is 404 and an anonymous caller 401.
|
||||
*
|
||||
* "Now" is the wall clock: every dose is seeded relative to it, and today's
|
||||
* slot is either seeded as taken (its window has opened) or still upcoming,
|
||||
* so neither state can turn into a miss on any day the suite runs.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET } from "@/app/api/medications/[id]/cadence/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeMedication, makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/medications/{id}/cadence";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CadenceBody {
|
||||
windowDays: number;
|
||||
anchorIso: string;
|
||||
next: {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
scheduleIndex: number;
|
||||
} | null;
|
||||
chips: {
|
||||
adherenceRate: number | null;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
missedLast30: number;
|
||||
windowDays: number;
|
||||
};
|
||||
timeline: Array<{
|
||||
day: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
scheduleIndex: number;
|
||||
status: "taken" | "skipped" | "missed" | "upcoming";
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ValidationBody {
|
||||
details: { issues: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
/** 08:00 UTC on the day `daysAgo` before today. */
|
||||
function slotStart(daysAgo: number): Date {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - daysAgo,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A daily 08:00–10:00 medication created 40 days ago, taken half an hour
|
||||
* into the window on every day of the last `days` except `missedDaysAgo`.
|
||||
* Today's dose is recorded only once its take time has passed.
|
||||
*/
|
||||
async function dailyMedicationWithOneMiss(options: {
|
||||
days: number;
|
||||
missedDaysAgo: number;
|
||||
}) {
|
||||
const owner = await makeUser({ timezone: "UTC" });
|
||||
const med = await makeMedication(owner, {
|
||||
createdAt: new Date(Date.now() - 40 * DAY_MS),
|
||||
});
|
||||
const takes: Date[] = [];
|
||||
for (let daysAgo = options.days; daysAgo >= 0; daysAgo--) {
|
||||
if (daysAgo === options.missedDaysAgo) continue;
|
||||
const takenAt = new Date(slotStart(daysAgo).getTime() + 30 * 60 * 1000);
|
||||
if (takenAt.getTime() > Date.now()) continue;
|
||||
takes.push(takenAt);
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: slotStart(daysAgo),
|
||||
takenAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { owner, med, takes };
|
||||
}
|
||||
|
||||
function pastDays(body: CadenceBody) {
|
||||
const today = slotStart(0).toISOString().slice(0, 10);
|
||||
return body.timeline.filter((p) => p.windowStart.slice(0, 10) < today);
|
||||
}
|
||||
|
||||
describe("GET /api/medications/{id}/cadence", () => {
|
||||
it("computes the timeline and the chips from the stored doses", async () => {
|
||||
const { owner, med } = await dailyMedicationWithOneMiss({
|
||||
days: 20,
|
||||
missedDaysAgo: 3,
|
||||
});
|
||||
|
||||
const res = await callRoute<CadenceBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
query: { days: 14 },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.windowDays).toBe(14);
|
||||
expect(res.data.anchorIso).toBe(med.createdAt.toISOString());
|
||||
|
||||
const past = pastDays(res.data);
|
||||
expect(past.length).toBeGreaterThanOrEqual(13);
|
||||
const missed = past.filter((p) => p.status === "missed");
|
||||
expect(missed.map((p) => p.windowStart)).toEqual([
|
||||
slotStart(3).toISOString(),
|
||||
]);
|
||||
expect(
|
||||
past
|
||||
.filter((p) => p.status !== "missed")
|
||||
.every((p) => p.status === "taken"),
|
||||
).toBe(true);
|
||||
// Today's slot appears once its window has opened, and is then taken or
|
||||
// still upcoming, never a miss.
|
||||
const today = res.data.timeline.filter(
|
||||
(p) => p.windowStart >= slotStart(0).toISOString(),
|
||||
);
|
||||
expect(today.every((p) => p.status !== "missed")).toBe(true);
|
||||
|
||||
// One miss three days ago: the run since covers two past days and today.
|
||||
expect(res.data.chips.missedLast30).toBe(1);
|
||||
expect(res.data.chips.currentStreak).toBe(3);
|
||||
const taken = res.data.timeline.filter((p) => p.status === "taken").length;
|
||||
expect(res.data.chips.adherenceRate).toBe(
|
||||
Math.round((taken / (taken + 1)) * 100),
|
||||
);
|
||||
expect(res.data.next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("counts a tombstoned dose as not taken", async () => {
|
||||
const { owner, med } = await dailyMedicationWithOneMiss({
|
||||
days: 20,
|
||||
missedDaysAgo: 3,
|
||||
});
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
where: { medicationId: med.id, scheduledFor: slotStart(5) },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const res = await callRoute<CadenceBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
query: { days: 14 },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
pastDays(res.data)
|
||||
.filter((p) => p.status === "missed")
|
||||
.map((p) => p.windowStart),
|
||||
).toEqual([slotStart(5).toISOString(), slotStart(3).toISOString()]);
|
||||
expect(res.data.chips.missedLast30).toBe(2);
|
||||
});
|
||||
|
||||
it("defaults to a 30-day window and has no next dose without a schedule", async () => {
|
||||
const owner = await makeUser({ timezone: "UTC" });
|
||||
const med = await makeMedication(
|
||||
owner,
|
||||
{ createdAt: new Date(Date.now() - 60 * DAY_MS) },
|
||||
[],
|
||||
);
|
||||
|
||||
const res = await callRoute<CadenceBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.windowDays).toBe(30);
|
||||
expect(res.data.next).toBeNull();
|
||||
expect(res.data.timeline).toEqual([]);
|
||||
expect(res.data.chips.adherenceRate).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["9999", "too large"],
|
||||
["0", "below one"],
|
||||
["notanumber", "not a number"],
|
||||
])("refuses days=%s (%s) with the multi-issue 422", async (days) => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
query: { days },
|
||||
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.length).toBeGreaterThanOrEqual(1);
|
||||
expect(issues.every((i) => i.path === "days")).toBe(true);
|
||||
for (const issue of issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers 404 for another account's medication", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const theirs = await makeMedication(other);
|
||||
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: theirs.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("answers 401 to an anonymous caller", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* `POST /api/medications/{id}/intake/bulk-delete` — undo several doses in
|
||||
* one call, against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the named doses of this medication and this record are tombstoned
|
||||
* (`deletedAt` set, sync counter bumped), and `deleted` is the real
|
||||
* count; ids of another medication or another account delete nothing
|
||||
* and answer `deleted: 0`; re-posting tombstoned ids counts zero and
|
||||
* bumps nothing;
|
||||
* - a dose that consumed stock puts its units back, once; an unstamped
|
||||
* dose touches no container;
|
||||
* - every local day the deleted doses sat on is recomputed, so the
|
||||
* supplement micronutrients of both days drop out;
|
||||
* - the medications list reflects the deletion on its very next read;
|
||||
* - an audit row carries the requested and the deleted count;
|
||||
* - 30 calls a minute per account, the 31st refused with 429 before
|
||||
* anything is deleted, another account's allowance untouched;
|
||||
* - a malformed body is 422 and deletes nothing; another account's
|
||||
* medication is 404; 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 { POST } from "@/app/api/medications/[id]/intake/bulk-delete/route";
|
||||
import { recomputeSupplementNutrientsForUser } from "@/lib/medications/ingredients/recompute";
|
||||
|
||||
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}/intake/bulk-delete";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
async function dose(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
daysAgo: number,
|
||||
overrides: Partial<
|
||||
Omit<
|
||||
Prisma.MedicationIntakeEventUncheckedCreateInput,
|
||||
"userId" | "medicationId"
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
const at = new Date(Date.now() - daysAgo * DAY_MS);
|
||||
return prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId,
|
||||
scheduledFor: at,
|
||||
takenAt: at,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function eventState(ids: string[]) {
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
syncVersion: true,
|
||||
inventoryConsumption: true,
|
||||
},
|
||||
});
|
||||
return new Map(rows.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
function bulkDelete(auth: RouteAuth, medicationId: string, body: unknown) {
|
||||
return callRoute<{ deleted: number }>(POST, {
|
||||
method: "POST",
|
||||
route: ROUTE,
|
||||
params: { id: medicationId },
|
||||
body,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/medications/{id}/intake/bulk-delete", () => {
|
||||
it("tombstones the named doses of this medication and reports the real count", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const a = await dose(owner.id, med.id, 1);
|
||||
const b = await dose(owner.id, med.id, 1, {
|
||||
scheduledFor: new Date(Date.now() - DAY_MS - 60_000),
|
||||
});
|
||||
const c = await dose(owner.id, med.id, 2);
|
||||
const kept = await dose(owner.id, med.id, 3);
|
||||
const session = await makeSession(owner);
|
||||
const before = Date.now();
|
||||
|
||||
const res = await bulkDelete(session, med.id, {
|
||||
eventIds: [a.id, b.id, c.id, "not-a-real-id"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ deleted: 3 });
|
||||
const state = await eventState([a.id, b.id, c.id, kept.id]);
|
||||
for (const event of [a, b, c]) {
|
||||
expect(state.get(event.id)?.deletedAt?.getTime()).toBeGreaterThanOrEqual(
|
||||
before,
|
||||
);
|
||||
expect(state.get(event.id)?.syncVersion).toBe(event.syncVersion + 1);
|
||||
}
|
||||
expect(state.get(kept.id)?.deletedAt).toBeNull();
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: owner.id, action: "medication.intake.bulk_delete" },
|
||||
});
|
||||
expect(audit.map((r) => JSON.parse(r.details ?? "{}"))).toEqual([
|
||||
{ medicationId: med.id, requestedCount: 4, deletedCount: 3 },
|
||||
]);
|
||||
|
||||
const again = await bulkDelete(session, med.id, { eventIds: [a.id] });
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.data).toEqual({ deleted: 0 });
|
||||
expect((await eventState([a.id])).get(a.id)?.syncVersion).toBe(
|
||||
a.syncVersion + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes nothing for ids of another medication or another account", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const sibling = await makeMedication(owner);
|
||||
const theirMed = await makeMedication(other);
|
||||
const siblings = await dose(owner.id, sibling.id, 1);
|
||||
const theirs = await dose(other.id, theirMed.id, 1);
|
||||
|
||||
const res = await bulkDelete(await makeSession(owner), med.id, {
|
||||
eventIds: [siblings.id, theirs.id],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ deleted: 0 });
|
||||
const state = await eventState([siblings.id, theirs.id]);
|
||||
expect(state.get(siblings.id)?.deletedAt).toBeNull();
|
||||
expect(state.get(theirs.id)?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a stamped dose's units back once and touches no container for an unstamped one", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const item = await prisma.medicationInventoryItem.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
state: "IN_USE",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 2,
|
||||
firstUseAt: new Date(Date.now() - 3 * DAY_MS),
|
||||
},
|
||||
});
|
||||
const stamped = await dose(owner.id, med.id, 1, {
|
||||
inventoryConsumption: [{ itemId: item.id, units: 2 }],
|
||||
});
|
||||
const unstamped = await dose(owner.id, med.id, 2);
|
||||
const session = await makeSession(owner);
|
||||
|
||||
const res = await bulkDelete(session, med.id, {
|
||||
eventIds: [stamped.id, unstamped.id],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ deleted: 2 });
|
||||
const container = await prisma.medicationInventoryItem.findUniqueOrThrow({
|
||||
where: { id: item.id },
|
||||
});
|
||||
expect(Number(container.unitsRemaining)).toBe(4);
|
||||
const state = await eventState([stamped.id, unstamped.id]);
|
||||
expect(state.get(stamped.id)?.inventoryConsumption).toBeNull();
|
||||
expect(state.get(stamped.id)?.deletedAt).not.toBeNull();
|
||||
expect(state.get(unstamped.id)?.deletedAt).not.toBeNull();
|
||||
|
||||
// A re-post finds the stamp already cleared and refunds nothing more.
|
||||
await bulkDelete(session, med.id, { eventIds: [stamped.id] });
|
||||
const after = await prisma.medicationInventoryItem.findUniqueOrThrow({
|
||||
where: { id: item.id },
|
||||
});
|
||||
expect(Number(after.unitsRemaining)).toBe(4);
|
||||
});
|
||||
|
||||
it("recomputes every day the deleted doses sat on", async () => {
|
||||
const owner = await makeUser({
|
||||
timezone: "UTC",
|
||||
modulePreferencesJson: { nutrients: true },
|
||||
});
|
||||
const med = await makeMedication(owner);
|
||||
await prisma.medicationIngredient.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
nutrient: "magnesium",
|
||||
amountPerUnit: 100,
|
||||
unit: "mg",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
const first = await dose(owner.id, med.id, 1);
|
||||
const second = await dose(owner.id, med.id, 2);
|
||||
await recomputeSupplementNutrientsForUser(owner.id);
|
||||
const supplementDays = () =>
|
||||
prisma.nutrientIntakeDay.findMany({
|
||||
where: { userId: owner.id, source: "SUPPLEMENT" },
|
||||
select: { day: true },
|
||||
orderBy: { day: "asc" },
|
||||
});
|
||||
expect((await supplementDays()).map((r) => r.day)).toEqual([
|
||||
second.scheduledFor.toISOString().slice(0, 10),
|
||||
first.scheduledFor.toISOString().slice(0, 10),
|
||||
]);
|
||||
|
||||
const res = await bulkDelete(await makeSession(owner), med.id, {
|
||||
eventIds: [first.id, second.id],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await supplementDays()).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows the deletion on the medications list's very next read", async () => {
|
||||
const owner = await makeUser({ timezone: "UTC" });
|
||||
const med = await makeMedication(owner);
|
||||
const session = await makeSession(owner);
|
||||
const today = await dose(owner.id, med.id, 0);
|
||||
const listed = async () => {
|
||||
const res = await callRoute<
|
||||
Array<{ id: string; todayEventCount: number }>
|
||||
>(LIST_MEDICATIONS, {
|
||||
method: "GET",
|
||||
route: "/api/medications",
|
||||
auth: session,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return res.data.find((m) => m.id === med.id)?.todayEventCount;
|
||||
};
|
||||
expect(await listed()).toBe(1);
|
||||
|
||||
const res = await bulkDelete(session, med.id, { eventIds: [today.id] });
|
||||
expect(res.data).toEqual({ deleted: 1 });
|
||||
|
||||
expect(await listed()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing list", {}],
|
||||
["an empty list", { eventIds: [] }],
|
||||
["a non-string id", { eventIds: [42] }],
|
||||
])("refuses %s with 422 and deletes nothing", async (_label, body) => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const event = await dose(owner.id, med.id, 1);
|
||||
|
||||
const res = await bulkDelete(await makeSession(owner), med.id, body);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect((await eventState([event.id])).get(event.id)?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("allows 30 calls a minute per account and refuses the 31st before deleting anything", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const theirMed = await makeMedication(other);
|
||||
const target = await dose(owner.id, med.id, 1);
|
||||
const session = await makeSession(owner);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ok = await bulkDelete(session, med.id, {
|
||||
eventIds: [`unknown-${i}`],
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
|
||||
const refused = await bulkDelete(session, med.id, {
|
||||
eventIds: [target.id],
|
||||
});
|
||||
|
||||
expect(refused.status).toBe(429);
|
||||
expect(refused.headers.get("X-RateLimit-Remaining")).toBe("0");
|
||||
expect(
|
||||
(await eventState([target.id])).get(target.id)?.deletedAt,
|
||||
).toBeNull();
|
||||
|
||||
const neighbour = await bulkDelete(await makeSession(other), theirMed.id, {
|
||||
eventIds: ["unknown"],
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
|
||||
it("answers 404 for another account's medication and keeps its doses", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const theirMed = await makeMedication(other);
|
||||
const theirs = await dose(other.id, theirMed.id, 1);
|
||||
|
||||
const res = await bulkDelete(await makeSession(owner), theirMed.id, {
|
||||
eventIds: [theirs.id],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(
|
||||
(await eventState([theirs.id])).get(theirs.id)?.deletedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("answers 401 to an anonymous caller", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const event = await dose(owner.id, med.id, 1);
|
||||
|
||||
const res = await bulkDelete(anonymous, med.id, { eventIds: [event.id] });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect((await eventState([event.id])).get(event.id)?.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* `GET /api/medications/{id}/intake/import/{jobId}/status` — poll a
|
||||
* per-medication intake import, against the real database and through the
|
||||
* real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the caller's own job answers its state in every phase (queued,
|
||||
* running, done, failed) with the projection the contract publishes,
|
||||
* and nothing that names the record or the medication;
|
||||
* - a stored result is passed through the allowlist: a well-formed one
|
||||
* arrives, one that does not add up arrives as null;
|
||||
* - another account's job, a job of another medication, an account-wide
|
||||
* job and an unknown id are all the same 404;
|
||||
* - an anonymous caller is 401.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { GET } from "@/app/api/medications/[id]/intake/import/[jobId]/status/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeMedication, makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/medications/{id}/intake/import/{jobId}/status";
|
||||
|
||||
interface JobStatus {
|
||||
jobId: string;
|
||||
status: string;
|
||||
progress: unknown;
|
||||
result: unknown;
|
||||
failureReason: string | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
async function makeJob(
|
||||
recordUserId: string,
|
||||
medicationId: string | null,
|
||||
overrides: Partial<
|
||||
Omit<
|
||||
Prisma.MedicationIntakeImportJobUncheckedCreateInput,
|
||||
"recordUserId" | "medicationId"
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
return prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
recordUserId,
|
||||
actorUserId: recordUserId,
|
||||
medicationId,
|
||||
payload: { rows: [] },
|
||||
progress: progress(0, 0),
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The progress shape an admitted job carries, `processed` of `total` done. */
|
||||
function progress(processed: number, total: number, imported = processed) {
|
||||
return {
|
||||
processed,
|
||||
total,
|
||||
imported,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const PHASES: Array<
|
||||
[
|
||||
string,
|
||||
Partial<Prisma.MedicationIntakeImportJobUncheckedCreateInput>,
|
||||
Partial<JobStatus>,
|
||||
]
|
||||
> = [
|
||||
[
|
||||
"queued",
|
||||
{ status: "queued", progress: progress(0, 2) },
|
||||
{
|
||||
progress: progress(0, 2),
|
||||
result: null,
|
||||
failureReason: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
"running",
|
||||
{
|
||||
status: "running",
|
||||
progress: progress(1, 2),
|
||||
startedAt: new Date("2026-07-21T10:00:01.000Z"),
|
||||
},
|
||||
{
|
||||
progress: progress(1, 2),
|
||||
startedAt: "2026-07-21T10:00:01.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
"done",
|
||||
{
|
||||
status: "done",
|
||||
progress: progress(2, 2),
|
||||
result: { imported: 2, skipped: 0, skipReasons: [] },
|
||||
startedAt: new Date("2026-07-21T10:00:01.000Z"),
|
||||
completedAt: new Date("2026-07-21T10:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
result: { imported: 2, skipped: 0, skipReasons: [] },
|
||||
failureReason: null,
|
||||
completedAt: "2026-07-21T10:00:02.000Z",
|
||||
},
|
||||
],
|
||||
[
|
||||
"failed",
|
||||
{
|
||||
status: "failed",
|
||||
progress: progress(1, 2),
|
||||
failureReason: "Medication intake import failed",
|
||||
startedAt: new Date("2026-07-21T10:00:01.000Z"),
|
||||
completedAt: new Date("2026-07-21T10:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
result: null,
|
||||
failureReason: "Medication intake import failed",
|
||||
completedAt: "2026-07-21T10:00:02.000Z",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe("GET /api/medications/{id}/intake/import/{jobId}/status", () => {
|
||||
it.each(PHASES)(
|
||||
"answers the owner's %s job without naming the record or the medication",
|
||||
async (status, stored, expected) => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const job = await makeJob(owner.id, med.id, {
|
||||
...stored,
|
||||
createdAt: new Date("2026-07-21T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await callRoute<JobStatus>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id, jobId: job.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toMatchObject({
|
||||
jobId: job.id,
|
||||
status,
|
||||
createdAt: "2026-07-21T10:00:00.000Z",
|
||||
...expected,
|
||||
});
|
||||
const text = JSON.stringify(res.body);
|
||||
expect(text).not.toContain(owner.id);
|
||||
expect(text).not.toContain(med.id);
|
||||
},
|
||||
);
|
||||
|
||||
it("answers a stored result that does not add up as null", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const job = await makeJob(owner.id, med.id, {
|
||||
status: "done",
|
||||
progress: progress(2, 2),
|
||||
// Two skips claimed, none accounted for.
|
||||
result: { imported: 2, skipped: 2, skipReasons: [], raw: "row data" },
|
||||
});
|
||||
|
||||
const res = await callRoute<JobStatus>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id, jobId: job.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.result).toBeNull();
|
||||
expect(JSON.stringify(res.body)).not.toContain("row data");
|
||||
});
|
||||
|
||||
it("answers the same 404 for a foreign job, another medication's job, an account-wide job and an unknown id", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const sibling = await makeMedication(owner);
|
||||
const theirMed = await makeMedication(other);
|
||||
const theirs = await makeJob(other.id, theirMed.id);
|
||||
const siblings = await makeJob(owner.id, sibling.id);
|
||||
const accountWide = await makeJob(owner.id, null);
|
||||
const session = await makeSession(owner);
|
||||
|
||||
const attempts: Array<{ id: string; jobId: string }> = [
|
||||
{ id: theirMed.id, jobId: theirs.id },
|
||||
{ id: med.id, jobId: theirs.id },
|
||||
{ id: med.id, jobId: siblings.id },
|
||||
{ id: med.id, jobId: accountWide.id },
|
||||
{ id: med.id, jobId: "no-such-job" },
|
||||
];
|
||||
const results = [];
|
||||
for (const params of attempts) {
|
||||
results.push(
|
||||
await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params,
|
||||
auth: session,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(results.map((r) => r.status)).toEqual([404, 404, 404, 404, 404]);
|
||||
expect(new Set(results.map((r) => JSON.stringify(r.error))).size).toBe(1);
|
||||
});
|
||||
|
||||
it("answers 401 to an anonymous caller", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const job = await makeJob(owner.id, med.id);
|
||||
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id, jobId: job.id },
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* `DELETE /api/medications/{id}/intake/purge` — clear a medication's whole
|
||||
* intake history, against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - every live dose of this medication is tombstoned, not removed: the row
|
||||
* stays with `deletedAt` set and its sync counter bumped, so the sync
|
||||
* feed can carry the removal; the count in the answer is the number of
|
||||
* rows tombstoned;
|
||||
* - an already tombstoned dose, another medication's doses and another
|
||||
* account's doses are untouched, and a second purge counts zero;
|
||||
* - the purge refunds no inventory;
|
||||
* - the medications list reflects the empty history on its very next read;
|
||||
* - an audit row carries the count;
|
||||
* - another account's medication is 404 and keeps its history; 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 } from "@/app/api/medications/[id]/intake/purge/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}/intake/purge";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
async function dose(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
daysAgo: number,
|
||||
overrides: Partial<
|
||||
Omit<
|
||||
Prisma.MedicationIntakeEventUncheckedCreateInput,
|
||||
"userId" | "medicationId"
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
const at = new Date(Date.now() - daysAgo * DAY_MS);
|
||||
return prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId,
|
||||
scheduledFor: at,
|
||||
takenAt: at,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function eventState(ids: string[]) {
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, deletedAt: true, syncVersion: true },
|
||||
});
|
||||
return new Map(rows.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
async function todayEventCount(
|
||||
auth: RouteAuth,
|
||||
medicationId: string,
|
||||
): Promise<number | undefined> {
|
||||
const res = await callRoute<Array<{ id: string; todayEventCount: number }>>(
|
||||
LIST_MEDICATIONS,
|
||||
{ method: "GET", route: "/api/medications", auth },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return res.data.find((m) => m.id === medicationId)?.todayEventCount;
|
||||
}
|
||||
|
||||
describe("DELETE /api/medications/{id}/intake/purge", () => {
|
||||
it("tombstones every live dose of this medication and leaves the rest alone", async () => {
|
||||
const owner = await makeUser({ timezone: "UTC" });
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const sibling = await makeMedication(owner);
|
||||
const theirMed = await makeMedication(other);
|
||||
const live = [
|
||||
await dose(owner.id, med.id, 0),
|
||||
await dose(owner.id, med.id, 1),
|
||||
await dose(owner.id, med.id, 2, { takenAt: null, skipped: true }),
|
||||
];
|
||||
const gone = await dose(owner.id, med.id, 3, {
|
||||
deletedAt: new Date(Date.now() - DAY_MS),
|
||||
syncVersion: 4,
|
||||
});
|
||||
const siblings = await dose(owner.id, sibling.id, 0);
|
||||
const theirs = await dose(other.id, theirMed.id, 0);
|
||||
const session = await makeSession(owner);
|
||||
const before = Date.now();
|
||||
|
||||
const res = await callRoute<{ purged: boolean; count: number }>(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ purged: true, count: 3 });
|
||||
|
||||
const state = await eventState([
|
||||
...live.map((e) => e.id),
|
||||
gone.id,
|
||||
siblings.id,
|
||||
theirs.id,
|
||||
]);
|
||||
// Tombstoned, not removed: every row is still there.
|
||||
expect(state.size).toBe(6);
|
||||
for (const event of live) {
|
||||
const row = state.get(event.id);
|
||||
expect(row?.deletedAt?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(row?.syncVersion).toBe(event.syncVersion + 1);
|
||||
}
|
||||
expect(state.get(gone.id)).toMatchObject({
|
||||
deletedAt: gone.deletedAt,
|
||||
syncVersion: 4,
|
||||
});
|
||||
expect(state.get(siblings.id)?.deletedAt).toBeNull();
|
||||
expect(state.get(theirs.id)?.deletedAt).toBeNull();
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: owner.id, action: "medication.intake.purge" },
|
||||
});
|
||||
expect(audit.map((a) => JSON.parse(a.details ?? "{}"))).toEqual([
|
||||
{ medicationId: med.id, name: med.name, deletedCount: 3 },
|
||||
]);
|
||||
|
||||
const again = await callRoute<{ purged: boolean; count: number }>(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
expect(again.data).toEqual({ purged: true, count: 0 });
|
||||
const after = await eventState(live.map((e) => e.id));
|
||||
for (const event of live) {
|
||||
expect(after.get(event.id)?.syncVersion).toBe(event.syncVersion + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("refunds no inventory for the purged doses", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const item = await prisma.medicationInventoryItem.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
state: "IN_USE",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 2,
|
||||
firstUseAt: new Date(Date.now() - 2 * DAY_MS),
|
||||
},
|
||||
});
|
||||
const stamped = await dose(owner.id, med.id, 1, {
|
||||
inventoryConsumption: [{ itemId: item.id, units: 2 }],
|
||||
});
|
||||
|
||||
const res = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await prisma.medicationInventoryItem.findUniqueOrThrow({
|
||||
where: { id: item.id },
|
||||
});
|
||||
expect(Number(row.unitsRemaining)).toBe(2);
|
||||
expect(row.state).toBe("IN_USE");
|
||||
const event = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||||
where: { id: stamped.id },
|
||||
});
|
||||
expect(event.deletedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the cleared history on the medications list's very next read", async () => {
|
||||
const owner = await makeUser({ timezone: "UTC" });
|
||||
const med = await makeMedication(owner);
|
||||
const session = await makeSession(owner);
|
||||
await dose(owner.id, med.id, 0);
|
||||
expect(await todayEventCount(session, med.id)).toBe(1);
|
||||
|
||||
const res = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await todayEventCount(session, med.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("answers 404 for another account's medication and keeps its history", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const theirMed = await makeMedication(other);
|
||||
const theirs = await dose(other.id, theirMed.id, 0);
|
||||
|
||||
const res = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: theirMed.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect((await eventState([theirs.id])).get(theirs.id)).toMatchObject({
|
||||
deletedAt: null,
|
||||
syncVersion: theirs.syncVersion,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 401 to an anonymous caller and keeps the history", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const event = await dose(owner.id, med.id, 0);
|
||||
|
||||
const res = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect((await eventState([event.id])).get(event.id)?.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* `/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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* `/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* `/api/medications/{id}/phase-config` — read, replace and reset a
|
||||
* medication's reminder phase configuration, against the real database and
|
||||
* through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - with no stored row the GET answers the defaults, which carry no `id`
|
||||
* and no `medicationId`;
|
||||
* - the PUT stores exactly the eight phase fields (an unknown body field
|
||||
* is dropped, not written), replaces a stored row on a second write, and
|
||||
* answers a malformed body with the multi-issue 422 while storing
|
||||
* nothing;
|
||||
* - the DELETE drops the row so the GET falls back to the defaults, and a
|
||||
* second reset succeeds the same way;
|
||||
* - another account's medication answers 404 on every verb and its row
|
||||
* stays as it was; an anonymous caller is 401.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
PUT,
|
||||
} from "@/app/api/medications/[id]/phase-config/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeMedication, makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/medications/{id}/phase-config";
|
||||
|
||||
const DEFAULTS = {
|
||||
greenValue: 60,
|
||||
greenMode: "MINUTES",
|
||||
yellowValue: 30,
|
||||
yellowMode: "MINUTES",
|
||||
orangeValue: 0,
|
||||
orangeMode: "MINUTES",
|
||||
redValue: 240,
|
||||
redMode: "MINUTES",
|
||||
};
|
||||
|
||||
const CUSTOM = {
|
||||
greenValue: 45,
|
||||
greenMode: "PERCENT",
|
||||
yellowValue: 20,
|
||||
yellowMode: "MINUTES",
|
||||
orangeValue: 10,
|
||||
orangeMode: "PERCENT",
|
||||
redValue: 600,
|
||||
redMode: "MINUTES",
|
||||
} as const;
|
||||
|
||||
type PhaseConfig = typeof DEFAULTS & { id?: string; medicationId?: string };
|
||||
|
||||
interface ValidationBody {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
async function storedRow(medicationId: string) {
|
||||
return prisma.reminderPhaseConfig.findUnique({
|
||||
where: { medicationId },
|
||||
select: {
|
||||
greenValue: true,
|
||||
greenMode: true,
|
||||
yellowValue: true,
|
||||
yellowMode: true,
|
||||
orangeValue: true,
|
||||
orangeMode: true,
|
||||
redValue: true,
|
||||
redMode: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/medications/{id}/phase-config", () => {
|
||||
it("answers the defaults, without an id, when nothing is stored", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
|
||||
const res = await callRoute<PhaseConfig>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual(DEFAULTS);
|
||||
expect(await storedRow(med.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("answers 401 to an anonymous caller", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/{id}/phase-config", () => {
|
||||
it("stores the eight phase fields, drops an unknown one, and the GET reads them back", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const session = await makeSession(owner);
|
||||
|
||||
const res = await callRoute<PhaseConfig>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
body: { ...CUSTOM, maliciousField: "owned", medicationId: "elsewhere" },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toMatchObject({ ...CUSTOM, medicationId: med.id });
|
||||
expect(typeof res.data.id).toBe("string");
|
||||
expect(await storedRow(med.id)).toEqual(CUSTOM);
|
||||
expect(await prisma.reminderPhaseConfig.count()).toBe(1);
|
||||
|
||||
const read = await callRoute<PhaseConfig>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
expect(read.status).toBe(200);
|
||||
expect(read.data).toMatchObject({ ...CUSTOM, id: res.data.id });
|
||||
});
|
||||
|
||||
it("replaces a stored configuration on a second write, keeping one row", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const session = await makeSession(owner);
|
||||
await prisma.reminderPhaseConfig.create({
|
||||
data: { medicationId: med.id, ...CUSTOM },
|
||||
});
|
||||
|
||||
const res = await callRoute<PhaseConfig>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
body: { ...DEFAULTS, redValue: 90 },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedRow(med.id)).toEqual({ ...DEFAULTS, redValue: 90 });
|
||||
expect(await prisma.reminderPhaseConfig.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("answers a malformed body with every issue and stores nothing", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
body: {
|
||||
...DEFAULTS,
|
||||
greenValue: "not-a-number",
|
||||
greenMode: "BOGUS",
|
||||
redValue: 10_000,
|
||||
},
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const body = res.body as ValidationBody;
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.map((i) => i.path).sort()).toEqual([
|
||||
"greenMode",
|
||||
"greenValue",
|
||||
"redValue",
|
||||
]);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
expect(await storedRow(med.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("answers 404 for another account's medication and leaves its row alone", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const theirs = await makeMedication(other);
|
||||
await prisma.reminderPhaseConfig.create({
|
||||
data: { medicationId: theirs.id, ...CUSTOM },
|
||||
});
|
||||
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
params: { id: theirs.id },
|
||||
body: DEFAULTS,
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await storedRow(theirs.id)).toEqual(CUSTOM);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/{id}/phase-config", () => {
|
||||
it("drops the row so the GET answers the defaults, and resets again without complaint", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const session = await makeSession(owner);
|
||||
await prisma.reminderPhaseConfig.create({
|
||||
data: { medicationId: med.id, ...CUSTOM },
|
||||
});
|
||||
|
||||
const res = await callRoute<{ reset: boolean }>(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ reset: true });
|
||||
expect(await storedRow(med.id)).toBeNull();
|
||||
|
||||
const read = await callRoute<PhaseConfig>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
expect(read.data).toEqual(DEFAULTS);
|
||||
|
||||
const again = await callRoute<{ reset: boolean }>(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
auth: session,
|
||||
});
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.data).toEqual({ reset: true });
|
||||
});
|
||||
|
||||
it("answers 404 on every verb for another account's medication and keeps its row", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const theirs = await makeMedication(other);
|
||||
await prisma.reminderPhaseConfig.create({
|
||||
data: { medicationId: theirs.id, ...CUSTOM },
|
||||
});
|
||||
const session = await makeSession(owner);
|
||||
|
||||
const read = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: theirs.id },
|
||||
auth: session,
|
||||
});
|
||||
const reset = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: theirs.id },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(read.status).toBe(404);
|
||||
expect(reset.status).toBe(404);
|
||||
expect(await storedRow(theirs.id)).toEqual(CUSTOM);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* `/api/medications/{id}/side-effects` and `/{logId}` — list, log and remove
|
||||
* side-effect entries, against the real database and through the real API
|
||||
* kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the list carries this medication's entries of this record only, newest
|
||||
* first, with the note decrypted; `from` is inclusive, `to` exclusive,
|
||||
* and `limit` cuts the page and says so in `meta`;
|
||||
* - the POST derives the category from the entry whatever the client
|
||||
* sends, stores the note encrypted with the plaintext column empty,
|
||||
* defaults `occurredAt` to now, and writes an audit row;
|
||||
* - 30 writes a minute per account, the 31st refused with 429 and nothing
|
||||
* stored, while another account's allowance is untouched;
|
||||
* - the DELETE is a hard delete whose audit row keeps the entry, the
|
||||
* severity and the date; an entry of another account or filed under
|
||||
* another medication is 404 and stays;
|
||||
* - malformed queries and bodies answer the multi-issue 422 and store
|
||||
* nothing; another account's medication is 404; anonymous is 401.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { GET, POST } from "@/app/api/medications/[id]/side-effects/route";
|
||||
import { DELETE } from "@/app/api/medications/[id]/side-effects/[logId]/route";
|
||||
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeMedication, makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/medications/{id}/side-effects";
|
||||
const ITEM_ROUTE = "/api/medications/{id}/side-effects/{logId}";
|
||||
|
||||
interface SideEffect {
|
||||
id: string;
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
occurredAt: string;
|
||||
category: string;
|
||||
entry: string;
|
||||
severity: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface ListBody {
|
||||
items: SideEffect[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
interface ValidationBody {
|
||||
details: { issues: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
async function logSideEffect(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
overrides: Partial<
|
||||
Omit<
|
||||
Prisma.MedicationSideEffectUncheckedCreateInput,
|
||||
"userId" | "medicationId"
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
return prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId,
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt: new Date("2026-05-10T08:00:00Z"),
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/medications/{id}/side-effects", () => {
|
||||
it("lists this medication's entries newest first with the note decrypted, and nobody else's", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const sibling = await makeMedication(owner);
|
||||
const theirs = await makeMedication(other);
|
||||
const older = await logSideEffect(owner.id, med.id, {
|
||||
occurredAt: new Date("2026-05-01T08:00:00Z"),
|
||||
notesEncrypted: encryptNote("after breakfast"),
|
||||
});
|
||||
const newer = await logSideEffect(owner.id, med.id, {
|
||||
entry: "DIARRHEA",
|
||||
severity: 3,
|
||||
occurredAt: new Date("2026-05-12T08:00:00Z"),
|
||||
});
|
||||
await logSideEffect(owner.id, sibling.id);
|
||||
await logSideEffect(other.id, theirs.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.id, i.entry, i.notes])).toEqual([
|
||||
[newer.id, "DIARRHEA", null],
|
||||
[older.id, "NAUSEA", "after breakfast"],
|
||||
]);
|
||||
expect(res.data.meta.total).toBe(2);
|
||||
expect(JSON.stringify(res.body)).not.toContain("notesEncrypted");
|
||||
});
|
||||
|
||||
it("treats `from` as inclusive and `to` as exclusive, and cuts the page at `limit`", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const session = await makeSession(owner);
|
||||
const atFrom = await logSideEffect(owner.id, med.id, {
|
||||
occurredAt: new Date("2026-05-01T00:00:00Z"),
|
||||
});
|
||||
const inside = await logSideEffect(owner.id, med.id, {
|
||||
occurredAt: new Date("2026-05-07T00:00:00Z"),
|
||||
});
|
||||
await logSideEffect(owner.id, med.id, {
|
||||
occurredAt: new Date("2026-05-14T00:00:00Z"),
|
||||
});
|
||||
await logSideEffect(owner.id, med.id, {
|
||||
occurredAt: new Date("2026-04-30T23:59:59Z"),
|
||||
});
|
||||
|
||||
const windowed = await callRoute<ListBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
query: { from: "2026-05-01T00:00:00Z", to: "2026-05-14T00:00:00Z" },
|
||||
auth: session,
|
||||
});
|
||||
expect(windowed.status).toBe(200);
|
||||
expect(windowed.data.items.map((i) => i.id)).toEqual([
|
||||
inside.id,
|
||||
atFrom.id,
|
||||
]);
|
||||
expect(windowed.meta).toMatchObject({ truncated: false, limit: 50 });
|
||||
|
||||
const capped = await callRoute<ListBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
query: { limit: 3 },
|
||||
auth: session,
|
||||
});
|
||||
expect(capped.status).toBe(200);
|
||||
expect(capped.data.items).toHaveLength(3);
|
||||
expect(capped.data.meta.total).toBe(3);
|
||||
expect(capped.meta).toMatchObject({ truncated: true, limit: 3 });
|
||||
});
|
||||
|
||||
it("answers a malformed query with every issue", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
query: { from: "not-iso", limit: 99999 },
|
||||
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(["from", "limit"]);
|
||||
for (const issue of issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
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 logSideEffect(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}/side-effects", () => {
|
||||
it("stores the entry with the category derived from it and the note encrypted", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const occurredAt = "2026-05-14T08:00:00.000Z";
|
||||
|
||||
const res = await callRoute<SideEffect>(POST, {
|
||||
method: "POST",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
body: {
|
||||
// A client claim the server must not follow.
|
||||
category: "INJECTION_SITE",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt,
|
||||
notes: "after breakfast",
|
||||
},
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.data).toMatchObject({
|
||||
medicationId: med.id,
|
||||
userId: owner.id,
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt,
|
||||
notes: "after breakfast",
|
||||
});
|
||||
|
||||
const row = await prisma.medicationSideEffect.findUniqueOrThrow({
|
||||
where: { id: res.data.id },
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt: new Date(occurredAt),
|
||||
notes: null,
|
||||
});
|
||||
expect(row.notesEncrypted).not.toBeNull();
|
||||
expect(readNote(row.notesEncrypted, null)).toBe("after breakfast");
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: owner.id, action: "medication.sideEffect.create" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "{}")).toEqual({
|
||||
medicationId: med.id,
|
||||
sideEffectId: row.id,
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("dates an entry without `occurredAt` at the time of the write", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const before = Date.now();
|
||||
|
||||
const res = await callRoute<SideEffect>(POST, {
|
||||
method: "POST",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
body: { entry: "EARLY_SATIETY", severity: 3 },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
const after = Date.now();
|
||||
expect(res.status).toBe(201);
|
||||
const row = await prisma.medicationSideEffect.findUniqueOrThrow({
|
||||
where: { id: res.data.id },
|
||||
});
|
||||
expect(row.category).toBe("GLP1_SPECIFIC");
|
||||
expect(row.notesEncrypted).toBeNull();
|
||||
expect(row.occurredAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(row.occurredAt.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("refuses a severity outside 1..5 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: { entry: "NAUSEA", severity: 7 },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(await prisma.medicationSideEffect.count()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"two",
|
||||
{ severity: "junk", notes: "x".repeat(2000) },
|
||||
["entry", "notes", "severity"],
|
||||
],
|
||||
[
|
||||
"three",
|
||||
{
|
||||
entry: 999,
|
||||
severity: "junk",
|
||||
occurredAt: "not-iso",
|
||||
notes: "x".repeat(5000),
|
||||
},
|
||||
["entry", "notes", "occurredAt", "severity"],
|
||||
],
|
||||
])(
|
||||
"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);
|
||||
const issues = (res.body as ValidationBody).details.issues;
|
||||
expect(issues.map((i) => i.path).sort()).toEqual(paths);
|
||||
expect(await prisma.medicationSideEffect.count()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("allows 30 writes a minute per account and refuses the 31st with 429", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const theirMed = await makeMedication(other);
|
||||
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: { entry: "NAUSEA", severity: 1 },
|
||||
auth: session,
|
||||
});
|
||||
expect(ok.status).toBe(201);
|
||||
}
|
||||
|
||||
const refused = await callRoute(POST, {
|
||||
method: "POST",
|
||||
route: ROUTE,
|
||||
params: { id: med.id },
|
||||
body: { entry: "NAUSEA", severity: 1 },
|
||||
auth: session,
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
expect(refused.headers.get("Retry-After")).not.toBeNull();
|
||||
expect(
|
||||
await prisma.medicationSideEffect.count({ where: { userId: owner.id } }),
|
||||
).toBe(30);
|
||||
|
||||
const neighbour = await callRoute(POST, {
|
||||
method: "POST",
|
||||
route: ROUTE,
|
||||
params: { id: theirMed.id },
|
||||
body: { entry: "NAUSEA", severity: 1 },
|
||||
auth: await makeSession(other),
|
||||
});
|
||||
expect(neighbour.status).toBe(201);
|
||||
});
|
||||
|
||||
it("answers 404 for another account's medication and stores 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: { entry: "NAUSEA", severity: 2 },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await prisma.medicationSideEffect.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: { entry: "NAUSEA", severity: 2 },
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(await prisma.medicationSideEffect.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/{id}/side-effects/{logId}", () => {
|
||||
it("removes the row and keeps entry, severity and date in the audit trail", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const row = await logSideEffect(owner.id, med.id, {
|
||||
severity: 3,
|
||||
occurredAt: new Date("2026-05-14T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await callRoute<{ id: string; deleted: boolean }>(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ITEM_ROUTE,
|
||||
params: { id: med.id, logId: row.id },
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ id: row.id, deleted: true });
|
||||
expect(
|
||||
await prisma.medicationSideEffect.findUnique({ where: { id: row.id } }),
|
||||
).toBeNull();
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: owner.id, action: "medication.sideEffect.delete" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "{}")).toEqual({
|
||||
medicationId: med.id,
|
||||
sideEffectId: row.id,
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
occurredAt: "2026-05-14T08:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 for another account's entry and for one filed under another medication, and both stay", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const sibling = await makeMedication(owner);
|
||||
const theirMed = await makeMedication(other);
|
||||
const theirs = await logSideEffect(other.id, theirMed.id);
|
||||
const siblings = await logSideEffect(owner.id, sibling.id);
|
||||
const session = await makeSession(owner);
|
||||
|
||||
const foreign = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ITEM_ROUTE,
|
||||
params: { id: theirMed.id, logId: theirs.id },
|
||||
auth: session,
|
||||
});
|
||||
const crossed = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ITEM_ROUTE,
|
||||
params: { id: med.id, logId: siblings.id },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(foreign.status).toBe(404);
|
||||
expect(crossed.status).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await prisma.medicationSideEffect.findMany({
|
||||
where: { id: { in: [theirs.id, siblings.id] } },
|
||||
})
|
||||
).length,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("answers 401 to an anonymous caller and keeps the row", async () => {
|
||||
const owner = await makeUser();
|
||||
const med = await makeMedication(owner);
|
||||
const row = await logSideEffect(owner.id, med.id);
|
||||
|
||||
const res = await callRoute(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ITEM_ROUTE,
|
||||
params: { id: med.id, logId: row.id },
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(
|
||||
await prisma.medicationSideEffect.findUnique({ where: { id: row.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user