1
0
Fork 0
polaris-task-force/tests/int/announcements.int.spec.ts
Z8MB1E 8c95c1ee82 feat(announcements): add announcements system with roleDocs targeting and richText
- announcements collection: info/success/warning/error variants, banner/modal/dialog/toast display types, up to 3 action buttons, dismissible flag, activeFrom/activeUntil windows, audience targeting (all/users/roles) and path include/exclude matching copied from the Shims pattern
- pure client-safe evaluation lib (enabled, time window, audience via roleDoc IDs, path match) plus per-browser localStorage dismissals
- AnnouncementHost mounted in the (frontend) layout: banners render inline, toasts via sonner custom markup, modals and confirmation dialogs queue one at a time
- permissions announcements:create/read/update/delete + admin:announcements:manage
- description is Lexical richText rendered via RichText/lexical-content on all surfaces; role targeting uses the dynamic Roles collection
- hand-written migrations with snapshot sidecars (collection, roleDocs targeting, description richtext conversion) + integration tests (12 passing)
2026-09-22 21:08:46 -04:00

291 lines
9.7 KiB
TypeScript

import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { Role, User } from "@/payload-types";
import { Announcements } from "@/collections/Announcements";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
import {
isAnnouncementActive,
isWithinWindow,
matchesAudience,
matchesPath,
type AnnouncementEntry,
} from "@/lib/announcements/evaluate";
import {
readAnnouncementDismissals,
storeAnnouncementDismissal,
writeAnnouncementDismissals,
} from "@/lib/announcements/dismissals";
let payload: Payload;
const RUN = `ann-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
const NOW = 1_700_000_000_000;
const HOUR = 60 * 60 * 1000;
const base: AnnouncementEntry = {
id: 1,
title: "Test announcement",
variant: "info",
displayType: "banner",
enabled: true,
dismissible: true,
targeting: { audience: "all" },
paths: { matchType: "all" },
};
describe("announcement evaluation (pure)", () => {
it("treats missing bounds as an open window", () => {
expect(isWithinWindow({ ...base, activeFrom: null, activeUntil: null }, NOW)).toBe(true);
});
it("respects activeFrom and activeUntil", () => {
expect(isWithinWindow({ ...base, activeFrom: new Date(NOW + HOUR).toISOString() }, NOW)).toBe(
false,
);
expect(isWithinWindow({ ...base, activeFrom: new Date(NOW - HOUR).toISOString() }, NOW)).toBe(
true,
);
expect(isWithinWindow({ ...base, activeUntil: new Date(NOW).toISOString() }, NOW)).toBe(false);
expect(
isWithinWindow({ ...base, activeUntil: new Date(NOW + HOUR).toISOString() }, NOW),
).toBe(true);
});
it("audience all matches anyone; users matches by id; roles matches any listed roleDoc id", () => {
expect(matchesAudience({ ...base, targeting: { audience: "all" } }, 7, [])).toBe(true);
expect(
matchesAudience({ ...base, targeting: { audience: "users", users: [7, 9] } }, 9, []),
).toBe(true);
expect(
matchesAudience({ ...base, targeting: { audience: "users", users: [7, 9] } }, 8, []),
).toBe(false);
expect(matchesAudience({ ...base, targeting: { audience: "users" } }, null, [])).toBe(false);
expect(
matchesAudience(
{ ...base, targeting: { audience: "users", users: [{ id: 7 }] as never } },
7,
[],
),
).toBe(true);
expect(
matchesAudience(
{ ...base, targeting: { audience: "roles", roles: [3, 5] } },
null,
[2],
),
).toBe(false);
expect(
matchesAudience(
{ ...base, targeting: { audience: "roles", roles: [3, 5] } },
1,
[2, 5],
),
).toBe(true);
expect(
matchesAudience({ ...base, targeting: { audience: "roles" } }, 1, []),
).toBe(false);
});
it("isAnnouncementActive requires enabled", () => {
expect(isAnnouncementActive({ ...base, enabled: false }, 1, [], "/logistics", NOW)).toBe(
false,
);
expect(isAnnouncementActive(base, 1, [], "/logistics", NOW)).toBe(true);
});
it("path matching supports all, include prefixes, and exclude", () => {
expect(matchesPath("/logistics/market", { matchType: "all" })).toBe(true);
expect(
matchesPath("/logistics/market", { matchType: "include", paths: ["/logistics"] }),
).toBe(true);
expect(matchesPath("/wiki/some-page", { matchType: "include", paths: ["/logistics"] })).toBe(
false,
);
expect(
matchesPath("/wiki/some-page", { matchType: "exclude", paths: ["/logistics"] }),
).toBe(true);
expect(
matchesPath("/logistics/market", { matchType: "exclude", paths: ["/logistics"] }),
).toBe(false);
expect(matchesPath("/logistics", { matchType: "include", paths: ["/logistics"] })).toBe(true);
expect(
matchesPath("/logistics-market", { matchType: "include", paths: ["/logistics"] }),
).toBe(false);
});
it("isAnnouncementActive requires enabled", () => {
expect(isAnnouncementActive({ ...base, enabled: false }, 1, [], "/logistics", NOW)).toBe(
false,
);
expect(isAnnouncementActive(base, 1, [], "/logistics", NOW)).toBe(true);
});
});
describe("announcement dismissals (localStorage)", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("roundtrips dismissals and drops expired entries on read", () => {
writeAnnouncementDismissals({ "1": NOW + HOUR, "2": NOW - HOUR }, NOW);
const read = readAnnouncementDismissals(NOW);
expect(read["1"]).toBe(NOW + HOUR);
expect(read["2"]).toBeUndefined();
});
it("storeAnnouncementDismissal expires at activeUntil when set", () => {
storeAnnouncementDismissal(5, new Date(NOW + 2 * HOUR).toISOString(), NOW);
storeAnnouncementDismissal(6, new Date(NOW - 2 * HOUR).toISOString(), NOW);
storeAnnouncementDismissal(7, null, NOW);
const read = readAnnouncementDismissals(NOW);
expect(read["5"]).toBe(NOW + 2 * HOUR);
// Expired activeUntil still yields the 30-day fallback.
expect(read["6"]).toBeGreaterThan(NOW);
expect(read["7"]).toBeGreaterThan(NOW + 29 * 24 * HOUR);
});
});
describe("announcements collection access control", () => {
const roleIds: number[] = [];
const userIds: number[] = [];
const announcementIds: number[] = [];
let permittedUser: User;
let plainUser: User;
let devUser: User;
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
const role = (await payload.create({
collection: "roles",
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
overrideAccess: true,
depth: 0,
})) as unknown as Role;
roleIds.push(role.id);
return role;
};
const makeUser = async (label: string, roleId: number): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roleDocs: [roleId],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const accessDecision = async (
action: "create" | "read" | "update" | "delete",
user: User | null,
): Promise<unknown> => {
const fn = Announcements.access?.[action];
expect(typeof fn).toBe("function");
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
req: { user, payload },
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
invalidatePermissionCache();
const permittedRole = await makeRole("permitted", {
permissions: [
"announcements:create",
"announcements:read",
"announcements:update",
"announcements:delete",
],
});
const plainRole = await makeRole("plain", { permissions: [] });
const devRole = await makeRole("dev", { isSuperuser: true });
permittedUser = await makeUser("permitted", permittedRole.id);
plainUser = await makeUser("plain", plainRole.id);
devUser = await makeUser("dev", devRole.id);
}, TIMEOUT);
afterAll(async () => {
if (!payload) return;
for (const id of announcementIds) {
await payload
.delete({ collection: "announcements", id, overrideAccess: true })
.catch(() => {});
}
for (const id of userIds) {
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
});
it("grants CRUD to roles holding the announcements permissions", async () => {
expect(await accessDecision("read", permittedUser)).toBe(true);
expect(await accessDecision("create", permittedUser)).toBe(true);
expect(await accessDecision("update", permittedUser)).toBe(true);
expect(await accessDecision("delete", permittedUser)).toBe(true);
});
it("denies users without announcements permissions", async () => {
expect(await accessDecision("read", plainUser)).toBe(false);
expect(await accessDecision("create", plainUser)).toBe(false);
});
it("grants superusers everything and denies anonymous users", async () => {
expect(await accessDecision("read", devUser)).toBe(true);
expect(await accessDecision("delete", devUser)).toBe(true);
expect(await accessDecision("read", null)).toBe(false);
expect(await accessDecision("create", null)).toBe(false);
});
it("roundtrips an announcement through the local API", async () => {
const created = (await payload.create({
collection: "announcements",
data: {
title: `${RUN} maintenance window`,
variant: "warning",
displayType: "banner",
enabled: true,
dismissible: false,
targeting: { audience: "roles", roles: [roleIds[0]] },
paths: { matchType: "exclude", paths: ["/admin"] },
},
overrideAccess: true,
depth: 0,
})) as { id: number };
announcementIds.push(created.id);
const found = await payload.find({
collection: "announcements",
where: { enabled: { equals: true } },
limit: 50,
depth: 0,
overrideAccess: true,
});
const doc = found.docs.find((d) => d.id === created.id);
expect(doc).toBeDefined();
expect(doc?.title).toBe(`${RUN} maintenance window`);
expect((doc as { targeting: { roles: number[] } }).targeting.roles).toContain(roleIds[0]);
await payload.delete({ collection: "announcements", id: created.id, overrideAccess: true });
announcementIds.pop();
}, TIMEOUT);
});