import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Award, Profile, User } from "@/payload-types"; import { AwardGrantEntry, diffGrantedAwards } from "@/lib/awards"; let payload: Payload; const RUN = `aw-${Date.now().toString(36)}`; function richText(text: string) { return { root: { type: "root", format: "" as const, indent: 0, version: 1, children: [ { type: "paragraph", format: "" as const, indent: 0, version: 1, children: [ { type: "text", format: 0, style: "", mode: "normal" as const, text, version: 1 }, ], direction: "ltr" as const, }, ], direction: "ltr" as const, }, }; } describe("Award grant notifications", () => { let user: User; let profileId: number; let medalId: number; let ribbonId: number; const countGrantNotifications = async (userId: number) => { const res = await payload.find({ collection: "user-notifications", where: { user: { equals: userId }, type: { equals: "award:granted" } }, limit: 50, overrideAccess: true, depth: 0, }); return res.docs as Array<{ id: number; title?: string | null; message?: string | null; link?: string | null; }>; }; const setAwards = async (awards: AwardGrantEntry[]) => { await payload.update({ collection: "profiles", id: profileId, data: { progression: { awards } }, overrideAccess: true, depth: 0, }); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); const makeAward = async (name: string, type: "medal" | "ribbon"): Promise => { const created = (await payload.create({ collection: "awards", data: { name, description: richText(`${RUN} test award`), type, }, overrideAccess: true, depth: 0, })) as unknown as Award; return created.id; }; medalId = await makeAward(`${RUN} Test Medal`, "medal"); ribbonId = await makeAward(`${RUN} Test Ribbon`, "ribbon"); user = (await payload.create({ collection: "users", data: { username: `${RUN}-operator`, discordUsername: `${RUN}-operator-discord`, displayName: `${RUN} Operator`, steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", }, overrideAccess: true, depth: 0, })) as unknown as User; // The Users afterChange hook auto-creates a profile for new users. const profiles = await payload.find({ collection: "profiles", where: { user: { equals: user.id } }, limit: 1, depth: 0, overrideAccess: true, }); if (profiles.docs.length === 0) throw new Error("Test setup failed: profile was not auto-created."); profileId = profiles.docs[0].id; }); afterAll(async () => { await payload.delete({ collection: "profiles", id: profileId, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "users", id: user.id, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "awards", id: medalId, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "awards", id: ribbonId, overrideAccess: true }).catch(() => {}); }); it("notifies the user when an award is granted", async () => { await setAwards([{ award: medalId, awardedAt: new Date().toISOString() }]); const docs = await countGrantNotifications(user.id); expect(docs).toHaveLength(1); expect(docs[0].title).toBe(`Award received: ${RUN} Test Medal`); expect(docs[0].link).toBe(`/profile/${user.username}`); }); it("does not notify when an existing entry is edited", async () => { const current = await payload.findByID({ collection: "profiles", id: profileId, depth: 0, overrideAccess: true, }); const entries = (current as unknown as Profile).progression?.awards ?? []; expect(entries).toHaveLength(1); await setAwards([{ ...entries[0], awardReason: "For exceptional service." }]); expect(await countGrantNotifications(user.id)).toHaveLength(1); }); it("does not notify when an award is revoked", async () => { await setAwards([]); expect(await countGrantNotifications(user.id)).toHaveLength(1); }); it("notifies again when a revoked award is re-granted", async () => { await setAwards([ { award: medalId, awardedAt: new Date().toISOString(), awardReason: "For exceptional service." }, ]); expect(await countGrantNotifications(user.id)).toHaveLength(2); }); it("notifies once per newly granted award in a single update", async () => { const current = await payload.findByID({ collection: "profiles", id: profileId, depth: 0, overrideAccess: true, }); const existing = (current as unknown as Profile).progression?.awards ?? []; await setAwards([ ...existing, { award: ribbonId, awardedAt: new Date().toISOString() }, { award: medalId, awardedAt: new Date(Date.now() + 60_000).toISOString() }, ]); expect(await countGrantNotifications(user.id)).toHaveLength(4); }); it("includes the reason and unit attribution in the message", async () => { const docs = await countGrantNotifications(user.id); expect(docs.some((d) => d.message?.includes("For exceptional service."))).toBe(true); // No issuer on any of these grants, so they are attributed to the unit. expect(docs.every((d) => d.message?.includes("On behalf of the unit."))).toBe(true); }); }); describe("diffGrantedAwards (pure)", () => { const entry = (over: Partial<{ id?: string; awardId?: number; at?: string }>) => ({ id: over.id, award: over.awardId ?? 1, awardedAt: over.at ?? "2026-01-01T00:00:00.000Z", }); it("returns empty when nothing changed", () => { const doc = { progression: { awards: [entry({ id: "a" })] } }; expect(diffGrantedAwards(doc, doc)).toHaveLength(0); }); it("detects new rows by row id", () => { const before = { progression: { awards: [entry({ id: "a" })] } }; const after = { progression: { awards: [entry({ id: "a" }), entry({ id: "b", awardId: 2 })] } }; expect(diffGrantedAwards(before, after).map((e) => e.id)).toEqual(["b"]); }); it("falls back to award+date identity when rows have no id", () => { const before = { progression: { awards: [entry({ awardId: 1 })] } }; // Same award + date, still no ids → not a new grant (reason-only edits keep the date). const after = { progression: { awards: [entry({ awardId: 1 })] } }; expect(diffGrantedAwards(before, after)).toHaveLength(0); // New issue date → new grant. const regranted = { progression: { awards: [entry({ awardId: 1, at: "2026-02-01T00:00:00.000Z" })] } }; expect(diffGrantedAwards(before, regranted)).toHaveLength(1); }); it("handles the same award granted twice (multiset)", () => { const before = { progression: { awards: [entry({ id: "a", awardId: 1 }), entry({ id: "b", awardId: 1 })] }, }; const after = { progression: { awards: [ entry({ id: "a", awardId: 1 }), entry({ id: "b", awardId: 1 }), entry({ id: "c", awardId: 1 }), ], }, }; expect(diffGrantedAwards(before, after).map((e) => e.id)).toEqual(["c"]); }); it("treats a created profile with awards as grants", () => { const after = { progression: { awards: [entry({ id: "a" })] } }; expect(diffGrantedAwards(null, after)).toHaveLength(1); }); });