1import { describe, it, expect, vi } from "vitest";
2import {
3 policyFor,
4 runRetentionScope,
5 runOrgRetention,
6 RETENTION_SCOPES,
7 RETENTION_DEFAULTS,
8 type RetentionActor
9} from "../processors/retention.js";
10
11
18
19function makePrisma() {
20 const calls: Record<string, unknown[]> = {};
21 const rec = (op: string) => calls[op] ?? (calls[op] = []);
22
23 const tx = {
24 block: { deleteMany: vi.fn(async (a: unknown) => { rec("block.deleteMany").push(a); return { count: 3 }; }) },
25 page: { deleteMany: vi.fn(async (a: unknown) => { rec("page.deleteMany").push(a); return { count: 2 }; }) },
26 databaseRecord: { deleteMany: vi.fn(async (a: unknown) => { rec("databaseRecord.deleteMany").push(a); return { count: 1 }; }) },
27 comment: { deleteMany: vi.fn(async (a: unknown) => { rec("comment.deleteMany").push(a); return { count: 2 }; }) },
28 commentReply: { deleteMany: vi.fn(async (a: unknown) => { rec("commentReply.deleteMany").push(a); return { count: 1 }; }) },
29 commentReaction: { deleteMany: vi.fn(async (a: unknown) => { rec("commentReaction.deleteMany").push(a); return { count: 0 }; }) },
30 file: { deleteMany: vi.fn(async (a: unknown) => { rec("file.deleteMany").push(a); return { count: 2 }; }) },
31 fileVersion: { deleteMany: vi.fn(async (a: unknown) => { rec("fileVersion.deleteMany").push(a); return { count: 2 }; }) }
32 };
33
34
35 return {
36 calls,
37 prisma: {
38 workspace: { findMany: vi.fn(async () => [{ id: "ws1" }, { id: "ws2" }]) },
39 retentionPolicy: {
40 findUnique: vi.fn(async () => null),
41 create: vi.fn(async (a: unknown) => { rec("retentionPolicy.create").push(a); return a; }),
42 update: vi.fn(async (a: unknown) => { rec("retentionPolicy.update").push(a); return a; })
43 },
44 page: { findMany: vi.fn(async () => [{ id: "p1" }, { id: "p2" }]) },
45 comment: { findMany: vi.fn(async () => [{ id: "c1" }, { id: "c2" }]) },
46 commentReply: { findMany: vi.fn(async () => []) },
47 auditLog: {
48 count: vi.fn(async () => 5),
49 findMany: vi.fn(async () => [
50 { id: "a1" },
51 { id: "a2" },
52 { id: "a3" },
53 { id: "a4" },
54 { id: "a5" }
55 ]),
56 deleteMany: vi.fn(async (a: unknown) => { rec("auditLog.deleteMany").push(a); return { count: 5 }; }),
57 create: vi.fn(async (a: unknown) => { rec("auditLog.create").push(a); return a; })
58 },
59 file: { findMany: vi.fn(async () => [{ id: "f1", size: 100 }, { id: "f2", size: 42 }]) },
60 $transaction: vi.fn(async (fn: (t: unknown) => Promise<unknown>) => fn(tx))
61 }
62 } as { calls: Record<string, unknown[]>; prisma: never };
63}
64
65const ACTOR: RetentionActor = { type: "USER", id: "admin1" };
66
67describe("policyFor", () => {
68 it("defaults: trash PURGE 30, comments PURGE 90, audit_logs HOLD 365, files PURGE 0", async () => {
69 const { prisma } = makePrisma();
70 for (const scope of RETENTION_SCOPES) {
71 const p = await policyFor(prisma as never, "org1", scope);
72 expect(p.type).toBe(RETENTION_DEFAULTS[scope].type);
73 expect(p.retainDays).toBe(RETENTION_DEFAULTS[scope].retainDays);
74 }
75 });
76
77 it("a stored row overrides the default", async () => {
78 const { prisma } = makePrisma();
79 (prisma as never as { retentionPolicy: { findUnique: ReturnType<typeof vi.fn> } })
80 .retentionPolicy.findUnique.mockResolvedValueOnce({
81 type: "HOLD",
82 retainDays: 7,
83 config: { note: "legal" }
84 });
85 const p = await policyFor(prisma as never, "org1", "trash");
86 expect(p).toEqual({ type: "HOLD", retainDays: 7, config: { note: "legal" } });
87 });
88});
89
90describe("runRetentionScope — HOLD / no-purge skip", () => {
91 it("audit_logs default HOLD → skipped + retention.hold_skipped (NEVER purged)", async () => {
92 const { prisma, calls } = makePrisma();
93 const report = await runRetentionScope({
94 prisma: prisma as never,
95 orgId: "org1",
96 scope: "audit_logs",
97 mode: "execute",
98 actor: ACTOR
99 });
100 expect(report.status).toBe("skipped");
101 expect(report.reason).toBe("hold");
102 expect(calls["auditLog.deleteMany"] ?? []).toHaveLength(0);
103 const audits = calls["auditLog.create"] ?? [];
104 expect(audits).toHaveLength(1);
105 expect((audits[0] as { data: { action: string } }).data.action).toBe("retention.hold_skipped");
106 });
107
108 it("files default retainDays 0 → no_purge skip (even in execute mode)", async () => {
109 const { prisma, calls } = makePrisma();
110 const report = await runRetentionScope({
111 prisma: prisma as never,
112 orgId: "org1",
113 scope: "files",
114 mode: "execute",
115 actor: ACTOR
116 });
117 expect(report.status).toBe("skipped");
118 expect(report.reason).toBe("no_purge");
119 expect(calls["file.deleteMany"] ?? []).toHaveLength(0);
120 });
121});
122
123describe("runRetentionScope — dry vs execute", () => {
124 it("trash DRY reports the count, deletes NOTHING, audits retention.dry_run", async () => {
125 const { prisma, calls } = makePrisma();
126 const report = await runRetentionScope({
127 prisma: prisma as never,
128 orgId: "org1",
129 scope: "trash",
130 mode: "dry",
131 actor: ACTOR
132 });
133 expect(report.status).toBe("dry_run");
134 expect(report.counts).toEqual({ pages: 2, blocks: 0, comments: 0, files: 0, bytesFreed: 0 });
135 expect(calls["page.deleteMany"] ?? []).toHaveLength(0);
136 expect(calls["block.deleteMany"] ?? []).toHaveLength(0);
137 const audits = calls["auditLog.create"] ?? [];
138 expect(audits).toHaveLength(1);
139 expect((audits[0] as { data: { action: string; metadata: { count: number } } }).data.action).toBe(
140 "retention.dry_run"
141 );
142 expect((audits[0] as { data: { metadata: { count: number } } }).data.metadata.count).toBe(2);
143 });
144
145 it("trash EXECUTE deletes in the org's workspaces (cutoff-filtered) + audits retention.purged", async () => {
146 const { prisma, calls } = makePrisma();
147 const report = await runRetentionScope({
148 prisma: prisma as never,
149 orgId: "org1",
150 scope: "trash",
151 mode: "execute",
152 actor: ACTOR
153 });
154 expect(report.status).toBe("purged");
155
156 const readWhere = (prisma as never as { page: { findMany: ReturnType<typeof vi.fn> } })
157 .page.findMany.mock.calls[0]![0] as { where: { workspaceId: { in: string[] }; trashedAt: { lt: Date } } };
158 expect(readWhere.where.workspaceId.in).toEqual(["ws1", "ws2"]);
159 expect(readWhere.where.trashedAt.lt).toBeInstanceOf(Date);
160
161 expect(calls["page.deleteMany"]).toHaveLength(1);
162 expect(calls["block.deleteMany"]).toHaveLength(1);
163 expect(calls["databaseRecord.deleteMany"]).toHaveLength(1);
164 const audits = calls["auditLog.create"] ?? [];
165 expect((audits[0] as { data: { action: string } }).data.action).toBe("retention.purged");
166
167 const auditData = (audits[0] as { data: { resourceType: string; resourceId: string; actorType: string; actorId: string } }).data;
168 expect(auditData.resourceType).toBe("organization");
169 expect(auditData.resourceId).toBe("org1");
170 expect(auditData.actorType).toBe("USER");
171 expect(auditData.actorId).toBe("admin1");
172 });
173});
174
175describe("runRetentionScope — comments", () => {
176 it("a thread with a RECENT reply survives the purge", async () => {
177 const { prisma, calls } = makePrisma();
178 (prisma as never as { commentReply: { findMany: ReturnType<typeof vi.fn> } })
179 .commentReply.findMany.mockResolvedValueOnce([{ commentId: "c1" }]);
180 const report = await runRetentionScope({
181 prisma: prisma as never,
182 orgId: "org1",
183 scope: "comments",
184 mode: "execute",
185 actor: ACTOR
186 });
187 expect(report.status).toBe("purged");
188 const del = (calls["comment.deleteMany"] ?? [])[0] as { where: { id: { in: string[] } } };
189 expect(del.where.id.in).toEqual(["c2"]);
190 });
191});
192
193describe("runOrgRetention — per-scope isolation", () => {
194 it("a throw in one scope does not stop the others (+ retention.failed audit)", async () => {
195 const { prisma, calls } = makePrisma();
196
197 (prisma as never as { page: { findMany: ReturnType<typeof vi.fn> } }).page.findMany.mockRejectedValueOnce(
198 new Error("boom")
199 );
200 const out = await runOrgRetention({
201 prisma: prisma as never,
202 orgId: "org1",
203 mode: "dry",
204 actor: ACTOR
205 });
206 expect((out["trash"] as { status: string }).status).toBe("failed");
207 expect((out["comments"] as { status: string }).status).toBe("dry_run");
208 expect((out["audit_logs"] as { status: string }).status).toBe("skipped");
209 expect((out["files"] as { status: string }).status).toBe("skipped");
210 const failed = (calls["auditLog.create"] ?? []).filter(
211 (a) => (a as { data: { action: string } }).data.action === "retention.failed"
212 );
213 expect(failed).toHaveLength(1);
214 expect((failed[0] as { data: { metadata: { scope: string; error: string } } }).data.metadata).toMatchObject({
215 scope: "trash",
216 error: "boom"
217 });
218 });
219});
220