import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { SanitizedConfig } from "payload"; import type { IJobQueue } from "@/jobs/job-queue.interface"; import type { AuditLogProtocol } from "@/di/bind-protocols"; import { parseDurationMs, scheduleDelayMs, buildPurgeHandler, registerRetentionPurgeJobs, type PayloadPurgeApi, type RetentionPurgeJobDeps, } from "./retention-purge.job"; // ---- test helpers ---- type MockCollection = { slug: string; custom?: { retention?: Record }; fields?: Array<{ name?: string; custom?: { pii?: unknown } }>; }; function makeConfig(collections: MockCollection[]): SanitizedConfig { return { collections } as unknown as SanitizedConfig; } function makeQueue() { const enqueue = vi.fn().mockResolvedValue({ jobId: "job-1" }); const queue = { enqueue } as unknown as IJobQueue; return { queue, enqueue }; } function makePayloadApi( docs: Array> = [], ): PayloadPurgeApi { return { find: vi.fn().mockResolvedValue({ docs }), update: vi.fn().mockResolvedValue({}), delete: vi.fn().mockResolvedValue({}), }; } function makeAuditLog(): { auditLog: AuditLogProtocol; record: ReturnType; } { const record = vi.fn().mockResolvedValue(undefined); return { auditLog: { record } as AuditLogProtocol, record }; } // ---- parseDurationMs ---- describe("parseDurationMs", () => { it("parses years: P2Y → 2 × 365 days", () => { expect(parseDurationMs("P2Y")).toBe(2 * 365 * 86_400_000); }); it("parses months: P1M → 30 days", () => { expect(parseDurationMs("P1M")).toBe(30 * 86_400_000); }); it("parses weeks: P1W → 7 days", () => { expect(parseDurationMs("P1W")).toBe(7 * 86_400_000); }); it("parses days: P30D → 30 days", () => { expect(parseDurationMs("P30D")).toBe(30 * 86_400_000); }); it("combines components: P1Y2M3D", () => { expect(parseDurationMs("P1Y2M3D")).toBe( 365 * 86_400_000 + 60 * 86_400_000 + 3 * 86_400_000, ); }); it("returns 0 for P0D", () => { expect(parseDurationMs("P0D")).toBe(0); }); it("returns 0 for unrecognised strings", () => { expect(parseDurationMs("invalid")).toBe(0); expect(parseDurationMs("")).toBe(0); expect(parseDurationMs("PT2H")).toBe(0); }); }); // ---- scheduleDelayMs ---- describe("scheduleDelayMs", () => { it("returns 1 day for 'daily'", () => { expect(scheduleDelayMs("daily")).toBe(86_400_000); }); it("returns 7 days for 'weekly'", () => { expect(scheduleDelayMs("weekly")).toBe(7 * 86_400_000); }); it("returns 30 days for 'monthly'", () => { expect(scheduleDelayMs("monthly")).toBe(30 * 86_400_000); }); it("falls back to 1 day for cron-style strings", () => { expect(scheduleDelayMs("0 3 * * 0")).toBe(86_400_000); }); }); // ---- registerRetentionPurgeJobs ---- describe("registerRetentionPurgeJobs", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("enqueues one job per collection with a purgeSchedule", async () => { const { queue, enqueue } = makeQueue(); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily" } } }, { slug: "articles", custom: { retention: { purgeSchedule: "weekly" } } }, ]); await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); expect(enqueue).toHaveBeenCalledTimes(2); }); it("skips collections without a retention config", async () => { const { queue, enqueue } = makeQueue(); const config = makeConfig([{ slug: "media" }]); await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); expect(enqueue).not.toHaveBeenCalled(); }); it("uses the correct taskSlug and runAt for each schedule type", async () => { const { queue, enqueue } = makeQueue(); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "weekly" } } }, ]); await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); expect(enqueue).toHaveBeenCalledWith( "retention-purge--users", {}, { runAt: new Date("2026-01-08T00:00:00.000Z") }, ); }); it("schedules daily purge 1 day from now", async () => { const { queue, enqueue } = makeQueue(); const config = makeConfig([ { slug: "sessions", custom: { retention: { purgeSchedule: "daily" } } }, ]); await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); expect(enqueue).toHaveBeenCalledWith( "retention-purge--sessions", {}, { runAt: new Date("2026-01-02T00:00:00.000Z") }, ); }); }); // ---- buildPurgeHandler — input validation ---- describe("buildPurgeHandler — input validation", () => { it("throws when the collection slug is not found in the config", () => { const { queue } = makeQueue(); const config = makeConfig([]); expect(() => buildPurgeHandler("missing", { queue, config, getPayload: vi.fn() }), ).toThrow("collection not found: missing"); }); it("throws when the collection has no retention config", () => { const { queue } = makeQueue(); const config = makeConfig([{ slug: "media" }]); expect(() => buildPurgeHandler("media", { queue, config, getPayload: vi.fn() }), ).toThrow("no retention config on collection: media"); }); }); // ---- buildPurgeHandler — hard-delete branch ---- describe("buildPurgeHandler — hard-delete", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("queries by createdAt for from-creation trigger", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([]); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P2Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P30D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(payload.find).toHaveBeenCalledWith( expect.objectContaining({ where: { createdAt: { less_than: new Date( Date.now() - parseDurationMs("P2Y"), ).toISOString(), }, }, }), ); }); it("queries by updatedAt for from-last-access trigger", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([]); const config = makeConfig([ { slug: "sessions", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P30D", trigger: "from-last-access" }, postDeletion: { action: "hard-delete", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("sessions", deps)(); expect(payload.find).toHaveBeenCalledWith( expect.objectContaining({ where: { updatedAt: { less_than: expect.any(String) } }, }), ); expect(payload.find).not.toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: expect.anything() }), }), ); }); it("deletes each returned row", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "row-1" }, { id: "row-2" }]); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(payload.delete).toHaveBeenCalledTimes(2); expect(payload.delete).toHaveBeenCalledWith({ collection: "users", id: "row-1", overrideAccess: true, }); expect(payload.delete).toHaveBeenCalledWith({ collection: "users", id: "row-2", overrideAccess: true, }); expect(payload.update).not.toHaveBeenCalled(); }); it("re-enqueues itself for the next purge cycle", async () => { const { queue, enqueue } = makeQueue(); const payload = makePayloadApi([]); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(enqueue).toHaveBeenCalledOnce(); expect(enqueue).toHaveBeenCalledWith( "retention-purge--users", {}, { runAt: new Date("2026-01-02T00:00:00.000Z") }, ); }); it("defaults to hard-delete when postDeletion is not declared", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "row-x" }]); const config = makeConfig([ { slug: "logs", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("logs", deps)(); expect(payload.delete).toHaveBeenCalledWith({ collection: "logs", id: "row-x", overrideAccess: true, }); expect(payload.update).not.toHaveBeenCalled(); }); }); // ---- buildPurgeHandler — postDeletion grace purge (A2) ---- describe("buildPurgeHandler — postDeletion grace purge", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); function postDeletionOnlyConfig( action: "hard-delete" | "pseudonymize", fields: MockCollection["fields"] = [], ) { return makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", postDeletion: { action, duration: "P30D", trigger: "after-deletion", }, }, }, fields, }, ]); } it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([]); const deps: RetentionPurgeJobDeps = { queue, config: postDeletionOnlyConfig("hard-delete"), getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(payload.find).toHaveBeenCalledWith( expect.objectContaining({ collection: "users", where: { deletedAt: { less_than: new Date( Date.now() - parseDurationMs("P30D"), ).toISOString(), }, }, }), ); }); it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([ { id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days { id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days { id: "row-live" }, // never soft-deleted ]); const deps: RetentionPurgeJobDeps = { queue, config: postDeletionOnlyConfig("hard-delete"), getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(payload.delete).toHaveBeenCalledTimes(1); expect(payload.delete).toHaveBeenCalledWith({ collection: "users", id: "row-old", overrideAccess: true, }); }); it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([ { id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" }, ]); const deps: RetentionPurgeJobDeps = { queue, config: postDeletionOnlyConfig("pseudonymize", [ { name: "email", custom: { pii: { category: "contact-email" } } }, { name: "status" }, ]), getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(payload.update).toHaveBeenCalledWith({ collection: "users", id: "row-old", data: { email: null }, overrideAccess: true, }); expect(payload.delete).not.toHaveBeenCalled(); }); it("records a retention-policy audit entry per purged row", async () => { const { queue } = makeQueue(); const { auditLog, record } = makeAuditLog(); const payload = makePayloadApi([ { id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" }, ]); const deps: RetentionPurgeJobDeps = { queue, config: postDeletionOnlyConfig("hard-delete"), getPayload: vi.fn().mockResolvedValue(payload), auditLog, }; await buildPurgeHandler("users", deps)(); expect(record).toHaveBeenCalledTimes(1); expect(record).toHaveBeenCalledWith( expect.objectContaining({ action: "DELETE", reason: "retention-policy", resource: { type: "users", id: "row-old" }, }), ); }); it("still runs activeRetention alongside postDeletion", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([ { id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" }, ]); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P2Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P30D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); // one find per branch: createdAt (activeRetention) + deletedAt (postDeletion) expect(payload.find).toHaveBeenCalledTimes(2); // the fake returns the tombstoned row for both branches: the // activeRetention branch deletes it by date, the postDeletion branch by // tombstone — 2 delete calls for the same doc through different policies. expect(payload.delete).toHaveBeenCalledTimes(2); }); it("re-enqueues the next cycle for postDeletion-only collections", async () => { const { queue, enqueue } = makeQueue(); const payload = makePayloadApi([]); const deps: RetentionPurgeJobDeps = { queue, config: postDeletionOnlyConfig("hard-delete"), getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("users", deps)(); expect(enqueue).toHaveBeenCalledWith( "retention-purge--users", {}, { runAt: new Date("2026-01-02T00:00:00.000Z") }, ); }); }); // ---- buildPurgeHandler — pseudonymize branch ---- describe("buildPurgeHandler — pseudonymize", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("nulls only PII-annotated fields for each matched row", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "row-2" }]); const config = makeConfig([ { slug: "contacts", custom: { retention: { purgeSchedule: "monthly", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "pseudonymize", duration: "P30D", trigger: "after-deletion", }, }, }, fields: [ { name: "email", custom: { pii: { category: "contact-email" } } }, { name: "phone", custom: { pii: { category: "contact-phone" } } }, { name: "status" }, // no pii — must NOT be nulled ], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("contacts", deps)(); expect(payload.update).toHaveBeenCalledWith({ collection: "contacts", id: "row-2", data: { email: null, phone: null }, overrideAccess: true, }); expect(payload.delete).not.toHaveBeenCalled(); }); it("skips update when no PII fields are declared on the collection", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "row-3" }]); const config = makeConfig([ { slug: "tags", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "pseudonymize", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [ { name: "label" }, // no pii ], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await buildPurgeHandler("tags", deps)(); expect(payload.update).not.toHaveBeenCalled(); expect(payload.delete).not.toHaveBeenCalled(); }); }); // ---- buildPurgeHandler — audit emission ---- describe("buildPurgeHandler — audit emission", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("emits one audit record per processed row", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "a" }, { id: "b" }]); const { auditLog, record } = makeAuditLog(); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), auditLog, }; await buildPurgeHandler("users", deps)(); expect(record).toHaveBeenCalledTimes(2); expect(record).toHaveBeenCalledWith( expect.objectContaining({ actorId: "system", actorType: "system", action: "DELETE", reason: "retention-policy", outcome: "success", from: { ipTruncated: "system", userAgent: "background-job" }, }), ); }); it("includes resource type and id in the audit entry", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "row-42" }]); const { auditLog, record } = makeAuditLog(); const config = makeConfig([ { slug: "orders", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), auditLog, }; await buildPurgeHandler("orders", deps)(); expect(record).toHaveBeenCalledWith( expect.objectContaining({ resource: { type: "orders", id: "row-42" }, }), ); }); it("gracefully skips audit emission when auditLog is not provided", async () => { const { queue } = makeQueue(); const payload = makePayloadApi([{ id: "x" }]); const config = makeConfig([ { slug: "users", custom: { retention: { purgeSchedule: "daily", activeRetention: { duration: "P1Y", trigger: "from-creation" }, postDeletion: { action: "hard-delete", duration: "P0D", trigger: "after-deletion", }, }, }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), }; await expect(buildPurgeHandler("users", deps)()).resolves.toBeUndefined(); }); it("skips all processing and audit when activeRetention is not declared", async () => { const { queue, enqueue } = makeQueue(); const payload = makePayloadApi([{ id: "y" }]); const { auditLog, record } = makeAuditLog(); const config = makeConfig([ { slug: "logs", custom: { retention: { purgeSchedule: "daily" } }, fields: [], }, ]); const deps: RetentionPurgeJobDeps = { queue, config, getPayload: vi.fn().mockResolvedValue(payload), auditLog, }; await buildPurgeHandler("logs", deps)(); expect(payload.find).not.toHaveBeenCalled(); expect(record).not.toHaveBeenCalled(); // Still re-enqueues for the next cycle expect(enqueue).toHaveBeenCalledOnce(); }); });