Files
petfeed/src/__tests__/admin.test.ts
T
adminandClaude Sonnet 5 215fba80d4 fix(cluster-h): wrap admin delete+moderation-log writes in a transaction
deletePost, banUser, unbanUser, dismissReport, and deleteReportedPost each
ran the mutation and the moderationLog.create as two separate sequential
awaits. The audit flagged this as "should be Promise.all", but that would
have introduced a real bug: if the delete/ban call failed, a concurrently
fired log write could still succeed, recording an action that never
happened. Wrapped both in $transaction instead (matching the pattern
deleteComment already used) — atomic, and the log can only be written if
the mutation actually succeeded.

Reviewed the audit's other Cluster H finding (a few include-vs-select
over-fetches) and found nothing actionable — the flagged spots already
narrow relations with select where it matters; the unnarrowed ones are
small lookup/relation tables used in full by their call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 20:39:20 +02:00

170 lines
7.0 KiB
TypeScript

/**
* 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", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "admin-1" }),
}));
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: {} }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
async function createAdminCaller(overrides?: {
userId?: string | null;
prisma?: ReturnType<typeof createMockPrisma>;
}) {
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();
});
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.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(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.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 });
});
});