diff --git a/src/__tests__/admin-report-feedback.test.ts b/src/__tests__/admin-report-feedback.test.ts new file mode 100644 index 0000000..b7ebc79 --- /dev/null +++ b/src/__tests__/admin-report-feedback.test.ts @@ -0,0 +1,153 @@ +/** + * found.md: Rückmeldung an den Melder per Team-Nachricht — Begründung bei + * abgewiesenem Report, Dank bei bestätigter Löschung. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "admin-1" }), + clerkClient: vi.fn(), +})); +vi.mock("@/lib/supabase-storage", () => ({ + createSignedUploadUrl: vi.fn(), + ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"], + MAX_FILE_SIZE: 10 * 1024 * 1024, + MUSIC_STORAGE_BUCKET: "music-tracks", + MUSIC_ALLOWED_CONTENT_TYPES: ["audio/mpeg", "audio/mp4", "audio/aac"], + MAX_MUSIC_FILE_SIZE: 5 * 1024 * 1024, +})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); +vi.mock("@/lib/gdpr-export", () => ({ buildDataExport: vi.fn() })); +vi.mock("@/lib/data-export-storage", () => ({ uploadExport: vi.fn() })); +vi.mock("@/lib/mail", () => ({ sendMail: vi.fn() })); +vi.mock("@/lib/maintenance", () => ({ isMaintenanceModeEnabled: vi.fn(), setMaintenanceMode: vi.fn() })); + +const { mockSendTeamMessage } = vi.hoisted(() => ({ mockSendTeamMessage: vi.fn() })); +vi.mock("@/lib/team-pet", () => ({ + ensureTeamPet: vi.fn(), + sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args), +})); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +async function createAdminCaller() { + const { adminRouter } = await import("@/trpc/routers/admin"); + const { initTRPC } = await import("@trpc/server"); + const mockPrisma = createMockPrisma(); + mockPrisma.$transaction.mockImplementation((arg: unknown) => Promise.all(arg as unknown[])); + const t = initTRPC.context<{ userId: string | null; prisma: typeof mockPrisma }>().create(); + const testRouter = t.router({ admin: adminRouter }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const caller = testRouter.createCaller({ userId: "admin-1", prisma: mockPrisma as any }); + return { caller, mockPrisma }; +} + +afterEach(() => { + vi.unstubAllEnvs(); + mockSendTeamMessage.mockReset(); +}); + +describe("admin.dismissReport reporter feedback", () => { + it("sends the reporter a team message with the reason, naming the reported post's pet", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null }); + mockPrisma.post.findUnique.mockResolvedValue({ pet: { name: "Bubi" } }); + mockSendTeamMessage.mockResolvedValue(undefined); + + await caller.admin.dismissReport({ reportId: "r1", message: "Das ist ein normales Spielvideo." }); + + expect(mockSendTeamMessage).toHaveBeenCalledTimes(1); + const [, petId, body] = mockSendTeamMessage.mock.calls[0]; + expect(petId).toBe("reporter-1"); + expect(body).toContain("den Beitrag von Bubi"); + expect(body).toContain("keinen Verstoß"); + expect(body).toContain("Begründung: Das ist ein normales Spielvideo."); + expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ reason: "Das ist ein normales Spielvideo." }) }) + ); + }); + + it("names the profile for a pet report and omits an empty reason", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.report.findUnique.mockResolvedValue({ id: "r2", reporterPetId: "reporter-1", targetPostId: null, targetPetId: "pet-9" }); + mockPrisma.pet.findUnique.mockResolvedValue({ name: "Luna" }); + mockSendTeamMessage.mockResolvedValue(undefined); + + await caller.admin.dismissReport({ reportId: "r2" }); + + const body = mockSendTeamMessage.mock.calls[0][2] as string; + expect(body).toContain("das Profil von Luna"); + expect(body).not.toContain("Begründung"); + }); + + it("sends nothing when notifyReporter is false", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: null }); + + await caller.admin.dismissReport({ reportId: "r1", notifyReporter: false }); + + expect(mockSendTeamMessage).not.toHaveBeenCalled(); + }); + + it("still succeeds when the message fails", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: null }); + mockSendTeamMessage.mockRejectedValue(new Error("db down")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(caller.admin.dismissReport({ reportId: "r1" })).resolves.toEqual({ success: true }); + }); +}); + +describe("admin.deleteReportedPost reporter feedback", () => { + it("deletes the post's reports and thanks every reporter once", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } }); + mockPrisma.report.findMany.mockResolvedValue([ + { reporterPetId: "reporter-1" }, + { reporterPetId: "reporter-2" }, + { reporterPetId: "reporter-1" }, + ]); + mockSendTeamMessage.mockResolvedValue(undefined); + + await caller.admin.deleteReportedPost({ postId: "post-1", reason: "Gewaltdarstellung" }); + + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } }); + expect(mockSendTeamMessage.mock.calls.map((c) => c[1])).toEqual(["reporter-1", "reporter-2"]); + const body = mockSendTeamMessage.mock.calls[0][2] as string; + expect(body).toContain("den Beitrag von Bubi"); + expect(body).toContain("Inhalt entfernt"); + // Die interne Lösch-Begründung geht nicht an den Melder. + expect(body).not.toContain("Gewaltdarstellung"); + }); + + it("keeps notifying the other reporters when one message fails", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } }); + mockPrisma.report.findMany.mockResolvedValue([{ reporterPetId: "reporter-1" }, { reporterPetId: "reporter-2" }]); + mockSendTeamMessage.mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(caller.admin.deleteReportedPost({ postId: "post-1" })).resolves.toEqual({ success: true }); + expect(mockSendTeamMessage).toHaveBeenCalledTimes(2); + }); + + it("sends nothing when notifyReporters is false", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } }); + mockPrisma.report.findMany.mockResolvedValue([{ reporterPetId: "reporter-1" }]); + + await caller.admin.deleteReportedPost({ postId: "post-1", notifyReporters: false }); + + expect(mockSendTeamMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index d76f44e..eab54d7 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -198,7 +198,7 @@ describe("admin transactional moderation actions", () => { mockPrisma.report.delete.mockResolvedValue({ id: "report-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); - const result = await caller.admin.dismissReport({ reportId: "report-1" }); + const result = await caller.admin.dismissReport({ reportId: "report-1", notifyReporter: false }); expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.report.delete).toHaveBeenCalledWith({ where: { id: "report-1" } }); @@ -211,6 +211,7 @@ describe("admin transactional moderation actions", () => { mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); mockPrisma.post.delete.mockResolvedValue({ id: "post-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); + mockPrisma.report.findMany.mockResolvedValue([]); const result = await caller.admin.deleteReportedPost({ postId: "post-1" }); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index c0bab2a..2fcce12 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -143,6 +143,7 @@ interface MockReportClient { findMany: MockFn; findUnique: MockFn; delete: MockFn; + deleteMany: MockFn; count: MockFn; } @@ -536,6 +537,7 @@ export function createMockPrisma(): MockPrismaClient { findMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn(), + deleteMany: vi.fn(), count: vi.fn(), }, userBan: { diff --git a/src/app/p/[secret]/reports/page.tsx b/src/app/p/[secret]/reports/page.tsx index dc218d9..1a0a30c 100644 --- a/src/app/p/[secret]/reports/page.tsx +++ b/src/app/p/[secret]/reports/page.tsx @@ -8,6 +8,8 @@ import { BlurImage } from "@/components/ui/blur-image"; import { Flag, Trash2, X, Loader2, CheckCheck, MessageCircle, Heart, ImageOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; @@ -37,6 +39,9 @@ export default function AdminReportsPage() { const qc = useQueryClient(); const [action, setAction] = useState(null); const [reason, setReason] = useState(""); + // found.md: Melder bekommt eine Team-Nachricht (Begründung bzw. Dank). + const [dismissMessage, setDismissMessage] = useState(""); + const [notifyReporter, setNotifyReporter] = useState(true); const [detailPostId, setDetailPostId] = useState(null); const { data: reports = [], isLoading } = useQuery(trpc.admin.listReports.queryOptions()); @@ -72,14 +77,20 @@ export default function AdminReportsPage() { function closeDialog() { setAction(null); setReason(""); + setDismissMessage(""); + setNotifyReporter(true); } function confirm() { if (!action) return; if (action.kind === "dismiss") { - dismiss.mutate({ reportId: action.reportId }); + dismiss.mutate({ + reportId: action.reportId, + notifyReporter, + message: dismissMessage.trim() || undefined, + }); } else { - deletePost.mutate({ postId: action.postId, reason: reason || undefined }); + deletePost.mutate({ postId: action.postId, reason: reason || undefined, notifyReporters: notifyReporter }); } } @@ -269,9 +280,20 @@ export default function AdminReportsPage() { {action?.kind === "dismiss" ? ( -

- Den Report als unbegründet schließen? Der Inhalt bleibt bestehen. -

+ <> +

+ Den Report als unbegründet schließen? Der Inhalt bleibt bestehen. +

+