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:
Marc-André Bombeck committed 2026-10-22 05:32:22 +02:00
1 parent 43b86e46da
commit bc3522aabb
16 files changed
+2850 -2346

No files matched your search

@@ -1,202 +0,0 @@
/**
* v1.4.25 — cadence + compliance API route tests.
*
* Mirrors the W19d side-effect test fixture pattern: external
* dependencies are mocked at module boundaries (Prisma, session,
* logging transport), and each test pins one contract case.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/cadence", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 for an out-of-range days parameter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=9999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns the timeline + chips for an owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sch-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence?days=14"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(14);
expect(Array.isArray(json.data.timeline)).toBe(true);
expect(json.data.chips).toMatchObject({
adherenceRate: expect.anything(),
currentStreak: expect.any(Number),
longestStreak: expect.any(Number),
missedLast30: expect.any(Number),
});
});
it("defaults to a 30-day window when no `days` query param is supplied", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(30);
});
it("returns a `null` next-dose when no schedule expands in the lookahead", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.next).toBeNull();
});
});
describe("GET /api/medications/[id]/cadence — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
} as never);
// Schema accepts `days` (coerce.number, range 1-365). Force two
// failures: non-numeric `days` + extra unknown knob is silently
// stripped, so we use the bounds-violation route: a negative number
// string still coerces to a negative int → min violation.
// For ≥2 we use a non-coercible string AND query for too-many keys.
// Cadence schema is small, so we pin the contract on ≥ 2 issues.
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=notanumber",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
});
@@ -1,299 +0,0 @@
/**
* v1.5.5 — POST /api/medications/[id]/intake/bulk-delete tests.
*
* Pins the contract the detail-page intake-history preview's
* multi-select Löschen depends on:
*
* - Asserts ownership through the shared `assertMedicationOwnership`
* helper. Negative branch returns the helper's 404 response.
* - Reads rows scoped by `(eventId IN, userId, medicationId)` so a
* leaked id from another medication never deletes anything.
* - Recomputes the rollup once per unique dayKey (not once per
* event), so a 14-row delete spanning two days closes in two
* SQL recompute trips.
* - 422 on malformed body via `bulkDeleteIntakeEventsSchema`.
* - 200 with `{ deleted: number }` carrying the real `deleteMany`
* count so the client toast announces the truth.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
medication: { findUnique: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
// v1.15.18 — bulk delete is now a soft-delete updateMany.
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
}));
vi.mock("@/lib/medications/dose-day-refresh", () => ({
dayKeyForScheduledFor: vi.fn((d: Date) => {
const iso = d.toISOString();
return iso.slice(0, 10);
}),
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// (#22) — silent cross-device intake sync. Mocked so the route test can
// assert the hook without reaching the APNs senders or the coalescing
// timers.
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
import { POST } from "../route";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import {
dayKeyForScheduledFor,
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function postWithBody(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/medications/med-1/intake/bulk-delete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("POST /api/medications/[id]/intake/bulk-delete", () => {
it("422 on missing body", async () => {
const res = await POST(postWithBody({}), ROUTE_PARAMS);
expect(res.status).toBe(422);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("422 on empty eventIds", async () => {
const res = await POST(postWithBody({ eventIds: [] }), ROUTE_PARAMS);
expect(res.status).toBe(422);
});
it("returns the 404 from the shared ownership helper", async () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
const res = await POST(postWithBody({ eventIds: ["evt-1"] }), ROUTE_PARAMS);
expect(res.status).toBe(404);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
it("returns deleted:0 + skips recompute when no matching rows are found", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce(
[] as never,
);
const res = await POST(
postWithBody({ eventIds: ["evt-leaked", "evt-other"] }),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ deleted: 0 });
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(refreshDoseDay).not.toHaveBeenCalled();
// (#22) — a no-op delete queues no cross-device sync fan-out.
expect(queueMedicationIntakeSync).not.toHaveBeenCalled();
});
it("recomputes the rollup once per unique dayKey", async () => {
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
const day2 = new Date(Date.UTC(2026, 4, 28, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "evt-a", scheduledFor: day1 },
{ id: "evt-b", scheduledFor: day1 },
{ id: "evt-c", scheduledFor: day2 },
] as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 3,
} as never);
const res = await POST(
postWithBody({ eventIds: ["evt-a", "evt-b", "evt-c"] }),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ deleted: 3 });
expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3);
// Three rows, two unique dayKeys → two recompute calls.
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
// (#22) — the whole bulk delete queues exactly ONE cross-device
// sync fan-out, not one per removed row.
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
});
it("soft-deletes scoped by userId + medicationId + deletedAt:null", async () => {
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "evt-a", scheduledFor: day1 },
] as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 1,
} as never);
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
// v1.15.18 — a bulk delete is a soft-delete updateMany: scoped by
// (id IN, user, medication, deletedAt:null) so a re-post counts zero, and
// the data sets deletedAt + bumps syncVersion for the iOS tombstone feed.
const call = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0];
expect(call?.where).toEqual({
id: { in: ["evt-a"] },
userId: "user-1",
medicationId: "med-1",
deletedAt: null,
});
expect(call?.data).toMatchObject({
deletedAt: expect.any(Date),
syncVersion: { increment: 1 },
});
});
});
describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", () => {
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("scopes the bucket key to the calling user", async () => {
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
expect(checkRateLimit).toHaveBeenCalledWith(
"medication-intake-bulk-delete:user-1",
30,
60_000,
);
});
});
describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => {
it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => {
const { restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "evt-stamped",
scheduledFor: day1,
inventoryConsumption: [{ itemId: "item-1", units: 2 }],
},
{ id: "evt-unstamped", scheduledFor: day1, inventoryConsumption: null },
] as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 2,
} as never);
const res = await POST(
postWithBody({ eventIds: ["evt-stamped", "evt-unstamped"] }),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
expect(restoreForIntake).toHaveBeenCalledTimes(1);
expect(restoreForIntake).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }),
);
// The refund runs BEFORE the soft-delete sweep.
const restoreOrder =
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(sweepOrder);
});
});
@@ -1,106 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeImportJob: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const request = new NextRequest(
"http://localhost/api/medications/med-1/intake/import/job-1",
);
const context = {
params: Promise.resolve({ id: "med-1", jobId: "job-1" }),
};
const session = {
session: { id: "session-1", expiresAt: new Date(Date.now() + 60_000) },
user: { id: "user-1", username: "owner", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(session as never);
});
describe("GET medication intake import status", () => {
it("rejects unauthenticated polling", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await GET(request, context);
expect(response.status).toBe(401);
expect(prisma.medicationIntakeImportJob.findFirst).not.toHaveBeenCalled();
});
it("uses a single owner-and-medication-scoped lookup and hides foreign jobs", async () => {
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue(
null,
);
const response = await GET(request, context);
expect(response.status).toBe(404);
expect(prisma.medicationIntakeImportJob.findFirst).toHaveBeenCalledWith({
where: {
id: "job-1",
medicationId: "med-1",
recordUserId: "user-1",
},
});
});
it.each(["queued", "running", "done", "failed"])(
"returns the owner-visible %s state",
async (status) => {
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue({
id: "job-1",
status,
progress: { processed: status === "queued" ? 0 : 2, total: 2 },
result: status === "done" ? { imported: 2 } : null,
failureReason:
status === "failed" ? "Medication intake import failed" : null,
createdAt: new Date("2026-07-21T10:00:00.000Z"),
startedAt:
status === "queued" ? null : new Date("2026-07-21T10:00:01.000Z"),
completedAt:
status === "done" || status === "failed"
? new Date("2026-07-21T10:00:02.000Z")
: null,
} as never);
const response = await GET(request, context);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.data).toEqual(
expect.objectContaining({
jobId: "job-1",
status,
progress: expect.any(Object),
}),
);
expect(JSON.stringify(body)).not.toContain("user-1");
expect(JSON.stringify(body)).not.toContain("med-1");
},
);
});
@@ -1,149 +0,0 @@
/**
* v1.5.5 — purge route fires the medication cache bundle.
*
* Pins the contract the detail-page Verlauf-löschen cascade depends
* on: tombstoning the rows is necessary but not sufficient. The
* analytics + iOS today-tally + dashboard tiles all read off cached
* shapes that survive the tombstoning for their TTL; the bundle
* invalidation forces every downstream reader to converge on the
* post-purge counts in the same tick the user sees the toast.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { updateMany: vi.fn() },
// v1.43 — no composition, so no supplement recompute is queued.
medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// (#22) — silent cross-device intake sync. Mocked so the route test can
// assert the hook without reaching the APNs senders or the coalescing
// timers.
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
import { DELETE } from "../route";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function deleteReq(): NextRequest {
return new NextRequest(
"http://localhost/api/medications/med-1/intake/purge",
{
method: "DELETE",
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
name: "Test",
} as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 7,
} as never);
});
describe("DELETE /api/medications/[id]/intake/purge", () => {
it("returns 200 with the deleted count", async () => {
const res = await DELETE(deleteReq(), ROUTE_PARAMS);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ purged: true, count: 7 });
// (#22) — the purge queues exactly ONE cross-device sync fan-out.
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
});
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
await DELETE(deleteReq(), ROUTE_PARAMS);
// v1.16.8 — the purge is an interactive write, so it hard-evicts the
// SWR buckets instead of marking them stale.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns the 404 from the shared ownership helper without invalidating", async () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
const res = await DELETE(deleteReq(), ROUTE_PARAMS);
expect(res.status).toBe(404);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(invalidateUserMedications).not.toHaveBeenCalled();
});
// The purge was the one destructive verb in the intake family that removed
// rows outright: a mis-tap was final, and a client offline at purge time
// kept every row forever because a row that no longer exists produces no
// tombstone. Assert the CALL SHAPE rather than a count — a count comes back
// identical from a `deleteMany`, which is exactly the regression this pins.
it("tombstones the events instead of removing them", async () => {
await DELETE(deleteReq(), ROUTE_PARAMS);
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
// Scoped to the caller's own rows for this medication, and to LIVE rows
// only, so a second purge counts zero rather than re-stamping tombstones
// and re-bumping every syncVersion the clients have already seen.
expect(call.where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
});
// A tombstone the sync feed can carry: a stamp plus a monotonic bump.
expect(call.data.deletedAt).toBeInstanceOf(Date);
expect(call.data.syncVersion).toEqual({ increment: 1 });
});
});
@@ -1,355 +0,0 @@
/**
* v1.4.43 — multi-issue 422 envelope on PATCH
* /api/medications/[id]/inventory/[itemId].
*
* v1.16.1 — stock-correction contract: `unitsRemaining` sets the count
* absolutely, clamps to `unitsTotal`, and the canonical state machine
* derives the next state (0 ⇒ USED_UP).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => {
// v1.32.22 — the PATCH / DELETE now run inside `prisma.$transaction`
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as the tx, so the existing model mocks apply
// inside the transaction and `$queryRaw` (the advisory lock) is observable.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/service", () => ({
computeExpiresAt: vi.fn().mockReturnValue(null),
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
// v1.16.12 — the route serialises its Decimal unit columns to numbers
// on the way out; a passthrough keeps these update-logic assertions
// focused on the Prisma call, not the response shape.
serializeInventoryItem: <T>(item: T) => item,
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const prismaTx = prisma as unknown as {
$queryRaw: ReturnType<typeof vi.fn>;
$transaction: ReturnType<typeof vi.fn>;
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/m1/inventory/i1", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = {
params: Promise.resolve({ id: "m1", itemId: "i1" }),
};
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it so
// the callback still runs with the base client as the tx.
prismaTx.$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "ACTIVE",
unitsRemaining: 4,
printedExpiry: null,
} as never);
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `markAsFirstUseAt` iso + bad `markAsUsedUp` (not boolean).
const res = await PATCH(
patchReq({ markAsFirstUseAt: "not-iso", markAsUsedUp: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await PATCH(
patchReq({
markAsFirstUseAt: "not-iso",
markAsUsedUp: "string",
printedExpiry: "also-bad",
notes: 123,
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stock correction (v1.16.1)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
});
it("sets the remaining count absolutely", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
expect(update.data.unitsRemaining).toBe(1);
});
it("derives USED_UP when corrected to zero", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as unknown as {
data: { unitsRemaining: number; state: string };
};
expect(update.data.unitsRemaining).toBe(0);
expect(update.data.state).toBe("USED_UP");
});
it("clamps a raise above the item's capacity to unitsTotal", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 99 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
expect(update.data.unitsRemaining).toBe(4);
});
it("rejects a negative correction with 422", async () => {
const res = await PATCH(patchReq({ unitsRemaining: -1 }), ROUTE_CTX);
expect(res.status).toBe(422);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
it("updates manufacturer + doseStrength when supplied", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(
patchReq({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBe("Example Pharma");
expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
});
it("leaves both untouched when the request omits them", async () => {
// Absent must mean "I have nothing to say", never "blank it" — a client
// that does not know the fields must not wipe what another populated.
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect("manufacturer" in arg.data).toBe(false);
expect("doseStrength" in arg.data).toBe(false);
});
it("clears a value on an explicit null", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBeNull();
expect("doseStrength" in arg.data).toBe(false);
});
});
/**
* v1.32.22 — the stock-correction PATCH and the DELETE route their
* read-compose-write through the SAME per-medication advisory lock that
* `consumeForIntake` takes, so an absolute stock write can no longer clobber a
* concurrent dose decrement. These pin the routing: the write runs inside a
* transaction that takes the advisory lock BEFORE it reads + writes.
*/
describe("PATCH / DELETE inventory — advisory-lock serialisation (M5)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "i1",
} as never);
});
it("takes the medication advisory lock inside a transaction before the write", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
// One transaction, one advisory-lock query.
expect(prismaTx.$transaction).toHaveBeenCalledTimes(1);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
// The lock SQL is the canonical `pg_advisory_xact_lock` — proves the route
// shares the intake hook's key rather than re-deriving one.
const sql = prismaTx.$queryRaw.mock.calls[0]?.[0] as {
strings?: string[];
};
expect((sql.strings ?? []).join("")).toContain("pg_advisory_xact_lock");
// Lock is acquired BEFORE the row update (serialises against a concurrent
// consume decrement).
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const updateOrder = vi.mocked(prisma.medicationInventoryItem.update).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(updateOrder);
});
it("re-reads the row INSIDE the locked transaction", async () => {
await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
// The ownership re-read runs after the lock is taken.
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const readOrder = vi.mocked(prisma.medicationInventoryItem.findUnique).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(readOrder);
});
it("DELETE takes the same lock before deleting", async () => {
const res = await DELETE(patchReq({}), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const deleteOrder = vi.mocked(prisma.medicationInventoryItem.delete).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(deleteOrder);
});
it("still 404s an item owned by another user (checked inside the lock)", async () => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "someone-else",
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
firstUseAt: null,
notes: null,
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(404);
expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled();
});
});
@@ -1,625 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => {
// v1.32.22 — the [itemId] PATCH / DELETE now run inside a transaction
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as tx, and `$queryRaw` stands in for the
// advisory-lock query.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it.
(
prisma as unknown as { $transaction: ReturnType<typeof vi.fn> }
).$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/inventory", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the inventory list for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "ACTIVE" },
{ id: "inv-2", state: "IN_USE" },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
// v1.19.0 (iOS#25) — the GET response carries a server-computed supply
// summary so the detail-page clients render the canonical Bestand
// instead of re-deriving it. ACTIVE / IN_USE with units pool in;
// EXPIRED surfaces separately and never counts as available.
it("returns a server-computed supply summary (dose-derived, expired separate)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
unitsPerDose: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
{ id: "inv-2", state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
{ id: "inv-3", state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
{ id: "inv-4", state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
summary: {
unitsRemaining: number;
unitsTotal: number;
dosesRemaining: number;
dosesTotal: number;
expiredUnits: number;
};
};
};
// 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses;
// 4 units sit in the expired container (visible, never available).
expect(body.data.summary).toEqual({
unitsRemaining: 7,
unitsTotal: 8,
dosesRemaining: 3,
dosesTotal: 4,
expiredUnits: 4,
});
});
});
describe("POST /api/medications/[id]/inventory", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when unitsTotal is missing or invalid", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 0,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("creates an ACTIVE pen with computed expiresAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
// Now-anchored so the fixture can never age into a past expiry.
const printed = new Date(
Date.now() + 300 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
printedExpiry: printed,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE",
containerType: "OTHER",
unitsTotal: 4,
unitsRemaining: 4,
firstUseAt: null,
printedExpiry: new Date(printed),
expiresAt: new Date(printed),
}),
});
// Staleness regression: the dose-derived stock the medications-list
// payload carries must hard-evict on a registration so the card / table
// reflect the new supply on the very next read (not after the SWR
// stale window). Mark-stale would serve the pre-write stock.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
const existingActive = {
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE" as const,
// A pen — the container kind that carries the post-opening clock.
containerType: "PEN" as const,
unitsTotal: 4,
unitsRemaining: 4,
firstUseAt: null,
printedExpiry: null,
purchasedAt: null,
notes: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("flips ACTIVE → IN_USE on markAsFirstUseAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
// Anchor first-use to one day ago so the 30-day in-use window is
// always open relative to "now" — a previously hardcoded date
// (2026-05-14) silently aged past its window and flipped the expected
// state to EXPIRED once the wall clock crossed firstUseAt + 30 days.
const intake = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: intake },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(intake),
});
});
it("flips state to USED_UP and zeros remaining on markAsUsedUp", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date("2026-05-01"),
unitsRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "USED_UP",
unitsRemaining: 0,
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "USED_UP",
unitsRemaining: 0,
});
// Staleness regression: a stock-affecting correction must hard-evict
// the medications-list bucket so the card reflects the change next read.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
// Regression for the W19b state-machine bypass: composing the next
// state by hand sent ACTIVE → IN_USE on any first-use stamp, but a
// back-dated stamp whose 30-day window already lapsed must land at
// EXPIRED. Re-running `computeInventoryState` after the composed
// view closes the gap.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "EXPIRED",
} as never);
// 45 days in the past — well past the 30-day in-use window.
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "EXPIRED",
firstUseAt: new Date(backdated),
});
});
it("leaves a back-dated first use on a blister IN_USE with no expiresAt", async () => {
// The same back-dated stamp on a tablet pack must not write the
// remaining tablets off: a blister carries no post-opening clock,
// so the only deadline it could have is a printed one, and this row
// has none.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
containerType: "BLISTER",
unitsTotal: 60,
unitsRemaining: 54,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(backdated),
expiresAt: null,
});
});
it("clears the opening date when markAsFirstUseAt is null", async () => {
// The correction path for a container the consumption hook
// auto-opened by mistake: unsetting the date returns the row to
// ACTIVE and drops any in-use deadline with it.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000),
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "ACTIVE",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: null },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "ACTIVE",
firstUseAt: null,
expiresAt: null,
});
});
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
userId: "OTHER",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
it("hard-deletes the row and audit-logs the final state", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "EXPIRED",
unitsRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "inv-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/inventory/inv-1",
{
method: "DELETE",
},
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
// Staleness regression: removing a container drops the dose-derived
// stock the list payload reports — hard-evict so it reflects next read.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
});
describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: "string",
printedExpiry: "not-iso",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: "string",
printedExpiry: "not-iso",
purchasedAt: "also-not-iso",
notes: 123,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("POST /api/medications/[id]/inventory — carton labelling", () => {
it("persists manufacturer + doseStrength when the client sends them", async () => {
// The native pen list uses these two as its headline and subhead. With
// nowhere on the server to hold them, a container registered on the web
// could not be rendered there at all.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
containerType: "PEN",
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
});
});
it("stores null for both when the client omits them", async () => {
// A plain supply row (blister, bottle) carries no carton labelling, and
// every row that predates the columns is truthfully null.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 30,
containerType: "BLISTER",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: null,
doseStrength: null,
}),
});
});
});
@@ -1,163 +0,0 @@
/**
* v1.5.5 — phase-config PUT now returns the multi-issue 422
* envelope rather than the flat `400 Invalid input`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
reminderPhaseConfig: { upsert: vi.fn() },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function putReq(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/medications/med-1/phase-config",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(prisma.reminderPhaseConfig.upsert).mockResolvedValue({
medicationId: "med-1",
} as never);
});
describe("PUT /api/medications/[id]/phase-config — F-1 H-3 multi-issue 422", () => {
it("returns the multi-issue envelope on a malformed body", async () => {
// Three issues: greenValue not int, greenMode not enum, redValue overflow.
const res = await PUT(
putReq({
greenValue: "not-a-number",
greenMode: "BOGUS",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 10_000,
redMode: "MINUTES",
}),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
});
describe("PUT /api/medications/[id]/phase-config — F-1 H-4 field-by-field upsert", () => {
it("builds the create + update payloads field-by-field rather than spreading parsed.data", async () => {
const body = {
greenValue: 60,
greenMode: "MINUTES",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 240,
redMode: "MINUTES",
};
await PUT(putReq(body), ROUTE_PARAMS);
const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock
.calls[0][0];
// create payload contains exactly the eight enumerated phase fields
// plus the `medicationId` route binding — nothing else.
expect(Object.keys(callArg.create).sort()).toEqual([
"greenMode",
"greenValue",
"medicationId",
"orangeMode",
"orangeValue",
"redMode",
"redValue",
"yellowMode",
"yellowValue",
]);
// update payload mirrors the eight phase fields (no `medicationId`).
expect(Object.keys(callArg.update).sort()).toEqual([
"greenMode",
"greenValue",
"orangeMode",
"orangeValue",
"redMode",
"redValue",
"yellowMode",
"yellowValue",
]);
});
it("ignores an unknown field smuggled in the body (no mass assignment)", async () => {
const body = {
greenValue: 60,
greenMode: "MINUTES",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 240,
redMode: "MINUTES",
// Unknown rogue field — should never reach the Prisma upsert.
maliciousField: "owned",
};
await PUT(putReq(body), ROUTE_PARAMS);
const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock
.calls[0][0];
expect(
(callArg.create as Record<string, unknown>).maliciousField,
).toBeUndefined();
expect(
(callArg.update as Record<string, unknown>).maliciousField,
).toBeUndefined();
});
});
@@ -1,447 +0,0 @@
/**
* v1.4.25 — side-effect API route tests.
*
* Mirrors the inventory route test fixture pattern: every external
* dependency is mocked at module boundaries (Prisma, session, audit,
* rate-limit, logging transport), and each describe block exercises
* one HTTP verb's contract.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationSideEffect: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
// The POST path encrypts the free-text note at rest; drive the legacy single
// key so encryptNote has material to work with.
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/side-effects", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the logs for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue([
{ id: "se-1", entry: "NAUSEA", severity: 2 },
{ id: "se-2", entry: "DIARRHEA", severity: 3 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
it("applies from / to / limit query params", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue(
[] as never,
);
const from = "2026-05-01T00:00:00Z";
const to = "2026-05-14T00:00:00Z";
await GET(
new NextRequest(
`http://localhost/api/medications/med-1/side-effects?from=${from}&to=${to}&limit=10`,
),
{ params: Promise.resolve({ id: "med-1" }) },
);
const call = vi.mocked(prisma.medicationSideEffect.findMany).mock
.calls[0][0];
expect(call).toMatchObject({
where: {
userId: "user-1",
medicationId: "med-1",
occurredAt: { gte: new Date(from), lt: new Date(to) },
},
// One row past the limit, so the response can say it was cut.
take: 11,
});
});
});
describe("POST /api/medications/[id]/side-effects", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when severity is out of range", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 7,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => {
// Client maliciously sends category=INJECTION_SITE for a
// NAUSEA entry. The server now derives the canonical category
// from `entry`, so the row lands with category="GI" regardless
// of the client claim. Backwards-compatible with older clients
// that still send `category`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "INJECTION_SITE",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as { data: { category: string } };
expect(callArgs.data.category).toBe("GI");
});
it("creates a row with the server-derived category", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const occurredAt = "2026-05-14T08:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt,
notes: "after breakfast",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as {
data: {
userId: string;
medicationId: string;
category: string;
entry: string;
severity: number;
occurredAt: Date;
notes: string | null;
notesEncrypted: Uint8Array | null;
};
};
expect(createArgs.data).toMatchObject({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
// v1.25 — the plaintext column is nulled; the note lands encrypted.
notes: null,
});
expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array);
expect(readNote(createArgs.data.notesEncrypted, null)).toBe(
"after breakfast",
);
});
it("defaults occurredAt to now when omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
} as never);
const before = Date.now();
await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GLP1_SPECIFIC",
entry: "EARLY_SATIETY",
severity: 3,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
const after = Date.now();
const call = vi.mocked(prisma.medicationSideEffect.create).mock.calls[0][0];
const created = call.data.occurredAt as Date;
expect(created.getTime()).toBeGreaterThanOrEqual(before);
expect(created.getTime()).toBeLessThanOrEqual(after);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
it("returns 401 unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "OTHER",
medicationId: "med-1",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(),
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(404);
});
it("hard-deletes the row and audit-logs entry + severity", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "user-1",
medicationId: "med-1",
entry: "NAUSEA",
severity: 3,
occurredAt: new Date("2026-05-14T08:00:00Z"),
} as never);
vi.mocked(prisma.medicationSideEffect.delete).mockResolvedValue({
id: "se-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationSideEffect.delete).toHaveBeenCalledWith({
where: { id: "se-1" },
});
});
});
describe("/api/medications/[id]/side-effects — 422 multi-issue (v1.4.43 W6)", () => {
it("GET surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// Bad `from` iso + bad `limit` (out of bounds).
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects?from=not-iso&limit=99999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// Missing `entry` + bad `severity`.
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
severity: "junk",
notes: "x".repeat(2000),
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("POST surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
entry: 999,
severity: "junk",
occurredAt: "not-iso",
notes: "x".repeat(5000),
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -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();
});
});