From 043c335ba3b67e8383125f8d3fff998a6838a970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 29 May 2026 00:29:44 +0200 Subject: [PATCH] fix(meds): purge route invalidates the user-medication caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tier-3a Verlauf-löschen cascade dropped the rollup rows but left the analytics / iOS today-tally caches with the pre-purge counts for up to their TTL. Add invalidateUserMedications to the success path so every downstream reader sees the fresh state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/intake/purge/__tests__/route.test.ts | 104 ++++++++++++++++++ .../medications/[id]/intake/purge/route.ts | 8 ++ 2 files changed, 112 insertions(+) create mode 100644 src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts 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 new file mode 100644 index 000000000..7328792f0 --- /dev/null +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -0,0 +1,104 @@ +/** + * v1.5.5 F-1 C-3 — purge route fires the medication cache bundle. + * + * Pins the contract the detail-page Verlauf-löschen cascade depends + * on: dropping the rollup rows is necessary but not sufficient. The + * analytics + iOS today-tally + dashboard tiles all read off cached + * shapes that survive the rollup delete 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: { deleteMany: vi.fn() }, + medicationComplianceRollup: { deleteMany: vi.fn() }, + }, +})); + +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: () => {}, + })), +})); + +import { DELETE } from "../route"; +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.deleteMany).mockResolvedValue({ + count: 7, + } as never); + vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({ + count: 5, + } 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 }); + }); + + it("invalidates the user medication caches on success (F-1 C-3)", async () => { + await DELETE(deleteReq(), ROUTE_PARAMS); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1"); + }); + + 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.deleteMany).not.toHaveBeenCalled(); + expect(invalidateUserMedications).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 496dcd0f0..61be38f6d 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -4,6 +4,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -58,6 +59,13 @@ export const DELETE = apiHandler( meta: { purged_count: count }, }); + // v1.5.5 F-1 C-3 — drop the per-user medication caches so the + // analytics + iOS today-tally + dashboard tiles converge on the + // post-purge counts rather than reading stale values for the + // TTL of each cache. Matches the sibling routes (POST intake, + // PUT medication, bulk-delete) that all fire this bundle. + invalidateUserMedications(user.id); + return apiSuccess({ purged: true, count }); }, );