import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import type { User, RibbonSubmission } from "@/payload-types"; import { submitRibbonDesign, withdrawRibbonSubmission, reviewRibbonSubmission } from "@/app/(frontend)/awards/design/actions"; import { EventTypes } from "@/utils/event-log/eventTypes"; // Mock next/headers to avoid "headers was called outside a request scope" error vi.mock("next/headers", () => ({ headers: async () => new Headers(), })); let payload: Payload; let authSpy: MockInstance; const RUN = `ribbon-${Date.now().toString(36)}`; describe("Ribbon Design Submissions", () => { let user: User; let userId: number; let adminUser: User; let adminUserId: number; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); // Create a regular user user = (await payload.create({ collection: "users", data: { username: `${RUN}-user`, discordUsername: `${RUN}-user`, displayName: "RIBBON TEST", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; userId = user.id; // Create an admin user adminUser = (await payload.create({ collection: "users", data: { username: `${RUN}-admin`, discordUsername: `${RUN}-admin`, displayName: "RIBBON ADMIN", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["admin"], }, overrideAccess: true, depth: 0, })) as unknown as User; adminUserId = adminUser.id; // Set up auth spy for testing authentication authSpy = vi.spyOn(payload, "auth"); }); afterAll(async () => { // Clean up users try { await payload.delete({ collection: "users", id: userId, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "users", id: adminUserId, overrideAccess: true }); } catch { // Ignore if already deleted } // Restore auth spy authSpy.mockRestore(); }); describe("submitRibbonDesign", () => { it("creates a pending submission and emits the submitted event", async () => { // Mock authentication for regular user authSpy.mockResolvedValue({ user }); const design = { stripes: [ { color: "#b91c1c", width: 30 }, { color: "#f5f5f4", width: 5 }, { color: "#1e3a5f", width: 30 }, ], devices: [], mirrored: false, }; const result = await submitRibbonDesign({ name: "Test Ribbon", description: "A test ribbon design", design, }); expect(result.success).toBe(true); expect(result.data?.id).toBeDefined(); const submissionId = result.data!.id; // Verify the submission was created with correct data const submission = await payload.findByID({ collection: "ribbon-submissions", id: submissionId, depth: 0, overrideAccess: true, }) as RibbonSubmission; expect(submission.user).toBe(userId); expect(submission.status).toBe("pending"); expect(submission.name).toBe("Test Ribbon"); expect(submission.description).toBe("A test ribbon design"); expect(submission.design).toEqual(design); // Verify event was emitted (scoped to this submission; the shared dev // database accumulates events across runs) const events = await payload.find({ collection: "game-event-logs", where: { and: [ { type: { equals: EventTypes.AwardRibbonSubmitted } }, { targetId: { equals: submissionId } }, ], }, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); expect(events.docs[0].message).toContain("Test Ribbon"); }); it("rejects an invalid design", async () => { // Mock authentication for regular user authSpy.mockResolvedValue({ user }); const result = await submitRibbonDesign({ name: "Invalid Test", description: "A test with invalid design", design: { stripes: [], devices: [], mirrored: false, }, }); expect(result.success).toBe(false); expect(result.error).toBe("Must have between 1 and 10 stripes"); }); it("enforces the pending cap", async () => { // Mock authentication for regular user authSpy.mockResolvedValue({ user }); // Fill the pending cap for (let i = 0; i < 15; i++) { await payload.create({ collection: "ribbon-submissions", data: { name: `Pending ${i}`, description: `Pending submission ${i}`, design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); } const result = await submitRibbonDesign({ name: "Too Many", description: "This should fail due to cap", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, }); expect(result.success).toBe(false); expect(result.error).toBe("You already have 15 pending ribbon submissions. Wait for them to be reviewed first."); }); }); describe("withdrawRibbonSubmission", () => { it("allows withdrawing own pending submissions", async () => { // Mock authentication for regular user authSpy.mockResolvedValue({ user }); // Create a pending submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Withdraw Test", description: "A test for withdrawal", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); const result = await withdrawRibbonSubmission(submission.id); expect(result.success).toBe(true); // Verify submission was deleted const fetchedSubmission = await payload.findByID({ collection: "ribbon-submissions", id: submission.id, depth: 0, overrideAccess: true, }).catch(() => null); expect(fetchedSubmission).toBeNull(); }); it("rejects withdrawing submissions that don't belong to the user", async () => { // Create a pending submission for the user const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Withdraw Test 2", description: "A test for withdrawal", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); // Mock authentication for admin user to test access control authSpy.mockResolvedValue({ user: adminUser }); const result = await withdrawRibbonSubmission(submission.id); expect(result.success).toBe(false); expect(result.error).toBe("You can only withdraw your own submissions."); }); it("rejects withdrawing non-pending submissions", async () => { // Create an approved submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Approved Test", description: "An approved submission", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "approved", }, overrideAccess: true, depth: 0, }); // Mock authentication for regular user authSpy.mockResolvedValue({ user }); const result = await withdrawRibbonSubmission(submission.id); expect(result.success).toBe(false); expect(result.error).toBe("Only pending submissions can be withdrawn."); }); }); describe("reviewRibbonSubmission", () => { it("allows admins to approve submissions", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); // Create a pending submission (unique name: approvals persist an award // with this name, and the shared dev database keeps prior runs' awards) const submission = await payload.create({ collection: "ribbon-submissions", data: { name: `Approve Test-${RUN}`, description: "A test for approval", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); const result = await reviewRibbonSubmission(submission.id, "approved", { awardXp: 25, grantorTypes: ["division"], recipientScope: "same_command", }); expect(result.success).toBe(true); expect(result.data?.status).toBe("approved"); // Verify submission was updated const updatedSubmission = await payload.findByID({ collection: "ribbon-submissions", id: submission.id, depth: 0, overrideAccess: true, }) as RibbonSubmission; expect(updatedSubmission.status).toBe("approved"); expect(updatedSubmission.awardXp).toBe(25); expect(updatedSubmission.grantorTypes).toEqual(["division"]); expect(updatedSubmission.recipientScope).toBe("same_command"); }); it("rejects non-reviewers from updating submissions", async () => { // Create a pending submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Access Test", description: "A test for access control", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); // Mock authentication for regular user (not admin) authSpy.mockResolvedValue({ user }); // Try to approve as a regular user (should fail) const result = await reviewRibbonSubmission(submission.id, "approved", { awardXp: 25, grantorTypes: ["division"], }); expect(result.success).toBe(false); expect(result.error).toBe("Only admins and developers can review ribbon submissions."); }); it("rejects approval without XP", async () => { authSpy.mockResolvedValue({ user: adminUser }); // Create a pending submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "No XP Test", description: "A test for XP validation", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); const result = await reviewRibbonSubmission(submission.id, "approved", { grantorTypes: ["division"], }); expect(result.success).toBe(false); expect(result.error).toBe("XP must be at least 1."); }); it("rejects approval with empty grantor types", async () => { authSpy.mockResolvedValue({ user: adminUser }); // Create a pending submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Invalid Grantor Test", description: "A test for grantor validation", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); const result = await reviewRibbonSubmission(submission.id, "approved", { awardXp: 25, grantorTypes: [], }); expect(result.success).toBe(false); expect(result.error).toBe("Choose at least one grantor type."); }); it("rejects approval with invalid recipient scope", async () => { authSpy.mockResolvedValue({ user: adminUser }); // Create a pending submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Invalid Scope Test", description: "A test for recipient scope validation", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); // Server action args arrive untyped over the wire, so a caller can send // a scope outside the union; forge one to exercise the runtime guard. const forgedOpts = { awardXp: 25, grantorTypes: ["division"], recipientScope: "whitelist", }; const result = await reviewRibbonSubmission( submission.id, "approved", forgedOpts as Parameters[2], ); expect(result.success).toBe(false); expect(result.error).toBe("Whitelist scoping is not available yet."); }); it("rejects approval with duplicate award names", async () => { authSpy.mockResolvedValue({ user: adminUser }); const dupName = `Duplicate Test-${RUN}`; // Create an existing award with the same name (using proper structure) await payload.create({ collection: "awards", data: { name: dupName, description: { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Test description", format: 0, style: "", mode: "normal", detail: 0, version: 1, }, ], direction: "ltr", format: "", indent: 0, textFormat: 0, textStyle: "", version: 1, }, ], direction: "ltr", format: "", indent: 0, version: 1, }, }, type: "ribbon", ribbonDesign: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, experiencePoints: 25, grantConfig: { allowedGrantorAssignmentTypes: ["division"], recipientScope: "same_command", maxGrantsPerGrantor: null, }, }, overrideAccess: true, depth: 0, }); const submission = await payload.create({ collection: "ribbon-submissions", data: { name: dupName, description: "A test for duplicate name", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); const result = await reviewRibbonSubmission(submission.id, "approved", { awardXp: 25, grantorTypes: ["division"], }); expect(result.success).toBe(false); expect(result.error).toContain("already exists"); }); it("rejects rejection without review note", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); // Create a pending submission const submission = await payload.create({ collection: "ribbon-submissions", data: { name: "Reject Test", description: "A test for rejection", design: { stripes: [{ color: "#b91c1c", width: 30 }], devices: [], mirrored: false, }, user: userId, status: "pending", }, overrideAccess: true, depth: 0, }); const result = await reviewRibbonSubmission(submission.id, "rejected", { reviewNote: "Too similar to an existing ribbon.", }); expect(result.success).toBe(true); expect(result.data?.status).toBe("rejected"); // Verify submission was updated const updatedSubmission = await payload.findByID({ collection: "ribbon-submissions", id: submission.id, depth: 0, overrideAccess: true, }) as RibbonSubmission; expect(updatedSubmission.status).toBe("rejected"); expect(updatedSubmission.reviewNote).toBe("Too similar to an existing ribbon."); }); }); });