Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
595 lines
No EOL
18 KiB
TypeScript
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 });
|
|
|
|
// 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<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.");
|
|
});
|
|
});
|
|
}); |