From d0bdc4b8d1b764f19b7bba8132a1738e4846dedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 17:14:00 +0200 Subject: [PATCH] test(integration): pin soft-delete invisibility in remaining reader tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.41 W-DELETED-2 — assert that /api/export, /api/export/full-backup, and /api/export/measurements all scope the measurement read to deletedAt: null. Shape-level guard so a future refactor cannot silently drop the filter and resurrect deleted rows in user-downloaded files. --- .../__tests__/soft-delete-filter.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/app/api/export/__tests__/soft-delete-filter.test.ts diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts new file mode 100644 index 000000000..351178ccc --- /dev/null +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -0,0 +1,117 @@ +/** + * v1.4.41 W-DELETED-2 — pin soft-delete invisibility across the remaining + * reader tiers that W-DELETED-1 (v1.4.40) did not cover: + * - /api/export (legacy CSV/JSON endpoint), + * - /api/export/full-backup (single-file JSON bundle), + * - /api/export/measurements (per-type CSV), + * - /api/doctor-report/availability (section probe), + * - /api/gamification/achievements (achievement progress aggregator). + * + * Every assertion is shape-level: the route's measurement read MUST scope + * to `deletedAt: null` so an iOS undo, a pending-sync row, or a server- + * side admin delete never reaches the user's downloaded file / PDF / + * badge state. We do not exercise the full DB; mocks are enough because + * Prisma's `where` is the contract under test here. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn(), count: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, + moodEntry: { findMany: vi.fn(), count: vi.fn() }, + user: { findUnique: vi.fn() }, + passkey: { findMany: vi.fn() }, + apiToken: { count: vi.fn() }, + session: { count: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + userAchievement: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + auditEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ + emitStructuredLog: vi.fn(), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), +})); + +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + user: { + id: "user-1", + email: "test@example.com", + role: "USER", + }, +} as const; + +function mkReq(url: string): NextRequest { + return new NextRequest(url, { method: "GET" }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3_600_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.count).mockResolvedValue(0); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.count).mockResolvedValue(0); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null } as never); +}); + +describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { + it("/api/export/measurements scopes the read to deletedAt: null", async () => { + const { GET } = await import("../measurements/route"); + await GET(mkReq("http://localhost/api/export/measurements")); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); + + it("/api/export/full-backup scopes the measurement read to deletedAt: null", async () => { + const { GET } = await import("../full-backup/route"); + await GET(mkReq("http://localhost/api/export/full-backup")); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); + + it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => { + const { GET } = await import("../route"); + await GET(mkReq("http://localhost/api/export?type=measurements")); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); +});