import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import type { User, Award } from "@/payload-types"; import { grantAward } from "@/app/(frontend)/awards/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 = `award-${Date.now().toString(36)}`; describe("Award Advancement Series", () => { let adminUser: User; let adminUserId: number; let userA: User; let userAId: number; let userB: User; let userBId: number; let userC: User; let userCId: number; let tier1Award: Award; let tier2Award: Award; let tier3Award: Award; let superRoleId: number; async function profileIdFor(userId: number): Promise { const res = await payload.find({ collection: "profiles", where: { user: { equals: userId } }, limit: 1, depth: 0, overrideAccess: true, }); if (!res.docs[0]) throw new Error(`No profile found for user ${userId}`); return res.docs[0].id; } beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); // Create a superuser role (RBAC resolves via roleDocs, not the legacy enum) const superRole = await payload.create({ collection: "roles", data: { name: `Advancement Superuser ${RUN}`, slug: `${RUN}-superuser`, isSuperuser: true, isSystem: false, permissions: [], }, overrideAccess: true, depth: 0, }); superRoleId = superRole.id; // Create an admin user adminUser = (await payload.create({ collection: "users", data: { username: `${RUN}-admin`, discordUsername: `${RUN}-admin`, displayName: "AWARD ADMIN", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["admin"], roleDocs: [superRole.id], }, overrideAccess: true, depth: 0, })) as unknown as User; adminUserId = adminUser.id; // Create users userA = (await payload.create({ collection: "users", data: { username: `${RUN}-userA`, discordUsername: `${RUN}-userA`, displayName: "USER A", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; userAId = userA.id; userB = (await payload.create({ collection: "users", data: { username: `${RUN}-userB`, discordUsername: `${RUN}-userB`, displayName: "USER B", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; userBId = userB.id; userC = (await payload.create({ collection: "users", data: { username: `${RUN}-userC`, discordUsername: `${RUN}-userC`, displayName: "USER C", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; userCId = userC.id; // Set up auth spy for testing authentication authSpy = vi.spyOn(payload, "auth"); // Create awards for testing advancement tier1Award = (await payload.create({ collection: "awards", data: { name: `Tier 1 Award-${RUN}`, description: { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Tier 1 Award", 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", advancement: { seriesKey: `${RUN}-years-of-service`, tier: 1, }, ribbonDesign: { stripes: [ { color: "#b91c1c", width: 30 }, { color: "#f5f5f4", width: 5 }, { color: "#1e3a5f", width: 30 }, ], devices: [], mirrored: false, }, experiencePoints: 25, grantConfig: { allowedGrantorAssignmentTypes: ["division"], recipientScope: "same_command", maxGrantsPerGrantor: null, }, }, overrideAccess: true, depth: 0, })) as unknown as Award; tier2Award = (await payload.create({ collection: "awards", data: { name: `Tier 2 Award-${RUN}`, description: { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Tier 2 Award", 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", advancement: { seriesKey: `${RUN}-years-of-service`, tier: 2, }, ribbonDesign: { stripes: [ { color: "#1e3a5f", width: 30 }, { color: "#f5f5f4", width: 5 }, { color: "#b91c1c", width: 30 }, ], devices: [], mirrored: false, }, experiencePoints: 50, grantConfig: { allowedGrantorAssignmentTypes: ["division"], recipientScope: "same_command", maxGrantsPerGrantor: null, }, }, overrideAccess: true, depth: 0, })) as unknown as Award; tier3Award = (await payload.create({ collection: "awards", data: { name: `Tier 3 Award-${RUN}`, description: { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Tier 3 Award", 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", advancement: { seriesKey: `${RUN}-years-of-service`, tier: 3, }, ribbonDesign: { stripes: [ { color: "#1e3a5f", width: 30 }, { color: "#f5f5f4", width: 5 }, { color: "#b91c1c", width: 30 }, ], devices: [], mirrored: false, }, experiencePoints: 100, grantConfig: { allowedGrantorAssignmentTypes: ["division"], recipientScope: "same_command", maxGrantsPerGrantor: 1, }, }, overrideAccess: true, depth: 0, })) as unknown as Award; }); afterAll(async () => { // Clean up users and awards try { await payload.delete({ collection: "users", id: userAId, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "users", id: userBId, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "users", id: userCId, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "users", id: adminUserId, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "awards", id: tier1Award.id, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "awards", id: tier2Award.id, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "awards", id: tier3Award.id, overrideAccess: true }); } catch { // Ignore if already deleted } try { await payload.delete({ collection: "roles", id: superRoleId, overrideAccess: true }); } catch { // Ignore if already deleted } // Restore auth spy authSpy.mockRestore(); }); it("should grant tier 1 to recipient A", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); const result = await grantAward({ recipientUserIds: [userAId], awardId: tier1Award.id, reason: "Test grant", }); expect(result.success).toBe(true); expect(result.data?.results).toHaveLength(1); expect(result.data?.results[0].success).toBe(true); expect(result.data?.granted).toBe(1); expect(result.data?.failed).toBe(0); // Verify the profile has the award entry const profile = await payload.findByID({ collection: "profiles", id: await profileIdFor(userAId), depth: 0, overrideAccess: true, }); const entries = profile.progression?.awards ?? []; expect(entries).toHaveLength(1); expect(entries[0]?.award).toBe(tier1Award.id); expect(entries[0]?.upgradedFrom).toBeNull(); expect(entries[0]?.upgradedAt).toBeNull(); }); it("should upgrade recipient A from tier 1 to tier 2", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); const result = await grantAward({ recipientUserIds: [userAId], awardId: tier2Award.id, reason: "Test upgrade", }); expect(result.success).toBe(true); expect(result.data?.results).toHaveLength(1); expect(result.data?.results[0].success).toBe(true); expect(result.data?.granted).toBe(1); expect(result.data?.failed).toBe(0); // Verify the profile has the upgraded award entry (same array position, replaced) const profile = await payload.findByID({ collection: "profiles", id: await profileIdFor(userAId), depth: 0, overrideAccess: true, }); const entries = profile.progression?.awards ?? []; expect(entries).toHaveLength(1); expect(entries[0]?.award).toBe(tier2Award.id); expect(entries[0]?.upgradedFrom).toBe(tier1Award.id); expect(entries[0]?.upgradedAt).toBeDefined(); expect(entries[0]?.awardedAt).toBeDefined(); // Preserved from tier 1 // Verify event was emitted (award names carry the RUN suffix, so the // message match is idempotent across runs) const events = await payload.find({ collection: "game-event-logs", where: { and: [ { type: { equals: EventTypes.AwardUpgrade } }, { message: { like: `Tier 2 Award-${RUN}` } }, ], }, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); }); it("should upgrade recipient A from tier 2 to tier 3", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); const result = await grantAward({ recipientUserIds: [userAId], awardId: tier3Award.id, reason: "Test upgrade 3", }); expect(result.success).toBe(true); expect(result.data?.results).toHaveLength(1); expect(result.data?.results[0].success).toBe(true); expect(result.data?.granted).toBe(1); expect(result.data?.failed).toBe(0); // Verify the profile has the upgraded award entry (same array position, replaced) const profile = await payload.findByID({ collection: "profiles", id: await profileIdFor(userAId), depth: 0, overrideAccess: true, }); const entries = profile.progression?.awards ?? []; expect(entries).toHaveLength(1); expect(entries[0]?.award).toBe(tier3Award.id); expect(entries[0]?.upgradedFrom).toBe(tier2Award.id); expect(entries[0]?.upgradedAt).toBeDefined(); expect(entries[0]?.awardedAt).toBeDefined(); // Preserved from tier 1 }); it("should fail when recipient A tries to get a higher tier of the same ribbon", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); const result = await grantAward({ recipientUserIds: [userAId], awardId: tier1Award.id, reason: "Test downgrade", }); expect(result.success).toBe(true); expect(result.data?.results).toHaveLength(1); expect(result.data?.results[0].success).toBe(false); expect(result.data?.results[0].error).toBe( "Recipient already holds a higher tier of this ribbon.", ); }); it("should grant tier 2 to fresh recipient B", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); const result = await grantAward({ recipientUserIds: [userBId], awardId: tier2Award.id, reason: "Fresh grant to B", }); expect(result.success).toBe(true); expect(result.data?.results).toHaveLength(1); expect(result.data?.results[0].success).toBe(true); expect(result.data?.granted).toBe(1); expect(result.data?.failed).toBe(0); // Verify the profile has the award entry (no upgradedFrom) const profile = await payload.findByID({ collection: "profiles", id: await profileIdFor(userBId), depth: 0, overrideAccess: true, }); const entries = profile.progression?.awards ?? []; expect(entries).toHaveLength(1); expect(entries[0]?.award).toBe(tier2Award.id); expect(entries[0]?.upgradedFrom).toBeNull(); }); it("should allow upgrade from tier 2 to tier 3 despite remaining 0", async () => { // First grant tier 3 to user C (consumes the slot) authSpy.mockResolvedValue({ user: adminUser }); const result1 = await grantAward({ recipientUserIds: [userCId], awardId: tier3Award.id, reason: "Grant to C", }); expect(result1.success).toBe(true); expect(result1.data?.results).toHaveLength(1); expect(result1.data?.results[0].success).toBe(true); // Now try to upgrade user B (tier 2) to tier 3 - should succeed despite remaining 0 authSpy.mockResolvedValue({ user: adminUser }); const result2 = await grantAward({ recipientUserIds: [userBId], awardId: tier3Award.id, reason: "Upgrade to B", }); expect(result2.success).toBe(true); expect(result2.data?.results).toHaveLength(1); expect(result2.data?.results[0].success).toBe(true); // Check that both have the tier-3 award const profileC = await payload.findByID({ collection: "profiles", id: await profileIdFor(userCId), depth: 0, overrideAccess: true, }); const profileB = await payload.findByID({ collection: "profiles", id: await profileIdFor(userBId), depth: 0, overrideAccess: true, }); const entriesC = profileC.progression?.awards ?? []; const entriesB = profileB.progression?.awards ?? []; expect(entriesC).toHaveLength(1); expect(entriesC[0]?.award).toBe(tier3Award.id); expect(entriesB).toHaveLength(1); expect(entriesB[0]?.award).toBe(tier3Award.id); }); it("should fail when trying to grant same tier award to a holder", async () => { // Mock authentication for admin user authSpy.mockResolvedValue({ user: adminUser }); // By this point the exemption test has upgraded userB to tier 3, so // granting tier 3 again must hit the same-tier guard const result = await grantAward({ recipientUserIds: [userBId], awardId: tier3Award.id, reason: "Same tier", }); expect(result.success).toBe(true); expect(result.data?.results).toHaveLength(1); expect(result.data?.results[0].success).toBe(false); expect(result.data?.results[0].error).toBe("Recipient already has this award."); }); it("should not affect non-series awards", async () => { // Create a non-series award const nonSeriesAward = await payload.create({ collection: "awards", data: { name: `Non-Series Award-${RUN}`, description: { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Non-Series Award", 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: "#1e3a5f", width: 30 }, { color: "#f5f5f4", width: 5 }, { color: "#b91c1c", width: 30 }, ], devices: [], mirrored: false, }, experiencePoints: 25, grantConfig: { allowedGrantorAssignmentTypes: ["division"], recipientScope: "same_command", maxGrantsPerGrantor: null, }, }, overrideAccess: true, depth: 0, }); // Grant it to user A authSpy.mockResolvedValue({ user: adminUser }); const result1 = await grantAward({ recipientUserIds: [userAId], awardId: nonSeriesAward.id, reason: "Non-series award", }); expect(result1.success).toBe(true); expect(result1.data?.results).toHaveLength(1); expect(result1.data?.results[0].success).toBe(true); // Try to grant it again - should fail const result2 = await grantAward({ recipientUserIds: [userAId], awardId: nonSeriesAward.id, reason: "Non-series award again", }); expect(result2.success).toBe(true); expect(result2.data?.results).toHaveLength(1); expect(result2.data?.results[0].success).toBe(false); expect(result2.data?.results[0].error).toBe("Recipient already has this award."); // Cleanup try { await payload.delete({ collection: "awards", id: nonSeriesAward.id, overrideAccess: true }); } catch { // Ignore if already deleted } }); it("should fail when creating two awards with same seriesKey + tier", async () => { // Try to create a duplicate award with same seriesKey and tier await expect( payload.create({ collection: "awards", data: { name: `Duplicate Award-${RUN}`, description: { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Duplicate Award", 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", advancement: { seriesKey: `${RUN}-years-of-service`, tier: 1, }, ribbonDesign: { stripes: [ { color: "#1e3a5f", width: 30 }, { color: "#f5f5f4", width: 5 }, { color: "#b91c1c", width: 30 }, ], devices: [], mirrored: false, }, experiencePoints: 25, grantConfig: { allowedGrantorAssignmentTypes: ["division"], recipientScope: "same_command", maxGrantsPerGrantor: null, }, }, overrideAccess: true, depth: 0, }), ).rejects.toThrow("Tier 1 of this series already exists."); }); });