Files
petfeed/src/__tests__/admin.test.ts
T
admin bf49024ace fix: 6 HIGH-severity findings from 2026-07-21 audit rerun
- feed.ts: Postgres fallback path (used when Redis is down) never
  filtered blocked pets, unlike the primary Redis path — blocked pets'
  posts could reappear during a Redis hiccup.
- feed-helpers.ts/feed.ts: getFeedPage always paginated by a hardcoded
  PAGE_SIZE=20, silently ignoring the client's requested (zod-validated
  1-50) limit whenever Redis was up.
- milestones.ts: create accepted any MilestoneType including the
  follower-threshold values (FOLLOWERS_500..FOLLOWERS_1M), which are
  meant to be exclusively auto-awarded — restriction existed only in
  the MilestoneForm picker, not server-side. Now validated against the
  existing USER_INITIATED_MILESTONE_TYPES constant.
- admin.ts: deletePost/deleteReportedPost didn't decrement the original
  post's repostCount when the deleted post was itself a repost, unlike
  reposts.ts's user-facing delete — counter drifted upward permanently.
- PawButton.tsx: count was optimistically guessed off a `reacted` flag
  that can be stale (callers pass initialReacted={false} unconditionally),
  permanently corrupting the displayed count on click. Count is now
  computed from the server's actual toggle result instead of guessed.
- PostDetailDialog.tsx: Report/Block dropdown items had no onClick and
  neither ReportSheet nor BlockDialog were rendered — wired up to match
  PostCard.tsx's existing pattern.
- VideoCard.tsx: invalidated the entire query cache on every video-ready
  transition; scoped to feed/posts/explore router queries instead.
2026-07-22 19:24:21 +02:00

192 lines
8.1 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.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 });
});
});