1
0
Fork 0
polaris-task-force/tests/int/ribbon-submissions.int.spec.ts
Z8MB1E ac822f259c feat(awards): add ribbon submissions collection, design validation and migration
Also scopes the MCP plugin to explicit per-collection eligibility (mcpCollections).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 03:25:59 -04:00

595 lines
No EOL
18 KiB
TypeScript

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 });
// Create 3 pending submissions
for (let i = 0; i < 3; 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 3 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<typeof reviewRibbonSubmission>[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.");
});
});
});