The retention purge job gated its whole body on activeRetention while every collection declares only postDeletion, and no app ever called registerRetentionPurgeJobs — retention was dead end to end (audit findings A2 + A3). The DSR soft delete now stamps a deletedAt tombstone on postDeletion collections (kept distinct from processingRestrictedAt so an Art. 18 restriction never feeds the purge), the job grace-purges tombstoned rows past postDeletion.duration with the declared action, core-cms injects the tombstone field + Payload task definitions, and bindAllProduction enqueues the first purge cycle at boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
84 lines
2.5 KiB
TypeScript
84 lines
2.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import type { CollectionConfig } from "payload";
|
|
import {
|
|
RETENTION_TOMBSTONE_FIELD,
|
|
hasPostDeletionPolicy,
|
|
withRetentionTombstone,
|
|
} from "@/payload/retention-purge/tombstone";
|
|
|
|
function makeCollection(custom?: Record<string, unknown>): CollectionConfig {
|
|
return {
|
|
slug: "things",
|
|
custom,
|
|
fields: [{ name: "title", type: "text" }],
|
|
} as CollectionConfig;
|
|
}
|
|
|
|
const postDeletionRetention = {
|
|
retention: {
|
|
purgeSchedule: "daily",
|
|
postDeletion: {
|
|
duration: "P30D",
|
|
trigger: "after-deletion",
|
|
action: "hard-delete",
|
|
},
|
|
},
|
|
};
|
|
|
|
describe("hasPostDeletionPolicy", () => {
|
|
it("is true when custom.retention.postDeletion is declared", () => {
|
|
expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("is false without retention or postDeletion", () => {
|
|
expect(hasPostDeletionPolicy(makeCollection())).toBe(false);
|
|
expect(
|
|
hasPostDeletionPolicy(
|
|
makeCollection({ retention: { purgeSchedule: "daily" } }),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("withRetentionTombstone", () => {
|
|
it("appends the deletedAt field to postDeletion collections", () => {
|
|
const result = withRetentionTombstone(
|
|
makeCollection(postDeletionRetention),
|
|
);
|
|
const names = result.fields.map((f) => (f as { name?: string }).name);
|
|
expect(names).toContain(RETENTION_TOMBSTONE_FIELD);
|
|
const tombstone = result.fields.find(
|
|
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
|
) as { type?: string; index?: boolean };
|
|
expect(tombstone.type).toBe("date");
|
|
expect(tombstone.index).toBe(true);
|
|
});
|
|
|
|
it("returns collections without a postDeletion policy unchanged", () => {
|
|
const collection = makeCollection();
|
|
expect(withRetentionTombstone(collection)).toBe(collection);
|
|
});
|
|
|
|
it("does not duplicate an already-declared tombstone field", () => {
|
|
const collection = {
|
|
...makeCollection(postDeletionRetention),
|
|
fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }],
|
|
} as CollectionConfig;
|
|
const result = withRetentionTombstone(collection);
|
|
expect(
|
|
result.fields.filter(
|
|
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("does not mutate the input collection", () => {
|
|
const collection = makeCollection(postDeletionRetention);
|
|
const before = collection.fields.length;
|
|
withRetentionTombstone(collection);
|
|
expect(collection.fields.length).toBe(before);
|
|
});
|
|
});
|