Files
agentic-dev/packages/core-shared/src/payload/retention-purge/tombstone.test.ts
Danijel Martinek 413ac0273c feat(core-shared): grace-purge soft-deleted rows + boot registration
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>
2026-07-10 18:25:46 +02:00

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);
});
});