mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(integration): move medication sub-route tests onto Postgres
Phase config, cadence, side effects, inventory, intake purge, bulk delete and the import status run through the real handlers against the database. The advisory-lock ordering the mocked tests read off call order is shown with a real concurrent transaction holding the inventory lock, cache eviction through the next list read with a control that the cached list would otherwise stay stale, and soft deletes on the tombstone and sync counter they leave.
This commit is contained in:
1 parent
43b86e46da
commit
bc3522aabb
16 files changed
+2850
-2346
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user