found.md: Abweisen schickt dem Melder eine Team-Nachricht mit optionaler Begruendung (auch im ModerationLog), Loeschen eines gemeldeten Beitrags dankt jedem Melder einmal. Beides per Schalter im Admin-Dialog abschaltbar, best-effort nach der Moderation. Die interne Loesch-Begruendung geht nicht an den Melder. Fix nebenbei: deleteReportedPost loescht jetzt die Reports des Beitrags (targetPostId ist keine Relation, sie blieben bisher verwaist in der Liste). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
866 lines
35 KiB
TypeScript
866 lines
35 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,
|
|
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: {} }));
|
|
|
|
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),
|
|
}));
|
|
|
|
const mockIsMaintenanceModeEnabled = vi.fn();
|
|
const mockSetMaintenanceMode = vi.fn();
|
|
vi.mock("@/lib/maintenance", () => ({
|
|
isMaintenanceModeEnabled: (...args: unknown[]) => mockIsMaintenanceModeEnabled(...args),
|
|
setMaintenanceMode: (...args: unknown[]) => mockSetMaintenanceMode(...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", notifyReporter: false });
|
|
|
|
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" });
|
|
mockPrisma.report.findMany.mockResolvedValue([]);
|
|
|
|
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]);
|
|
});
|
|
});
|
|
|
|
// Gitea #18 — legal AI-content disclosure moderator correction.
|
|
describe("admin.setAiDisclosure", () => {
|
|
it("wraps the post update + moderation log write in a transaction, requiring a reason", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.post.findUnique.mockResolvedValue({ id: "post-1" });
|
|
mockPrisma.post.update.mockResolvedValue({ id: "post-1", aiDisclosure: "AI_GENERATED" });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.setAiDisclosure({
|
|
postId: "post-1",
|
|
aiDisclosure: "AI_GENERATED",
|
|
reason: "Owner forgot to disclose",
|
|
});
|
|
|
|
expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array));
|
|
expect(mockPrisma.post.update).toHaveBeenCalledWith({
|
|
where: { id: "post-1" },
|
|
data: { aiDisclosure: "AI_GENERATED" },
|
|
});
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "SET_AI_DISCLOSURE",
|
|
targetType: "POST",
|
|
targetId: "post-1",
|
|
reason: "Owner forgot to disclose",
|
|
}),
|
|
})
|
|
);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it("rejects an empty reason", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller } = await createAdminCaller();
|
|
|
|
await expect(
|
|
caller.admin.setAiDisclosure({ postId: "post-1", aiDisclosure: "NONE", reason: "" })
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
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.setAiDisclosure({ postId: "post-1", aiDisclosure: "NONE", reason: "test" })
|
|
).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.post.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws NOT_FOUND when the post doesn't exist", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.post.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
caller.admin.setAiDisclosure({ postId: "missing", aiDisclosure: "NONE", reason: "test" })
|
|
).rejects.toThrow(TRPCError);
|
|
});
|
|
});
|
|
|
|
describe("admin.pinPost / admin.unpinPost", () => {
|
|
it("pinPost sets pinnedAt and writes a moderation log", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.post.findUnique.mockResolvedValue({ id: "post-1" });
|
|
mockPrisma.post.update.mockResolvedValue({ id: "post-1", pinnedAt: new Date() });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.pinPost({ postId: "post-1" });
|
|
|
|
expect(mockPrisma.post.update).toHaveBeenCalledWith({
|
|
where: { id: "post-1" },
|
|
data: { pinnedAt: expect.any(Date) },
|
|
});
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ action: "PIN_POST", targetId: "post-1" }),
|
|
})
|
|
);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it("unpinPost clears pinnedAt and writes a moderation log", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.post.findUnique.mockResolvedValue({ id: "post-1", pinnedAt: new Date() });
|
|
mockPrisma.post.update.mockResolvedValue({ id: "post-1", pinnedAt: null });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.unpinPost({ postId: "post-1" });
|
|
|
|
expect(mockPrisma.post.update).toHaveBeenCalledWith({
|
|
where: { id: "post-1" },
|
|
data: { pinnedAt: null },
|
|
});
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ action: "UNPIN_POST", targetId: "post-1" }),
|
|
})
|
|
);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it("pinPost throws NOT_FOUND when the post doesn't exist", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.post.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(caller.admin.pinPost({ postId: "missing" })).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.post.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("pinPost 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.pinPost({ postId: "post-1" })).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.post.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("admin.getMaintenanceMode / admin.setMaintenanceMode", () => {
|
|
afterEach(() => {
|
|
mockIsMaintenanceModeEnabled.mockReset();
|
|
mockSetMaintenanceMode.mockReset();
|
|
});
|
|
|
|
it("getMaintenanceMode reflects the current flag for a MODERATOR caller", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller } = await createAdminCaller();
|
|
mockIsMaintenanceModeEnabled.mockResolvedValue(true);
|
|
|
|
const result = await caller.admin.getMaintenanceMode();
|
|
|
|
expect(result).toEqual({ enabled: true });
|
|
});
|
|
|
|
it("setMaintenanceMode toggles the flag and writes a moderation log for a SUPER_ADMIN caller", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockSetMaintenanceMode.mockResolvedValue(undefined);
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.setMaintenanceMode({ enabled: true });
|
|
|
|
expect(mockSetMaintenanceMode).toHaveBeenCalledWith(true);
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ action: "ENABLE_MAINTENANCE_MODE", targetType: "SYSTEM" }),
|
|
})
|
|
);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it("setMaintenanceMode throws FORBIDDEN for a MODERATOR (below SUPER_ADMIN)", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" });
|
|
|
|
await expect(caller.admin.setMaintenanceMode({ enabled: true })).rejects.toThrow(TRPCError);
|
|
expect(mockSetMaintenanceMode).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("admin.createBroadcast / admin.listBroadcasts (Gitea #10 Phase 1)", () => {
|
|
it("createBroadcast creates a Broadcast and writes a SEND_BROADCAST moderation log for a SUPER_ADMIN caller", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.broadcast.create.mockResolvedValue({
|
|
id: "b1",
|
|
title: "Wartung",
|
|
body: "Heute Nacht 22 Uhr",
|
|
createdAt: new Date(),
|
|
createdBy: "admin-1",
|
|
});
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.createBroadcast({ title: "Wartung", body: "Heute Nacht 22 Uhr" });
|
|
|
|
expect(mockPrisma.broadcast.create).toHaveBeenCalledWith({
|
|
data: { title: "Wartung", body: "Heute Nacht 22 Uhr", createdBy: "admin-1" },
|
|
});
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "SEND_BROADCAST",
|
|
targetType: "SYSTEM",
|
|
targetId: "b1",
|
|
}),
|
|
})
|
|
);
|
|
expect(result.id).toBe("b1");
|
|
});
|
|
|
|
it("createBroadcast throws FORBIDDEN for a MODERATOR (below SUPER_ADMIN)", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" });
|
|
|
|
await expect(
|
|
caller.admin.createBroadcast({ title: "Wartung", body: "Heute Nacht" })
|
|
).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.broadcast.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("listBroadcasts returns broadcasts with delivered/read counts for a MODERATOR caller", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" });
|
|
mockPrisma.broadcast.findMany.mockResolvedValue([
|
|
{ id: "b1", title: "Wartung", body: "x", createdAt: new Date(), createdBy: "admin-1", _count: { receipts: 3 } },
|
|
]);
|
|
mockPrisma.owner.count.mockResolvedValue(10);
|
|
mockPrisma.broadcastReceipt.groupBy.mockResolvedValue([{ broadcastId: "b1", _count: 2 }]);
|
|
|
|
const result = await caller.admin.listBroadcasts();
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
id: "b1",
|
|
deliveredCount: 3,
|
|
readCount: 2,
|
|
totalOwners: 10,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("deleteBroadcast wraps delete + log in a transaction and requires a reason for a SUPER_ADMIN caller", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.broadcast.findUnique.mockResolvedValue({ id: "b1", title: "Wartung" });
|
|
mockPrisma.broadcast.delete.mockResolvedValue({ id: "b1" });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.deleteBroadcast({ broadcastId: "b1", reason: "Falscher Titel" });
|
|
|
|
expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array));
|
|
expect(mockPrisma.broadcast.delete).toHaveBeenCalledWith({ where: { id: "b1" } });
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "DELETE_BROADCAST",
|
|
targetType: "SYSTEM",
|
|
targetId: "b1",
|
|
reason: "Falscher Titel",
|
|
}),
|
|
})
|
|
);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it("deleteBroadcast throws FORBIDDEN for a MODERATOR (below SUPER_ADMIN)", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" });
|
|
|
|
await expect(
|
|
caller.admin.deleteBroadcast({ broadcastId: "b1", reason: "x" })
|
|
).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.broadcast.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("deleteBroadcast throws NOT_FOUND for a missing broadcast", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.broadcast.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
caller.admin.deleteBroadcast({ broadcastId: "missing", reason: "x" })
|
|
).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.broadcast.delete).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("admin music-track catalog", () => {
|
|
it("listMusicTracks returns the catalog for a MODERATOR caller", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.musicTrack.findMany.mockResolvedValue([{ id: "track-1", title: "Sunny Day" }]);
|
|
|
|
const result = await caller.admin.listMusicTracks(undefined);
|
|
|
|
expect(result).toEqual([{ id: "track-1", title: "Sunny Day" }]);
|
|
});
|
|
|
|
it("getMusicTrackUploadUrl requires SUPER_ADMIN, not just MODERATOR", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" });
|
|
|
|
await expect(
|
|
caller.admin.getMusicTrackUploadUrl({ contentType: "audio/mpeg", fileSize: 1000 })
|
|
).rejects.toThrow(TRPCError);
|
|
});
|
|
|
|
it("createMusicTrack writes the track + a CREATE_MUSIC_TRACK moderation log in a transaction", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.musicTrack.create.mockResolvedValue({ id: "track-1", title: "Sunny Day" });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.createMusicTrack({
|
|
title: "Sunny Day",
|
|
artist: "Test Artist",
|
|
licenseNote: "Royalty-free library, CC0",
|
|
storageKey: "tracks/abc.mp3",
|
|
durationSecs: 12.5,
|
|
});
|
|
|
|
expect(mockPrisma.musicTrack.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ title: "Sunny Day", createdBy: "admin-1" }),
|
|
});
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ action: "CREATE_MUSIC_TRACK", targetType: "MUSIC_TRACK" }),
|
|
});
|
|
expect(result).toEqual({ id: "track-1", title: "Sunny Day" });
|
|
});
|
|
|
|
it("createMusicTrack 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.createMusicTrack({
|
|
title: "Sunny Day",
|
|
artist: "Test Artist",
|
|
licenseNote: "Royalty-free library, CC0",
|
|
storageKey: "tracks/abc.mp3",
|
|
durationSecs: 12.5,
|
|
})
|
|
).rejects.toThrow(TRPCError);
|
|
expect(mockPrisma.musicTrack.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("updateMusicTrack can toggle active off without touching other fields", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.musicTrack.update.mockResolvedValue({ id: "track-1", active: false });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.updateMusicTrack({ id: "track-1", active: false });
|
|
|
|
expect(mockPrisma.musicTrack.update).toHaveBeenCalledWith({
|
|
where: { id: "track-1" },
|
|
data: { active: false },
|
|
});
|
|
expect(result).toEqual({ id: "track-1", active: false });
|
|
});
|
|
|
|
it("deleteMusicTrack wraps delete + log in a transaction", async () => {
|
|
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
|
const { caller, mockPrisma } = await createAdminCaller();
|
|
mockPrisma.musicTrack.delete.mockResolvedValue({ id: "track-1" });
|
|
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
|
|
|
const result = await caller.admin.deleteMusicTrack({ id: "track-1" });
|
|
|
|
expect(mockPrisma.musicTrack.delete).toHaveBeenCalledWith({ where: { id: "track-1" } });
|
|
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ action: "DELETE_MUSIC_TRACK", targetType: "MUSIC_TRACK", targetId: "track-1" }),
|
|
});
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|