diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 3974969..6a39226 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -28,6 +28,21 @@ vi.mock("@/lib/supabase-storage", () => ({ })); vi.mock("@/lib/prisma", () => ({ prisma: {} })); +const mockBuildDataExport = vi.fn(); +vi.mock("@/lib/gdpr-export", () => ({ + buildDataExport: (...args: unknown[]) => mockBuildDataExport(...args), +})); + +const mockUploadExport = vi.fn(); +vi.mock("@/lib/data-export-storage", () => ({ + uploadExportAndGetSignedUrl: (...args: unknown[]) => mockUploadExport(...args), +})); + +const mockSendMail = vi.fn(); +vi.mock("@/lib/mail", () => ({ + sendMail: (...args: unknown[]) => mockSendMail(...args), +})); + import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; async function createAdminCaller(overrides?: { @@ -59,6 +74,9 @@ async function createAdminCaller(overrides?: { afterEach(() => { vi.unstubAllEnvs(); + mockBuildDataExport.mockReset(); + mockUploadExport.mockReset(); + mockSendMail.mockReset(); }); describe("admin.deleteComment", () => { @@ -339,3 +357,67 @@ describe("admin.startTeamConversation", () => { ).rejects.toThrow(TRPCError); }); }); + +describe("admin.exportUserData", () => { + it("uploads the export, emails the signed link, and logs a moderation entry", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockBuildDataExport.mockResolvedValue({ + account: { clerkProfile: { email: "owner4@example.com" } }, + }); + mockUploadExport.mockResolvedValue("https://signed.example.com/export.json"); + mockSendMail.mockResolvedValue(undefined); + mockPrisma.moderationLog.create.mockResolvedValue({}); + + const result = await caller.admin.exportUserData({ ownerId: "owner-4" }); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: "owner4@example.com" }) + ); + expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "DATA_EXPORT", + targetType: "USER", + targetId: "owner-4", + }), + }) + ); + expect(result).toEqual({ success: true, sentTo: "owner4@example.com" }); + }); + + it("throws NOT_FOUND when the owner does not exist", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller } = await createAdminCaller(); + + mockBuildDataExport.mockResolvedValue(null); + + await expect( + caller.admin.exportUserData({ ownerId: "missing-owner" }) + ).rejects.toThrow(TRPCError); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("throws PRECONDITION_FAILED when the owner has no Clerk email on file", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller } = await createAdminCaller(); + + mockBuildDataExport.mockResolvedValue({ account: { clerkProfile: null } }); + + await expect( + caller.admin.exportUserData({ ownerId: "owner-5" }) + ).rejects.toThrow(TRPCError); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN for a non-admin caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); + const { caller } = await createAdminCaller(); + + await expect( + caller.admin.exportUserData({ ownerId: "owner-4" }) + ).rejects.toThrow(TRPCError); + expect(mockBuildDataExport).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index 1891f6d..f9cf852 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -2,10 +2,11 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useActivePet } from "@/context/ActivePetContext"; -import { ShieldOff, ShieldCheck, Loader2, ChevronDown, MessageCircle } from "lucide-react"; +import { ShieldOff, ShieldCheck, Loader2, ChevronDown, MessageCircle, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; @@ -59,6 +60,17 @@ export default function AdminUsersPage() { }) ); + const exportDataMutation = useMutation( + trpc.admin.exportUserData.mutationOptions({ + onSuccess: ({ sentTo }) => { + toast.success(`Datenauskunft an ${sentTo} verschickt.`); + }, + onError: (err) => { + toast.error(`Export fehlgeschlagen: ${err.message}`); + }, + }) + ); + return (

Users

@@ -120,6 +132,20 @@ export default function AdminUsersPage() { )} )} + {user.ban ? (