1
16import { afterAll, beforeAll, describe, expect, it } from "vitest";
17import { PrismaClient } from "@prisma/client";
18import { Redis } from "ioredis";
19import { getDatabaseUrl, getRedisUrl } from "./helpers/containers";
20import { deploySchema } from "./helpers/api";
21import { runRetentionScope, runOrgRetention, RETENTION_SCOPES } from "../../apps/worker/src/processors/retention.js";
22
23let prisma: PrismaClient;
24let redis: Redis;
25
26const OLD = new Date(Date.now() - 120 * 86_400_000);
27const RECENT = new Date(Date.now() - 1 * 3_600_000);
28
29describe("TASK-0133 retention engine (real Postgres)", () => {
30 beforeAll(async () => {
31 const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]);
32 await deploySchema(dbUrl);
33 prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
34 await prisma.$connect();
35 redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
36 }, 180_000);
37 afterAll(async () => {
38 await redis?.quit().catch(() => {});
39 await prisma?.$disconnect().catch(() => {});
40 });
41
42 async function seedOrg(name: string) {
43 const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
44 const owner = await prisma.user.create({
45 data: { email: `ret-${name.toLowerCase()}-${suffix}@factory.local`, name: `Ret ${name}` }
46 });
47 const org = await prisma.organization.create({
48 data: { name: `Ret ${name} ${suffix}`, slug: `ret-${suffix}`, ownerId: owner.id }
49 });
50 const ws = await prisma.workspace.create({
51 data: { name: "ws", slug: `ret-ws-${suffix}`, organizationId: org.id, ownerId: owner.id }
52 });
53 return { orgId: org.id, wsId: ws.id, ownerId: owner.id };
54 }
55
56 async function seedTrash(wsId: string, trashedAt: Date) {
57 const page = await prisma.page.create({
58 data: {
59 workspaceId: wsId,
60 position: `a${Math.random().toString(36).slice(2, 8)}`,
61 title: "trashed",
62 trashedAt,
63 deletedAt: trashedAt
64 }
65 });
66 await prisma.block.create({
67 data: { workspaceId: wsId, pageId: page.id, position: "a0", type: "PARAGRAPH" }
68 });
69 return page.id;
70 }
71
72 async function seedFile(wsId: string, deletedAt: Date | null, size: number, userId: string) {
73 const key = `ret/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
74 return prisma.file.create({
75 data: {
76 workspaceId: wsId,
77 name: "f.bin",
78 key,
79 size,
80 mimeType: "application/octet-stream",
81 status: "READY",
82 uploadedBy: userId,
83 deletedAt
84 }
85 });
86 }
87
88 async function seedComment(wsId: string, createdAt: Date, userId: string) {
89 const c = await prisma.comment.create({
90 data: { workspaceId: wsId, body: "old", createdBy: userId, createdAt }
91 });
92 return c.id;
93 }
94
95 async function auditCount(orgId: string, action: string): Promise<number> {
96 return prisma.auditLog.count({
97 where: { OR: [{ workspaceId: null, resourceType: "organization", resourceId: orgId, action }] }
98 });
99 }
100
101 it("trash: EXECUTE purges old trashed pages (+blocks), keeps recent; dry-run deletes nothing", async () => {
102 const a = await seedOrg("A");
103 const oldPage = await seedTrash(a.wsId, OLD);
104 const recentPage = await seedTrash(a.wsId, RECENT);
105
106
107 const dry = await runRetentionScope({
108 prisma: prisma as never,
109 redis,
110 orgId: a.orgId,
111 scope: "trash",
112 mode: "dry",
113 actor: { type: "SYSTEM", id: "worker:test" }
114 });
115 expect(dry.status).toBe("dry_run");
116 expect((dry.counts as { pages: number }).pages).toBe(1);
117 expect(await prisma.page.count({ where: { id: { in: [oldPage, recentPage] } } })).toBe(2);
118
119
120 const exec = await runRetentionScope({
121 prisma: prisma as never,
122 redis,
123 orgId: a.orgId,
124 scope: "trash",
125 mode: "execute",
126 actor: { type: "SYSTEM", id: "worker:test" }
127 });
128 expect(exec.status).toBe("purged");
129 expect(await prisma.page.findUnique({ where: { id: oldPage } })).toBeNull();
130 expect(await prisma.block.count({ where: { pageId: oldPage } })).toBe(0);
131 expect(await prisma.page.findUnique({ where: { id: recentPage } })).toBeTruthy();
132
133 expect(await auditCount(a.orgId, "retention.purged")).toBeGreaterThanOrEqual(1);
134 });
135
136 it("trash: ORG isolation — another org's old trashed page survives", async () => {
137 const a = await seedOrg("B");
138 const b = await seedOrg("C");
139 const bOld = await seedTrash(b.wsId, OLD);
140 await runRetentionScope({
141 prisma: prisma as never,
142 redis,
143 orgId: a.orgId,
144 scope: "trash",
145 mode: "execute",
146 actor: { type: "SYSTEM", id: "worker:test" }
147 });
148
149 expect(await prisma.page.findUnique({ where: { id: bOld } })).toBeTruthy();
150 });
151
152 it("audit_logs: HOLD (default) → NEVER purged, even in execute mode", async () => {
153 const a = await seedOrg("D");
154 const n = await prisma.auditLog.createMany({
155 data: [0, 1, 2].map(() => ({
156 actorType: "USER",
157 actorId: "u",
158 workspaceId: a.wsId,
159 resourceType: "page",
160 resourceId: "p",
161 action: "page.created",
162 createdAt: OLD
163 }))
164 });
165 expect(n.count).toBe(3);
166 const report = await runRetentionScope({
167 prisma: prisma as never,
168 redis,
169 orgId: a.orgId,
170 scope: "audit_logs",
171 mode: "execute",
172 actor: { type: "SYSTEM", id: "worker:test" }
173 });
174 expect(report.status).toBe("skipped");
175 expect(report.reason).toBe("hold");
176
177 expect(await prisma.auditLog.count({ where: { workspaceId: a.wsId, createdAt: OLD } })).toBe(3);
178
179 expect(await auditCount(a.orgId, "retention.hold_skipped")).toBeGreaterThanOrEqual(1);
180 });
181
182 it("audit_logs: PURGE 30d policy purges old rows (the org's own scope)", async () => {
183 const a = await seedOrg("E");
184 const other = await seedOrg("F");
185 await prisma.retentionPolicy.create({
186 data: { organizationId: a.orgId, scope: "audit_logs", type: "PURGE", retainDays: 30 }
187 });
188 await prisma.auditLog.createMany({
189 data: [
190
191 { actorType: "USER", actorId: "u", workspaceId: a.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: OLD },
192 { actorType: "USER", actorId: "u", workspaceId: a.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: RECENT },
193
194 { actorType: "USER", actorId: "u", workspaceId: other.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: OLD }
195 ]
196 });
197 await runRetentionScope({
198 prisma: prisma as never,
199 redis,
200 orgId: a.orgId,
201 scope: "audit_logs",
202 mode: "execute",
203 actor: { type: "SYSTEM", id: "worker:test" }
204 });
205
206 expect(await prisma.auditLog.count({ where: { workspaceId: a.wsId, createdAt: OLD } })).toBe(0);
207 expect(await prisma.auditLog.count({ where: { workspaceId: a.wsId, createdAt: RECENT } })).toBe(1);
208
209 expect(await prisma.auditLog.count({ where: { workspaceId: other.wsId, createdAt: OLD } })).toBe(1);
210 });
211
212 it("files: PURGE 30d purges old deleted files + versions, bytesFreed = sum(sizes)", async () => {
213 const a = await seedOrg("G");
214 await prisma.retentionPolicy.create({
215 data: { organizationId: a.orgId, scope: "files", type: "PURGE", retainDays: 30 }
216 });
217 const oldFile = await seedFile(a.wsId, OLD, 1000, a.ownerId);
218 await prisma.fileVersion.create({ data: { fileId: oldFile.id, version: 1, key: oldFile.key, size: 1000, createdBy: a.ownerId } });
219 await seedFile(a.wsId, RECENT, 500, a.ownerId);
220 const report = await runRetentionScope({
221 prisma: prisma as never,
222 redis,
223 orgId: a.orgId,
224 scope: "files",
225 mode: "execute",
226 actor: { type: "SYSTEM", id: "worker:test" }
227 });
228 expect(report.status).toBe("purged");
229 const counts = report.counts as { files: number; bytesFreed: number };
230 expect(counts.files).toBe(1);
231 expect(counts.bytesFreed).toBe(1000);
232 expect(await prisma.file.findUnique({ where: { id: oldFile.id } })).toBeNull();
233 expect(await prisma.fileVersion.count({ where: { fileId: oldFile.id } })).toBe(0);
234
235 expect(await prisma.file.count({ where: { workspaceId: a.wsId, deletedAt: RECENT } })).toBe(1);
236 });
237
238 it("comments: a thread with a RECENT reply survives; an old thread with no recent reply is purged", async () => {
239 const a = await seedOrg("H");
240 await prisma.retentionPolicy.create({
241 data: { organizationId: a.orgId, scope: "comments", type: "PURGE", retainDays: 30 }
242 });
243 const kept = await seedComment(a.wsId, OLD, a.ownerId);
244 const purged = await seedComment(a.wsId, OLD, a.ownerId);
245
246 await prisma.commentReply.create({
247 data: { commentId: kept, body: "recent reply", createdBy: a.ownerId, createdAt: RECENT }
248 });
249 const report = await runRetentionScope({
250 prisma: prisma as never,
251 redis,
252 orgId: a.orgId,
253 scope: "comments",
254 mode: "execute",
255 actor: { type: "SYSTEM", id: "worker:test" }
256 });
257 expect(report.status).toBe("purged");
258 expect(await prisma.comment.findUnique({ where: { id: purged } })).toBeNull();
259 expect(await prisma.comment.findUnique({ where: { id: kept } })).toBeTruthy();
260 });
261
262 it("runOrgRetention: all 4 scopes run, per-scope isolation (a throw in one does not stop the others)", async () => {
263 const a = await seedOrg("I");
264
265
266 await seedTrash(a.wsId, OLD);
267 await seedFile(a.wsId, OLD, 10, a.ownerId);
268 const out = await runOrgRetention({
269 prisma: prisma as never,
270 redis,
271 orgId: a.orgId,
272 mode: "execute",
273 actor: { type: "SYSTEM", id: "worker:test" }
274 });
275 expect(Object.keys(out).sort()).toEqual([...RETENTION_SCOPES].sort());
276 expect((out["trash"] as { status: string }).status).toBe("purged");
277 expect((out["comments"] as { status: string }).status).toBe("purged");
278 expect((out["audit_logs"] as { status: string }).status).toBe("skipped");
279 expect((out["files"] as { status: string }).status).toBe("skipped");
280 });
281
282 it("writes the admin run report to Redis (worker:retention:report:{runId})", async () => {
283 const a = await seedOrg("J");
284 const runId = `run-${Date.now()}`;
285 await runRetentionScope({
286 prisma: prisma as never,
287 redis,
288 orgId: a.orgId,
289 scope: "trash",
290 mode: "dry",
291 actor: { type: "USER", id: "admin" },
292 runId
293 });
294 const raw = await redis.get(`worker:retention:report:${runId}`);
295 expect(raw).toBeTruthy();
296 const report = JSON.parse(raw!) as { scope: string; mode: string; status: string };
297 expect(report.scope).toBe("trash");
298 expect(report.mode).toBe("dry");
299 expect(report.status).toBe("dry_run");
300 await redis.del(`worker:retention:report:${runId}`);
301 });
302});
303