import { test, describe } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { findCollectionFiles, parseCollectionFile, validateRetentionPolicy, buildRetentionPolicy, renderRetentionPolicyYaml, unifiedDiff, OUTPUT_PATH, } from "./emit-retention-policy.mjs"; // ---- Fixtures ---- const USERS_TS = ` import type { CollectionConfig } from "payload"; export const users: CollectionConfig = { slug: "users", auth: true, custom: { retention: { purgeSchedule: "daily", postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" }, }, }, fields: [{ name: "displayName", type: "text" }], }; `; const ARTICLES_TS = ` import type { CollectionConfig } from "payload"; export const articles: CollectionConfig = { slug: "articles", custom: { retention: { purgeSchedule: "monthly", postDeletion: { duration: "P90D", trigger: "after-deletion", action: "hard-delete" }, }, }, fields: [{ name: "title", type: "text" }], }; `; const ALL_FIELDS_TS = ` import type { CollectionConfig } from "payload"; export const full: CollectionConfig = { slug: "full", custom: { retention: { purgeSchedule: "weekly", activeRetention: { duration: "P1Y", trigger: "from-last-access" }, postDeletion: { duration: "P30D", trigger: "after-deletion", action: "pseudonymize" }, coldArchive: { duration: "P2Y", trigger: "from-creation" }, }, }, fields: [], }; `; const MISSING_PURGE_TS = ` import type { CollectionConfig } from "payload"; export const orphan: CollectionConfig = { slug: "orphan", custom: { retention: { postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" } } }, fields: [], }; `; const NO_RETENTION_TS = ` import type { CollectionConfig } from "payload"; export const bare: CollectionConfig = { slug: "bare", fields: [], }; `; // ---- Test helpers ---- function makeRepo(collections) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-retention-policy-")); for (const [pkgSlug, src] of Object.entries(collections)) { const dir = path.join( root, "packages", pkgSlug, "src", "integrations", "cms", "collections", ); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `${pkgSlug}.ts`), src, "utf8"); } return root; } function parseFixtures(fixtures) { const root = makeRepo(fixtures); const files = findCollectionFiles(root); return files.map((f) => parseCollectionFile(f)).filter(Boolean); } // ---- Tests ---- describe("validateRetentionPolicy — required fields", () => { test("returns no errors when all collections have purgeSchedule", () => { const raw = parseFixtures({ auth: USERS_TS, blog: ARTICLES_TS }); assert.deepEqual(validateRetentionPolicy(raw), []); }); test("returns an error for a collection missing purgeSchedule", () => { const raw = parseFixtures({ orphan: MISSING_PURGE_TS }); const errors = validateRetentionPolicy(raw); assert.equal(errors.length, 1); assert.ok(errors[0].includes(`"orphan"`)); assert.ok(errors[0].includes("purgeSchedule")); assert.ok(errors[0].includes("Hint:")); }); test("returns an error for a collection with no custom.retention at all", () => { const raw = parseFixtures({ bare: NO_RETENTION_TS }); const errors = validateRetentionPolicy(raw); assert.equal(errors.length, 1); assert.ok(errors[0].includes(`"bare"`)); }); test("returns multiple errors when multiple collections are missing purgeSchedule", () => { const raw = [ { slug: "alpha", custom: {} }, { slug: "beta", custom: { retention: {} } }, ]; const errors = validateRetentionPolicy(raw); assert.equal(errors.length, 2); assert.ok(errors.some((e) => e.includes('"alpha"'))); assert.ok(errors.some((e) => e.includes('"beta"'))); }); test("returns no errors for empty collections array", () => { assert.deepEqual(validateRetentionPolicy([]), []); }); }); describe("buildRetentionPolicy", () => { test("builds a map with slug and purgeSchedule for each collection", () => { const raw = parseFixtures({ auth: USERS_TS, blog: ARTICLES_TS }); const map = buildRetentionPolicy(raw); assert.ok("users" in map); assert.ok("articles" in map); assert.equal(map.users.purgeSchedule, "daily"); assert.equal(map.articles.purgeSchedule, "monthly"); assert.equal(map.users.slug, "users"); }); test("includes postDeletion when present", () => { const raw = parseFixtures({ auth: USERS_TS }); const map = buildRetentionPolicy(raw); assert.ok(map.users.postDeletion); assert.equal(map.users.postDeletion.duration, "P30D"); assert.equal(map.users.postDeletion.trigger, "after-deletion"); assert.equal(map.users.postDeletion.action, "hard-delete"); }); test("includes all optional fields (activeRetention, postDeletion, coldArchive)", () => { const raw = parseFixtures({ full: ALL_FIELDS_TS }); const map = buildRetentionPolicy(raw); assert.ok(map.full.activeRetention); assert.equal(map.full.activeRetention.duration, "P1Y"); assert.equal(map.full.activeRetention.trigger, "from-last-access"); assert.ok(map.full.coldArchive); assert.equal(map.full.coldArchive.duration, "P2Y"); assert.equal(map.full.coldArchive.trigger, "from-creation"); assert.ok(map.full.postDeletion); assert.equal(map.full.postDeletion.action, "pseudonymize"); }); test("omits absent optional fields from entry", () => { const raw = [ { slug: "simple", custom: { retention: { purgeSchedule: "weekly" } } }, ]; const map = buildRetentionPolicy(raw); assert.ok(!("activeRetention" in map.simple)); assert.ok(!("coldArchive" in map.simple)); assert.ok(!("postDeletion" in map.simple)); }); test("returns empty map for no collections", () => { assert.deepEqual(buildRetentionPolicy([]), {}); }); }); describe("renderRetentionPolicyYaml", () => { test("renders collections in alphabetical order", () => { const map = { users: { slug: "users", purgeSchedule: "daily" }, articles: { slug: "articles", purgeSchedule: "monthly" }, }; const yaml = renderRetentionPolicyYaml(map); const articlesIdx = yaml.indexOf(" articles:"); const usersIdx = yaml.indexOf(" users:"); assert.ok(articlesIdx < usersIdx, "articles should appear before users"); }); test("renders purgeSchedule and slug for every collection", () => { const map = { blog: { slug: "blog", purgeSchedule: "monthly" } }; const yaml = renderRetentionPolicyYaml(map); assert.ok(yaml.includes("purgeSchedule: monthly")); assert.ok(yaml.includes("slug: blog")); }); test("renders postDeletion sub-object with sorted keys", () => { const map = { users: { slug: "users", purgeSchedule: "daily", postDeletion: { action: "hard-delete", duration: "P30D", trigger: "after-deletion", }, }, }; const yaml = renderRetentionPolicyYaml(map); assert.ok(yaml.includes("postDeletion:")); assert.ok(yaml.includes("action: hard-delete")); assert.ok(yaml.includes("duration: P30D")); assert.ok(yaml.includes("trigger: after-deletion")); // action (a) comes before duration (d) comes before trigger (t) const actionIdx = yaml.indexOf("action:"); const durationIdx = yaml.indexOf("duration:"); const triggerIdx = yaml.indexOf("trigger:"); assert.ok(actionIdx < durationIdx && durationIdx < triggerIdx); }); test("renders activeRetention and coldArchive when present", () => { const raw = parseFixtures({ full: ALL_FIELDS_TS }); const map = buildRetentionPolicy(raw); const yaml = renderRetentionPolicyYaml(map); assert.ok(yaml.includes("activeRetention:")); assert.ok(yaml.includes("coldArchive:")); }); test("rendered YAML includes header comment", () => { const yaml = renderRetentionPolicyYaml({}); assert.ok(yaml.startsWith("# compliance/retention-policy.yml")); assert.ok(yaml.includes("emit-retention-policy.mjs")); }); test("output is deterministic across multiple calls", () => { const raw = parseFixtures({ auth: USERS_TS, blog: ARTICLES_TS }); const map = buildRetentionPolicy(raw); assert.equal( renderRetentionPolicyYaml(map), renderRetentionPolicyYaml(map), ); }); test("OUTPUT_PATH is compliance/retention-policy.yml", () => { assert.equal(OUTPUT_PATH, "compliance/retention-policy.yml"); }); }); describe("--check mode (integration)", () => { test("passes when committed file matches generated output", () => { const raw = parseFixtures({ auth: USERS_TS }); const map = buildRetentionPolicy(raw); const yaml = renderRetentionPolicyYaml(map); const diff = unifiedDiff(yaml, yaml, OUTPUT_PATH); assert.equal(diff, null, "no diff expected when files match"); }); test("fails with readable diff when committed file is stale", () => { const raw = parseFixtures({ auth: USERS_TS }); const map = buildRetentionPolicy(raw); const yaml = renderRetentionPolicyYaml(map); const stale = "# stale content\ncollections: {}\n"; const diff = unifiedDiff(stale, yaml, OUTPUT_PATH); assert.ok(diff !== null, "diff expected when file is stale"); assert.ok( diff.includes(`--- ${OUTPUT_PATH}`), "diff should include the filename header", ); assert.ok( diff.includes("- # stale content"), "diff should show removed line", ); assert.ok(diff.includes("Line"), "diff should include line numbers"); }); });