diff --git a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts deleted file mode 100644 index aa4146750..000000000 --- a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts +++ /dev/null @@ -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"]); - } - }); -}); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts deleted file mode 100644 index a42c9fa5b..000000000 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts deleted file mode 100644 index 786f4687b..000000000 --- a/src/app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts +++ /dev/null @@ -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"); - }, - ); -}); diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts deleted file mode 100644 index 06e1dd824..000000000 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ /dev/null @@ -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; - data: Record; - }; - - // 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 }); - }); -}); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts deleted file mode 100644 index 7e704b957..000000000 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ /dev/null @@ -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 = { - 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: (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; - $transaction: ReturnType; -}; - -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 }; - }; - 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 }) => ({ - 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; - }; - 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; - }; - 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; - }; - 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 }) => ({ - 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(); - }); -}); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts deleted file mode 100644 index eb7ea16b9..000000000 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ /dev/null @@ -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 = { - 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 } - ).$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 }; - }; - 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, - }), - }); - }); -}); diff --git a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts deleted file mode 100644 index b2533196e..000000000 --- a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts +++ /dev/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).maliciousField, - ).toBeUndefined(); - expect( - (callArg.update as Record).maliciousField, - ).toBeUndefined(); - }); -}); diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts deleted file mode 100644 index c0b955928..000000000 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ /dev/null @@ -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 }; - }; - expect(body.details.issues.length).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/tests/integration/routes/medication-cadence.test.ts b/tests/integration/routes/medication-cadence.test.ts new file mode 100644 index 000000000..94ea2b746 --- /dev/null +++ b/tests/integration/routes/medication-cadence.test.ts @@ -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> }; +} + +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(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(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(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); + }); +}); diff --git a/tests/integration/routes/medication-intake-bulk-delete.test.ts b/tests/integration/routes/medication-intake-bulk-delete.test.ts new file mode 100644 index 000000000..3c6e5659f --- /dev/null +++ b/tests/integration/routes/medication-intake-bulk-delete.test.ts @@ -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(); + }); +}); diff --git a/tests/integration/routes/medication-intake-import-status.test.ts b/tests/integration/routes/medication-intake-import-status.test.ts new file mode 100644 index 000000000..4b031d22c --- /dev/null +++ b/tests/integration/routes/medication-intake-import-status.test.ts @@ -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, + Partial, + ] +> = [ + [ + "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(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(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); + }); +}); diff --git a/tests/integration/routes/medication-intake-purge.test.ts b/tests/integration/routes/medication-intake-purge.test.ts new file mode 100644 index 000000000..98e4e3e97 --- /dev/null +++ b/tests/integration/routes/medication-intake-purge.test.ts @@ -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 { + const res = await callRoute>( + 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(); + }); +}); diff --git a/tests/integration/routes/medication-inventory-item.test.ts b/tests/integration/routes/medication-inventory-item.test.ts new file mode 100644 index 000000000..d3ee41ad1 --- /dev/null +++ b/tests/integration/routes/medication-inventory-item.test.ts @@ -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> }; +} + +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 { + 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(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 = async () => {}, + ) { + let release = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + let signalHeld = () => {}; + const held = new Promise((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): Promise { + 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, + }); + }); +}); diff --git a/tests/integration/routes/medication-inventory.test.ts b/tests/integration/routes/medication-inventory.test.ts new file mode 100644 index 000000000..ba9f54407 --- /dev/null +++ b/tests/integration/routes/medication-inventory.test.ts @@ -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> }; +} + +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 { + 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(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(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(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(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); + }); +}); diff --git a/tests/integration/routes/medication-phase-config.test.ts b/tests/integration/routes/medication-phase-config.test.ts new file mode 100644 index 000000000..9c19e5ff4 --- /dev/null +++ b/tests/integration/routes/medication-phase-config.test.ts @@ -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> }; +} + +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(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(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(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(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(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); + }); +}); diff --git a/tests/integration/routes/medication-side-effects.test.ts b/tests/integration/routes/medication-side-effects.test.ts new file mode 100644 index 000000000..2db5b2d9b --- /dev/null +++ b/tests/integration/routes/medication-side-effects.test.ts @@ -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> }; +} + +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(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(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(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(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(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(); + }); +});