/** * Tests for admin users management — db/adminUsers + endpoints. * * Covers: * - listAdminUsers: pagination cursor + plan-filter + search * - updateAdminUser: plan-validation + ban-stamping + voice * - softDeleteAdminUser: PII-scrubbing + idempotency * - GET endpoint: 401 ohne admin-secret * - PATCH endpoint: 401 ohne admin-secret + happy path * - DELETE endpoint: 401 + happy path */ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; // Snapshot der globalen Nitro-Stubs (siehe tests/setup.ts) damit wir nach // Endpoint-Tests die Originale wiederherstellen können — sonst leakt // `getHeader`-mock auf andere Test-Files (singleFork-Pool). const g = globalThis as Record; const originalStubs = { getHeader: g.getHeader, getQuery: g.getQuery, getRouterParam: g.getRouterParam, readBody: g.readBody, useRuntimeConfig: g.useRuntimeConfig, }; // ─── Prisma mock ───────────────────────────────────────────────────────────── const prismaMock = vi.hoisted(() => ({ profile: { findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn(), }, })); vi.mock("../../server/utils/prisma", () => ({ usePrisma: () => prismaMock, })); import { listAdminUsers, updateAdminUser, softDeleteAdminUser, } from "../../server/db/adminUsers"; beforeEach(() => { vi.clearAllMocks(); // useRuntimeConfig stub gibt adminSecret für endpoint-tests g.useRuntimeConfig = vi.fn(() => ({ adminSecret: "test-secret", public: { supabase: { url: "", key: "" } }, })); }); afterEach(() => { // Globale Nitro-Stubs zurücksetzen — sonst leakt getHeader-mock auf // andere Test-Files (singleFork-Pool teilt sich Modul-Globals). for (const [k, v] of Object.entries(originalStubs)) { g[k] = v; } }); // ─── listAdminUsers ────────────────────────────────────────────────────────── describe("listAdminUsers — pagination + nextCursor", () => { it("returns nextCursor when more rows exist (limit+1 fetched)", async () => { // Simuliere 3 rows bei limit=2 → over-fetch ist 3, also nextCursor = items[1].id const fakeRows = [ makeRow("aaa", { plan: "free" }), makeRow("bbb", { plan: "pro" }), makeRow("ccc", { plan: "free" }), ]; prismaMock.profile.findMany.mockResolvedValueOnce(fakeRows); const result = await listAdminUsers({ limit: 2 }); expect(result.items).toHaveLength(2); expect(result.items.map((r) => r.id)).toEqual(["aaa", "bbb"]); expect(result.nextCursor).toBe("bbb"); expect(prismaMock.profile.findMany).toHaveBeenCalledWith( expect.objectContaining({ take: 3, // limit + 1 where: expect.objectContaining({ deletedAt: null }), }), ); }); it("nextCursor is null when no more rows", async () => { prismaMock.profile.findMany.mockResolvedValueOnce([ makeRow("only", { plan: "legend" }), ]); const result = await listAdminUsers({ limit: 50 }); expect(result.nextCursor).toBeNull(); expect(result.items).toHaveLength(1); }); it("applies plan-filter + search-term to where-clause", async () => { prismaMock.profile.findMany.mockResolvedValueOnce([]); await listAdminUsers({ plan: "pro", q: "Chahine" }); const callArgs = prismaMock.profile.findMany.mock.calls[0]![0]!; expect(callArgs.where.plan).toBe("pro"); expect(callArgs.where.OR).toEqual([ { nickname: { contains: "Chahine", mode: "insensitive" } }, { username: { contains: "Chahine", mode: "insensitive" } }, ]); }); }); // ─── updateAdminUser ───────────────────────────────────────────────────────── describe("updateAdminUser — validates plan + stamps bannedAt", () => { it("rejects unknown plan-values with 400", async () => { await expect( updateAdminUser("user-id", { plan: "enterprise" }), ).rejects.toMatchObject({ statusCode: 400 }); expect(prismaMock.profile.update).not.toHaveBeenCalled(); }); it("stamps bannedAt when banned=true and clears reason on un-ban", async () => { prismaMock.profile.update.mockResolvedValueOnce( makeRow("u1", { banned: true, bannedAt: new Date() }), ); await updateAdminUser("u1", { banned: true }); const dataArg = prismaMock.profile.update.mock.calls[0]![0]!.data; expect(dataArg.banned).toBe(true); expect(dataArg.bannedAt).toBeInstanceOf(Date); prismaMock.profile.update.mockResolvedValueOnce( makeRow("u1", { banned: false, bannedAt: null }), ); await updateAdminUser("u1", { banned: false }); const dataArg2 = prismaMock.profile.update.mock.calls[1]![0]!.data; expect(dataArg2.banned).toBe(false); expect(dataArg2.bannedAt).toBeNull(); expect(dataArg2.bannedReason).toBeNull(); }); it("rejects empty patch (no allowed fields → 400)", async () => { await expect(updateAdminUser("user-id", {})).rejects.toMatchObject({ statusCode: 400, }); }); }); // ─── softDeleteAdminUser ───────────────────────────────────────────────────── describe("softDeleteAdminUser — DSGVO PII-scrub + idempotent", () => { it("scrubs PII fields and stamps deletedAt", async () => { prismaMock.profile.findUnique.mockResolvedValueOnce({ deletedAt: null }); prismaMock.profile.update.mockResolvedValueOnce({}); const result = await softDeleteAdminUser( "128df360-2008-4d6f-8aa1-bdb41ec1362f", ); expect(result).toEqual({ ok: true, alreadyDeleted: false }); const updateCall = prismaMock.profile.update.mock.calls[0]![0]!; expect(updateCall.where.id).toBe("128df360-2008-4d6f-8aa1-bdb41ec1362f"); expect(updateCall.data.nickname).toBeNull(); expect(updateCall.data.avatar).toBeNull(); expect(updateCall.data.username).toMatch(/^deleted-[a-f0-9]{8}$/); expect(updateCall.data.birthYear).toBeNull(); expect(updateCall.data.gender).toBeNull(); expect(updateCall.data.bundesland).toBeNull(); expect(updateCall.data.stripeCustomerId).toBeNull(); expect(updateCall.data.deletedAt).toBeInstanceOf(Date); }); it("is idempotent — returns alreadyDeleted=true on re-run", async () => { prismaMock.profile.findUnique.mockResolvedValueOnce({ deletedAt: new Date("2026-01-01"), }); const result = await softDeleteAdminUser("user-id"); expect(result).toEqual({ ok: true, alreadyDeleted: true }); expect(prismaMock.profile.update).not.toHaveBeenCalled(); }); it("throws 404 if user not found", async () => { prismaMock.profile.findUnique.mockResolvedValueOnce(null); await expect(softDeleteAdminUser("ghost")).rejects.toMatchObject({ statusCode: 404, }); }); }); // ─── Endpoints — Auth-Guard ────────────────────────────────────────────────── describe("GET /api/admin/users — 401 ohne admin-secret", () => { it("rejects request without x-admin-secret header", async () => { (globalThis as Record).getHeader = vi.fn(() => undefined); (globalThis as Record).getQuery = vi.fn(() => ({})); const mod = await import("../../server/api/admin/users/index.get"); const handler = mod.default as (e: unknown) => Promise; await expect(handler({})).rejects.toMatchObject({ statusCode: 401 }); }); }); describe("PATCH /api/admin/users/[id] — 401 ohne admin-secret", () => { it("rejects request with wrong secret", async () => { (globalThis as Record).getHeader = vi.fn( () => "wrong-secret", ); (globalThis as Record).getRouterParam = vi.fn( () => "user-id", ); (globalThis as Record).readBody = vi.fn(async () => ({ banned: true, })); const mod = await import("../../server/api/admin/users/[id].patch"); const handler = mod.default as (e: unknown) => Promise; await expect(handler({})).rejects.toMatchObject({ statusCode: 401 }); }); }); describe("DELETE /api/admin/users/[id] — 401 ohne admin-secret", () => { it("rejects request without secret", async () => { (globalThis as Record).getHeader = vi.fn(() => undefined); (globalThis as Record).getRouterParam = vi.fn( () => "user-id", ); const mod = await import("../../server/api/admin/users/[id].delete"); const handler = mod.default as (e: unknown) => Promise; await expect(handler({})).rejects.toMatchObject({ statusCode: 401 }); }); }); // ─── Helpers ───────────────────────────────────────────────────────────────── function makeRow(id: string, overrides: Partial> = {}) { return { id, nickname: `nick-${id}`, username: `user-${id}`, avatar: null, plan: "free", streak: 0, banned: false, bannedAt: null, deletedAt: null, createdAt: new Date(), lyraVoiceId: null, premiumUntil: null, proTrialExpiresAt: null, ...overrides, }; }