/** * Admin Router Unit Tests — deleteComment (Cluster A counter fix) * * admin.ts had zero test coverage before this change. Scope is limited to * deleteComment, which previously deleted a Comment without ever decrementing * the parent Post's commentCount — a real gap fixed alongside the Post * denormalized-counter rollout (see comments.ts / reactions.ts for the same * pattern on the user-facing mutation paths). * * Tests: * - deleteComment decrements Post.commentCount and writes a moderation log * - deleteComment throws FORBIDDEN for a non-admin caller */ import { describe, it, expect, vi, afterEach } from "vitest"; import { TRPCError } from "@trpc/server"; vi.mock("server-only", () => ({})); const mockGetUserList = vi.fn(); vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "admin-1" }), clerkClient: vi.fn().mockResolvedValue({ users: { getUserList: mockGetUserList } }), })); vi.mock("@/lib/supabase-storage", () => ({ createSignedUploadUrl: vi.fn(), ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"], MAX_FILE_SIZE: 10 * 1024 * 1024, })); 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", () => ({ uploadExport: (...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?: { userId?: string | null; prisma?: ReturnType; }) { const { adminRouter } = await import("@/trpc/routers/admin"); const { initTRPC } = await import("@trpc/server"); const mockPrisma = overrides?.prisma ?? createMockPrisma(); const userId = overrides?.userId !== undefined ? overrides.userId : "admin-1"; // deleteComment uses the interactive-transaction callback form; other sites // in this codebase use the array form — support both shapes here. mockPrisma.$transaction.mockImplementation((arg: unknown) => typeof arg === "function" ? (arg as (tx: typeof mockPrisma) => unknown)(mockPrisma) : 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, prisma: mockPrisma as any }); return { caller, mockPrisma }; } afterEach(() => { vi.unstubAllEnvs(); mockBuildDataExport.mockReset(); mockUploadExport.mockReset(); mockSendMail.mockReset(); }); describe("admin.deleteComment", () => { it("decrements Post.commentCount and writes a moderation log", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.comment.delete.mockResolvedValue({ id: "cmt-1", postId: "post-1" }); mockPrisma.post.update.mockResolvedValue({ id: "post-1", commentCount: 4 }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.deleteComment({ commentId: "cmt-1", reason: "spam" }); expect(mockPrisma.comment.delete).toHaveBeenCalledWith({ where: { id: "cmt-1" } }); expect(mockPrisma.post.update).toHaveBeenCalledWith({ where: { id: "post-1" }, data: { commentCount: { decrement: 1 } }, }); expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "DELETE_COMMENT", targetId: "cmt-1" }), }) ); expect(result).toEqual({ success: true }); }); it("throws FORBIDDEN for a non-admin caller", async () => { vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.adminRole.findUnique.mockResolvedValue(null); await expect( caller.admin.deleteComment({ commentId: "cmt-1" }) ).rejects.toThrow(TRPCError); expect(mockPrisma.comment.delete).not.toHaveBeenCalled(); }); }); // Cluster H: deletePost/banUser/unbanUser/dismissReport/deleteReportedPost // now wrap the mutation + moderation-log write in $transaction so a failed // mutation can never leave behind a log entry describing an action that // didn't actually happen. describe("admin transactional moderation actions", () => { it("deletePost wraps delete + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); mockPrisma.post.delete.mockResolvedValue({ id: "post-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.deletePost({ postId: "post-1", reason: "spam" }); expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" } }); expect(mockPrisma.post.update).not.toHaveBeenCalled(); expect(result).toEqual({ success: true }); }); it("deletePost decrements the original post's repostCount when the deleted post is itself a repost (2026-07-22 audit fix)", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.post.findUnique.mockResolvedValue({ repost: { originalPostId: "original-post-1" }, }); mockPrisma.post.delete.mockResolvedValue({ id: "repost-post-1" }); mockPrisma.post.update.mockResolvedValue({ id: "original-post-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.deletePost({ postId: "repost-post-1" }); expect(mockPrisma.post.update).toHaveBeenCalledWith({ where: { id: "original-post-1" }, data: { repostCount: { decrement: 1 } }, }); expect(result).toEqual({ success: true }); }); it("banUser wraps upsert + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.userBan.upsert.mockResolvedValue({ ownerId: "owner-2" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.banUser({ ownerId: "owner-2", reason: "abuse" }); expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.userBan.upsert).toHaveBeenCalled(); expect(result).toEqual({ success: true }); }); it("unbanUser wraps delete + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.userBan.delete.mockResolvedValue({ ownerId: "owner-2" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.unbanUser({ ownerId: "owner-2" }); expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.userBan.delete).toHaveBeenCalledWith({ where: { ownerId: "owner-2" } }); expect(result).toEqual({ success: true }); }); it("dismissReport wraps delete + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.report.findUnique.mockResolvedValue({ id: "report-1" }); mockPrisma.report.delete.mockResolvedValue({ id: "report-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.dismissReport({ reportId: "report-1" }); expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.report.delete).toHaveBeenCalledWith({ where: { id: "report-1" } }); expect(result).toEqual({ success: true }); }); it("deleteReportedPost wraps delete + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); mockPrisma.post.delete.mockResolvedValue({ id: "post-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); const result = await caller.admin.deleteReportedPost({ postId: "post-1" }); expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" } }); expect(result).toEqual({ success: true }); }); }); describe("admin dashboard time-series", () => { it("getPostsOverTime returns a zero-filled 30-bucket series with today's posts counted correctly", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); const now = new Date(); mockPrisma.post.findMany.mockResolvedValue([ { createdAt: now }, { createdAt: now }, ]); const result = await caller.admin.getPostsOverTime({ granularity: "day" }); expect(result).toHaveLength(30); expect(result[29]!.value).toBe(2); // today = last bucket expect(result.slice(0, 29).every((b) => b.value === 0)).toBe(true); }); it("getOwnerGrowth returns a cumulative total, starting from the pre-window baseline", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.owner.count.mockResolvedValue(5); // owners registered before the window const now = new Date(); mockPrisma.owner.findMany.mockResolvedValue([{ createdAt: now }]); const result = await caller.admin.getOwnerGrowth({ granularity: "day" }); expect(result).toHaveLength(30); expect(result[0]!.value).toBe(5); // baseline carried through empty leading buckets expect(result[29]!.value).toBe(6); // baseline + 1 new owner today }); }); describe("admin.listUsers", () => { it("merges Clerk email/name onto each owner (Gitea #6)", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.owner.findMany.mockResolvedValue([ { id: "owner-2", pets: [], ban: null, adminRole: null, _count: { pets: 0 } }, ]); mockGetUserList.mockResolvedValue({ data: [ { id: "owner-2", emailAddresses: [{ id: "email-1", emailAddress: "owner2@example.com" }], primaryEmailAddressId: "email-1", firstName: "Jane", lastName: "Doe", createdAt: 1700000000000, }, ], }); const result = await caller.admin.listUsers({ limit: 20 }); expect(result.users[0]!.clerk).toEqual({ email: "owner2@example.com", name: "Jane Doe", createdAt: new Date(1700000000000), }); }); it("returns clerk: null when the Clerk profile is missing (e.g. deleted account)", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.owner.findMany.mockResolvedValue([ { id: "owner-3", pets: [], ban: null, adminRole: null, _count: { pets: 0 } }, ]); mockGetUserList.mockResolvedValue({ data: [] }); const result = await caller.admin.listUsers({ limit: 20 }); expect(result.users[0]!.clerk).toBeNull(); }); }); describe("admin.startTeamConversation", () => { it("creates the Team pet on first use and starts a conversation", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.pet.findUnique.mockResolvedValue({ id: "target-pet-1" }); mockPrisma.pet.findFirst.mockResolvedValue(null); // no Team pet yet mockPrisma.owner.upsert.mockResolvedValue({ id: "admin-1" }); mockPrisma.species.findFirst.mockResolvedValue({ id: "species-1" }); mockPrisma.pet.create.mockResolvedValue({ id: "team-pet-1" }); mockPrisma.conversation.upsert.mockResolvedValue({ id: "conv-1" }); const result = await caller.admin.startTeamConversation({ targetPetId: "target-pet-1" }); expect(mockPrisma.pet.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ ownerId: "admin-1", name: "PawFeed Team" }), }) ); expect(result).toEqual({ conversationId: "conv-1", teamPetId: "team-pet-1" }); }); it("reuses an existing Team pet instead of creating a duplicate", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.pet.findUnique.mockResolvedValue({ id: "target-pet-1" }); mockPrisma.pet.findFirst.mockResolvedValue({ id: "team-pet-existing" }); mockPrisma.conversation.upsert.mockResolvedValue({ id: "conv-2" }); const result = await caller.admin.startTeamConversation({ targetPetId: "target-pet-1" }); expect(mockPrisma.pet.create).not.toHaveBeenCalled(); expect(result.teamPetId).toBe("team-pet-existing"); }); it("throws NOT_FOUND when the target pet does not exist", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.pet.findUnique.mockResolvedValue(null); await expect( caller.admin.startTeamConversation({ targetPetId: "missing-pet" }) ).rejects.toThrow(TRPCError); }); it("throws BAD_REQUEST when the target pet is the Team pet itself", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.pet.findUnique.mockResolvedValue({ id: "team-pet-existing" }); mockPrisma.pet.findFirst.mockResolvedValue({ id: "team-pet-existing" }); await expect( caller.admin.startTeamConversation({ targetPetId: "team-pet-existing" }) ).rejects.toThrow(TRPCError); }); it("throws FORBIDDEN for a non-admin caller", async () => { vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); const { caller } = await createAdminCaller(); await expect( caller.admin.startTeamConversation({ targetPetId: "target-pet-1" }) ).rejects.toThrow(TRPCError); }); }); describe("admin.exportUserData", () => { it("uploads the export, creates a download token, emails the pawfeed.org link, and logs a moderation entry", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://pawfeed.org"); const { caller, mockPrisma } = await createAdminCaller(); mockBuildDataExport.mockResolvedValue({ account: { clerkProfile: { email: "owner4@example.com" } }, }); mockUploadExport.mockResolvedValue(undefined); mockPrisma.dataExportToken.create.mockResolvedValue({ token: "export-token-abc" }); mockSendMail.mockResolvedValue(undefined); mockPrisma.moderationLog.create.mockResolvedValue({}); const result = await caller.admin.exportUserData({ ownerId: "owner-4" }); expect(mockPrisma.dataExportToken.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ ownerId: "owner-4" }), }) ); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ to: "owner4@example.com", text: expect.stringContaining("https://pawfeed.org/api/gdpr-export/export-token-abc"), }) ); 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(); }); }); describe("admin.getActiveAds", () => { const adUnfiltered = { id: "ad-all", species: [], breeds: [] }; const adDogsOnly = { id: "ad-dogs", species: ["species-dog"], breeds: [] }; const adSiameseCatsOnly = { id: "ad-siamese", species: ["species-cat"], breeds: ["breed-siamese"] }; it("returns [] when the pet does not exist", async () => { const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.pet.findUnique.mockResolvedValue(null); const result = await caller.admin.getActiveAds({ petId: "missing-pet" }); expect(result).toEqual([]); }); it("returns untargeted ads and matching-species ads, excludes non-matching species", async () => { const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-dog", breedId: null }); mockPrisma.advertisement.findMany.mockResolvedValue([adUnfiltered, adDogsOnly, adSiameseCatsOnly]); const result = await caller.admin.getActiveAds({ petId: "pet-dog-1" }); expect(result.map((a) => a.id)).toEqual(["ad-all", "ad-dogs"]); }); it("only shows a breed-narrowed ad to a pet with the matching breed", async () => { const { caller, mockPrisma } = await createAdminCaller(); mockPrisma.advertisement.findMany.mockResolvedValue([adUnfiltered, adSiameseCatsOnly]); mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-cat", breedId: "breed-siamese" }); const matching = await caller.admin.getActiveAds({ petId: "pet-siamese" }); expect(matching.map((a) => a.id)).toEqual(["ad-all", "ad-siamese"]); mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-cat", breedId: "breed-persian" }); const nonMatching = await caller.admin.getActiveAds({ petId: "pet-persian" }); expect(nonMatching.map((a) => a.id)).toEqual(["ad-all"]); mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-cat", breedId: null }); const noBreedSet = await caller.admin.getActiveAds({ petId: "pet-mixed-cat" }); expect(noBreedSet.map((a) => a.id)).toEqual(["ad-all"]); }); it("does not require admin role — any authenticated owner can fetch ads for their pet", async () => { const { caller, mockPrisma } = await createAdminCaller({ userId: "regular-owner" }); mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-dog", breedId: null }); mockPrisma.advertisement.findMany.mockResolvedValue([adUnfiltered]); await expect(caller.admin.getActiveAds({ petId: "pet-dog-1" })).resolves.toEqual([adUnfiltered]); }); });