1
16import { afterAll, beforeAll, describe, expect, it } from "vitest";
17import { PrismaClient } from "@prisma/client";
18import { getDatabaseUrl } from "./helpers/containers";
19import { deploySchema } from "./helpers/api";
20import { pruneWorkspacePageVersions } from "../../apps/worker/src/processors/retention.js";
21
22let prisma: PrismaClient;
23const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
24
25describe("T-152 page-version retention (real Postgres)", () => {
26 beforeAll(async () => {
27 const dbUrl = await getDatabaseUrl();
28 await deploySchema(dbUrl);
29 prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
30 await prisma.$connect();
31 }, 180_000);
32 afterAll(async () => {
33 await prisma?.$disconnect().catch(() => {});
34 });
35
36 async function seedWorkspace(settings: unknown) {
37 const owner = await prisma.user.create({
38 data: { email: `vprune-${suffix}-${Math.random().toString(36).slice(2, 6)}@factory.local`, name: "VPrune" }
39 });
40 const ws = await prisma.workspace.create({
41 data: {
42 name: `VPrune ${suffix}`,
43 slug: `vprune-${suffix}-${Math.random().toString(36).slice(2, 6)}`,
44 isPersonal: false,
45 ownerId: owner.id,
46 settings: settings as object
47 }
48 });
49 return { owner, ws };
50 }
51
52
53 async function seedVersions(pageId: string, n: number) {
54 const rows = [];
55 for (let v = 1; v <= n; v++) {
56 rows.push(
57 await prisma.pageVersion.create({
58 data: {
59 pageId,
60 version: v,
61 snapshot: { page: { title: `v${v}` }, blocks: [] } as object,
62 note: `v${v}`,
63 createdAt: new Date(Date.now() - (n - v) * 86_400_000)
64 }
65 })
66 );
67 }
68 return rows;
69 }
70
71 it("prunes old versions per policy; keeps newest N + grace; idempotent; audited", async () => {
72 const { ws } = await seedWorkspace({ versions: { maxVersions: 3, keepRecentDays: 10 } });
73 const page = await prisma.page.create({
74 data: { workspaceId: ws.id, title: "Prune Target", position: "a0" }
75 });
76
77 const rows = await seedVersions(page.id, 8);
78 const allIds = rows.map((r) => r.id);
79
80 const res = await pruneWorkspacePageVersions(prisma, ws.id, { type: "SYSTEM", id: "worker:test" });
81
82
83
84 expect(res).toMatchObject({ pages: 0, versions: 0, maxVersions: 3, keepRecentDays: 10 });
85 expect(await prisma.pageVersion.count({ where: { pageId: page.id } })).toBe(8);
86
87
88 const { ws: ws2 } = await seedWorkspace({ versions: { maxVersions: 3, keepRecentDays: 0 } });
89 const page2 = await prisma.page.create({
90 data: { workspaceId: ws2.id, title: "Prune Target 2", position: "a0" }
91 });
92 const rows2 = await seedVersions(page2.id, 8);
93 void rows2;
94
95 const res2 = await pruneWorkspacePageVersions(prisma, ws2.id, { type: "SYSTEM", id: "worker:test" });
96 expect(res2.versions).toBe(5);
97 const remaining = await prisma.pageVersion.findMany({ where: { pageId: page2.id }, select: { version: true } });
98 expect(remaining.map((r) => r.version).sort()).toEqual([6, 7, 8]);
99
100
101 const audit = await prisma.auditLog.findMany({
102 where: { workspaceId: ws2.id, action: "page.versions_pruned" }
103 });
104 expect(audit).toHaveLength(1);
105 expect((audit[0]!.metadata as { versions: number }).versions).toBe(5);
106
107
108 const res3 = await pruneWorkspacePageVersions(prisma, ws2.id, { type: "SYSTEM", id: "worker:test" });
109 expect(res3.versions).toBe(0);
110 expect(await prisma.auditLog.count({ where: { workspaceId: ws2.id, action: "page.versions_pruned" } })).toBe(1);
111 void allIds;
112 });
113
114 it("grace period protects recent versions even beyond the maxVersions cap", async () => {
115 const { ws } = await seedWorkspace({ versions: { maxVersions: 2, keepRecentDays: 2 } });
116 const page = await prisma.page.create({
117 data: { workspaceId: ws.id, title: "Grace", position: "a0" }
118 });
119
120
121 await prisma.pageVersion.createMany({
122 data: [
123 { pageId: page.id, version: 1, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 30 * 86_400_000) },
124 { pageId: page.id, version: 2, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 3 * 86_400_000) },
125 { pageId: page.id, version: 3, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 1 * 86_400_000) },
126 { pageId: page.id, version: 4, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 1 * 86_400_000) },
127 { pageId: page.id, version: 5, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now()) }
128 ]
129 });
130 const res = await pruneWorkspacePageVersions(prisma, ws.id, { type: "SYSTEM", id: "worker:test" });
131 expect(res.versions).toBe(2);
132 const remaining = await prisma.pageVersion.findMany({ where: { pageId: page.id }, select: { version: true } });
133 expect(remaining.map((r) => r.version).sort((a, b) => a - b)).toEqual([3, 4, 5]);
134 });
135
136 it("workspaces without the setting use the conservative defaults (nothing pruned at default scale)", async () => {
137 const { ws } = await seedWorkspace({});
138 const page = await prisma.page.create({
139 data: { workspaceId: ws.id, title: "Defaults", position: "a0" }
140 });
141
142 await prisma.pageVersion.createMany({
143 data: Array.from({ length: 15 }, (_, i) => ({
144 pageId: page.id,
145 version: i + 1,
146 snapshot: { blocks: [] } as object,
147 createdAt: new Date(Date.now() - i * 3_600_000)
148 }))
149 });
150 const res = await pruneWorkspacePageVersions(prisma, ws.id, { type: "SYSTEM", id: "worker:test" });
151 expect(res).toMatchObject({ pages: 0, versions: 0, maxVersions: 100, keepRecentDays: 7 });
152 });
153});
154