chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
292
scripts/compliance/emit-retention-policy.test.mjs
Normal file
292
scripts/compliance/emit-retention-policy.test.mjs
Normal file
@@ -0,0 +1,292 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user