Files
petfeed/src/__tests__/admin.test.ts
T
admin efdf407642 feat(admin): add species/breed ad targeting (Gitea #9)
Advertisement.species existed in the schema but was never wired up —
the admin form never let you set it and the feed's ad query ignored it
entirely, showing every active ad to every pet regardless of species.

Adds a new Advertisement.breeds field alongside it and wires both
through the whole path: a targeting UI in the ad create/edit dialog
(species checkboxes, each expandable into its own breed checklist),
and a filter in admin.getActiveAds (now scoped to the viewing pet) so
cat food ads stop reaching dog owners. Empty species/breeds = shown to
everyone, unchanged from today's behavior.
2026-08-11 15:09:56 +02:00

484 lines
19 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", () => ({}));
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<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();
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]);
});
});