import { describe, it, expect, vi } from "vitest"; import { policyFor, runRetentionScope, runOrgRetention, RETENTION_SCOPES, RETENTION_DEFAULTS, type RetentionActor } from "../processors/retention.js"; /** * The retention core with a MOCKED Prisma (no Testcontainers for a unit — * the DB-level behavior is covered by the retention integration spec). The * mock records every write (deleteMany/create/audit) so the tests assert the * SEMANTICS: per-scope isolation, hold-skip, dry vs execute, batch sizes, * the audited counts, and the report shape the console polls. */ function makePrisma() { const calls: Record = {}; const rec = (op: string) => calls[op] ?? (calls[op] = []); // The $transaction callback receives the SAME recorded delegates. const tx = { block: { deleteMany: vi.fn(async (a: unknown) => { rec("block.deleteMany").push(a); return { count: 3 }; }) }, page: { deleteMany: vi.fn(async (a: unknown) => { rec("page.deleteMany").push(a); return { count: 2 }; }) }, databaseRecord: { deleteMany: vi.fn(async (a: unknown) => { rec("databaseRecord.deleteMany").push(a); return { count: 1 }; }) }, comment: { deleteMany: vi.fn(async (a: unknown) => { rec("comment.deleteMany").push(a); return { count: 2 }; }) }, commentReply: { deleteMany: vi.fn(async (a: unknown) => { rec("commentReply.deleteMany").push(a); return { count: 1 }; }) }, commentReaction: { deleteMany: vi.fn(async (a: unknown) => { rec("commentReaction.deleteMany").push(a); return { count: 0 }; }) }, file: { deleteMany: vi.fn(async (a: unknown) => { rec("file.deleteMany").push(a); return { count: 2 }; }) }, fileVersion: { deleteMany: vi.fn(async (a: unknown) => { rec("fileVersion.deleteMany").push(a); return { count: 2 }; }) } }; // `prisma: never` — the mock is a structural stand-in; call sites pass // `prisma as never` where the core expects a PrismaClient. return { calls, prisma: { workspace: { findMany: vi.fn(async () => [{ id: "ws1" }, { id: "ws2" }]) }, retentionPolicy: { findUnique: vi.fn(async () => null), create: vi.fn(async (a: unknown) => { rec("retentionPolicy.create").push(a); return a; }), update: vi.fn(async (a: unknown) => { rec("retentionPolicy.update").push(a); return a; }) }, page: { findMany: vi.fn(async () => [{ id: "p1" }, { id: "p2" }]) }, comment: { findMany: vi.fn(async () => [{ id: "c1" }, { id: "c2" }]) }, commentReply: { findMany: vi.fn(async () => []) }, auditLog: { count: vi.fn(async () => 5), findMany: vi.fn(async () => [ { id: "a1" }, { id: "a2" }, { id: "a3" }, { id: "a4" }, { id: "a5" } ]), deleteMany: vi.fn(async (a: unknown) => { rec("auditLog.deleteMany").push(a); return { count: 5 }; }), create: vi.fn(async (a: unknown) => { rec("auditLog.create").push(a); return a; }) }, file: { findMany: vi.fn(async () => [{ id: "f1", size: 100 }, { id: "f2", size: 42 }]) }, $transaction: vi.fn(async (fn: (t: unknown) => Promise) => fn(tx)) } } as { calls: Record; prisma: never }; } const ACTOR: RetentionActor = { type: "USER", id: "admin1" }; describe("policyFor", () => { it("defaults: trash PURGE 30, comments PURGE 90, audit_logs HOLD 365, files PURGE 0", async () => { const { prisma } = makePrisma(); for (const scope of RETENTION_SCOPES) { const p = await policyFor(prisma as never, "org1", scope); expect(p.type).toBe(RETENTION_DEFAULTS[scope].type); expect(p.retainDays).toBe(RETENTION_DEFAULTS[scope].retainDays); } }); it("a stored row overrides the default", async () => { const { prisma } = makePrisma(); (prisma as never as { retentionPolicy: { findUnique: ReturnType } }) .retentionPolicy.findUnique.mockResolvedValueOnce({ type: "HOLD", retainDays: 7, config: { note: "legal" } }); const p = await policyFor(prisma as never, "org1", "trash"); expect(p).toEqual({ type: "HOLD", retainDays: 7, config: { note: "legal" } }); }); }); describe("runRetentionScope — HOLD / no-purge skip", () => { it("audit_logs default HOLD → skipped + retention.hold_skipped (NEVER purged)", async () => { const { prisma, calls } = makePrisma(); const report = await runRetentionScope({ prisma: prisma as never, orgId: "org1", scope: "audit_logs", mode: "execute", actor: ACTOR }); expect(report.status).toBe("skipped"); expect(report.reason).toBe("hold"); expect(calls["auditLog.deleteMany"] ?? []).toHaveLength(0); const audits = calls["auditLog.create"] ?? []; expect(audits).toHaveLength(1); expect((audits[0] as { data: { action: string } }).data.action).toBe("retention.hold_skipped"); }); it("files default retainDays 0 → no_purge skip (even in execute mode)", async () => { const { prisma, calls } = makePrisma(); const report = await runRetentionScope({ prisma: prisma as never, orgId: "org1", scope: "files", mode: "execute", actor: ACTOR }); expect(report.status).toBe("skipped"); expect(report.reason).toBe("no_purge"); expect(calls["file.deleteMany"] ?? []).toHaveLength(0); }); }); describe("runRetentionScope — dry vs execute", () => { it("trash DRY reports the count, deletes NOTHING, audits retention.dry_run", async () => { const { prisma, calls } = makePrisma(); const report = await runRetentionScope({ prisma: prisma as never, orgId: "org1", scope: "trash", mode: "dry", actor: ACTOR }); expect(report.status).toBe("dry_run"); expect(report.counts).toEqual({ pages: 2, blocks: 0, comments: 0, files: 0, bytesFreed: 0 }); expect(calls["page.deleteMany"] ?? []).toHaveLength(0); expect(calls["block.deleteMany"] ?? []).toHaveLength(0); const audits = calls["auditLog.create"] ?? []; expect(audits).toHaveLength(1); expect((audits[0] as { data: { action: string; metadata: { count: number } } }).data.action).toBe( "retention.dry_run" ); expect((audits[0] as { data: { metadata: { count: number } } }).data.metadata.count).toBe(2); }); it("trash EXECUTE deletes in the org's workspaces (cutoff-filtered) + audits retention.purged", async () => { const { prisma, calls } = makePrisma(); const report = await runRetentionScope({ prisma: prisma as never, orgId: "org1", scope: "trash", mode: "execute", actor: ACTOR }); expect(report.status).toBe("purged"); // The read was org-scoped + cutoff-filtered. const readWhere = (prisma as never as { page: { findMany: ReturnType } }) .page.findMany.mock.calls[0]![0] as { where: { workspaceId: { in: string[] }; trashedAt: { lt: Date } } }; expect(readWhere.where.workspaceId.in).toEqual(["ws1", "ws2"]); expect(readWhere.where.trashedAt.lt).toBeInstanceOf(Date); // The deletes hit the same org scope (via the batched ids). expect(calls["page.deleteMany"]).toHaveLength(1); expect(calls["block.deleteMany"]).toHaveLength(1); expect(calls["databaseRecord.deleteMany"]).toHaveLength(1); const audits = calls["auditLog.create"] ?? []; expect((audits[0] as { data: { action: string } }).data.action).toBe("retention.purged"); // The audit is ORG-scoped (resourceType organization + resourceId orgId). const auditData = (audits[0] as { data: { resourceType: string; resourceId: string; actorType: string; actorId: string } }).data; expect(auditData.resourceType).toBe("organization"); expect(auditData.resourceId).toBe("org1"); expect(auditData.actorType).toBe("USER"); expect(auditData.actorId).toBe("admin1"); }); }); describe("runRetentionScope — comments", () => { it("a thread with a RECENT reply survives the purge", async () => { const { prisma, calls } = makePrisma(); (prisma as never as { commentReply: { findMany: ReturnType } }) .commentReply.findMany.mockResolvedValueOnce([{ commentId: "c1" }]); // c1 has a recent reply const report = await runRetentionScope({ prisma: prisma as never, orgId: "org1", scope: "comments", mode: "execute", actor: ACTOR }); expect(report.status).toBe("purged"); const del = (calls["comment.deleteMany"] ?? [])[0] as { where: { id: { in: string[] } } }; expect(del.where.id.in).toEqual(["c2"]); // c1 kept }); }); describe("runOrgRetention — per-scope isolation", () => { it("a throw in one scope does not stop the others (+ retention.failed audit)", async () => { const { prisma, calls } = makePrisma(); // Make trash throw; comments + audit_logs + files proceed. (prisma as never as { page: { findMany: ReturnType } }).page.findMany.mockRejectedValueOnce( new Error("boom") ); const out = await runOrgRetention({ prisma: prisma as never, orgId: "org1", mode: "dry", actor: ACTOR }); expect((out["trash"] as { status: string }).status).toBe("failed"); expect((out["comments"] as { status: string }).status).toBe("dry_run"); expect((out["audit_logs"] as { status: string }).status).toBe("skipped"); expect((out["files"] as { status: string }).status).toBe("skipped"); const failed = (calls["auditLog.create"] ?? []).filter( (a) => (a as { data: { action: string } }).data.action === "retention.failed" ); expect(failed).toHaveLength(1); expect((failed[0] as { data: { metadata: { scope: string; error: string } } }).data.metadata).toMatchObject({ scope: "trash", error: "boom" }); }); });