Add a Profiles afterChange hook that diffs progression.awards against previousDoc (row id, falling back to award+date composite key) and sends an award:granted notification including the grant reason and issuer. Register award:granted as a muteable notification type.
226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
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<number> => {
|
|
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);
|
|
});
|
|
});
|