Files
petfeed/src/__tests__/admin.test.ts
T
adminandClaude Opus 5.5 f51b158989 feat(admin): Team-Nachricht an Melder bei abgewiesenem/bestaetigtem Report
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>
2026-10-04 13:09:39 +02:00

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 });
});
});