/** * T-152 — Per-workspace page-version retention (real Postgres via * Testcontainers, mirroring retention-engine.integration.test.ts). * * We call the worker's `pruneWorkspacePageVersions` DIRECTLY (the same * function the daily 04:00 maintenance job + the ad-hoc `versions.prune` * job invoke) with a real PrismaClient. The seed is direct-DB (state the * API cannot backdate on demand). * * - per-page rule: keep the newest `maxVersions` AND the `keepRecentDays` * grace period; everything else is pruned * - policy comes from `workspace.settings.versions` (defaults 100 / 7 d) * - idempotent: a second run prunes nothing * - audit: one `page.versions_pruned` row per run that removed anything */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { PrismaClient } from "@prisma/client"; import { getDatabaseUrl } from "./helpers/containers"; import { deploySchema } from "./helpers/api"; import { pruneWorkspacePageVersions } from "../../apps/worker/src/processors/retention.js"; let prisma: PrismaClient; const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; describe("T-152 page-version retention (real Postgres)", () => { beforeAll(async () => { const dbUrl = await getDatabaseUrl(); await deploySchema(dbUrl); prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } }); await prisma.$connect(); }, 180_000); afterAll(async () => { await prisma?.$disconnect().catch(() => {}); }); async function seedWorkspace(settings: unknown) { const owner = await prisma.user.create({ data: { email: `vprune-${suffix}-${Math.random().toString(36).slice(2, 6)}@factory.local`, name: "VPrune" } }); const ws = await prisma.workspace.create({ data: { name: `VPrune ${suffix}`, slug: `vprune-${suffix}-${Math.random().toString(36).slice(2, 6)}`, isPersonal: false, ownerId: owner.id, settings: settings as object } }); return { owner, ws }; } /** Backdated versions on a page: `n` rows, oldest `n-1` days → newest 0 d. */ async function seedVersions(pageId: string, n: number) { const rows = []; for (let v = 1; v <= n; v++) { rows.push( await prisma.pageVersion.create({ data: { pageId, version: v, snapshot: { page: { title: `v${v}` }, blocks: [] } as object, note: `v${v}`, createdAt: new Date(Date.now() - (n - v) * 86_400_000) } }) ); } return rows; } it("prunes old versions per policy; keeps newest N + grace; idempotent; audited", async () => { const { ws } = await seedWorkspace({ versions: { maxVersions: 3, keepRecentDays: 10 } }); const page = await prisma.page.create({ data: { workspaceId: ws.id, title: "Prune Target", position: "a0" } }); // 8 versions spanning 7 days: v1=7d … v8=0d. const rows = await seedVersions(page.id, 8); const allIds = rows.map((r) => r.id); const res = await pruneWorkspacePageVersions(prisma, ws.id, { type: "SYSTEM", id: "worker:test" }); // Policy: keep newest 3 (v6, v7, v8) ∪ grace 10d (ALL are <10d old). // So the grace period protects every row → nothing is pruned yet. expect(res).toMatchObject({ pages: 0, versions: 0, maxVersions: 3, keepRecentDays: 10 }); expect(await prisma.pageVersion.count({ where: { pageId: page.id } })).toBe(8); // Now a workspace where the grace is 0 → only the maxVersions cap applies. const { ws: ws2 } = await seedWorkspace({ versions: { maxVersions: 3, keepRecentDays: 0 } }); const page2 = await prisma.page.create({ data: { workspaceId: ws2.id, title: "Prune Target 2", position: "a0" } }); const rows2 = await seedVersions(page2.id, 8); void rows2; const res2 = await pruneWorkspacePageVersions(prisma, ws2.id, { type: "SYSTEM", id: "worker:test" }); expect(res2.versions).toBe(5); // v1..v5 pruned const remaining = await prisma.pageVersion.findMany({ where: { pageId: page2.id }, select: { version: true } }); expect(remaining.map((r) => r.version).sort()).toEqual([6, 7, 8]); // audit row written (once — the pruning run) const audit = await prisma.auditLog.findMany({ where: { workspaceId: ws2.id, action: "page.versions_pruned" } }); expect(audit).toHaveLength(1); expect((audit[0]!.metadata as { versions: number }).versions).toBe(5); // idempotent: second run prunes nothing, writes NO second audit row const res3 = await pruneWorkspacePageVersions(prisma, ws2.id, { type: "SYSTEM", id: "worker:test" }); expect(res3.versions).toBe(0); expect(await prisma.auditLog.count({ where: { workspaceId: ws2.id, action: "page.versions_pruned" } })).toBe(1); void allIds; }); it("grace period protects recent versions even beyond the maxVersions cap", async () => { const { ws } = await seedWorkspace({ versions: { maxVersions: 2, keepRecentDays: 2 } }); const page = await prisma.page.create({ data: { workspaceId: ws.id, title: "Grace", position: "a0" } }); // v1=30d (old, outside top-2), v2=3d (old, outside top-2), v3=1d (recent, outside top-2!), // v4=1d, v5=0d (newest two → protected by cap) await prisma.pageVersion.createMany({ data: [ { pageId: page.id, version: 1, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 30 * 86_400_000) }, { pageId: page.id, version: 2, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 3 * 86_400_000) }, { pageId: page.id, version: 3, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 1 * 86_400_000) }, { pageId: page.id, version: 4, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - 1 * 86_400_000) }, { pageId: page.id, version: 5, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now()) } ] }); const res = await pruneWorkspacePageVersions(prisma, ws.id, { type: "SYSTEM", id: "worker:test" }); expect(res.versions).toBe(2); // v1, v2 const remaining = await prisma.pageVersion.findMany({ where: { pageId: page.id }, select: { version: true } }); expect(remaining.map((r) => r.version).sort((a, b) => a - b)).toEqual([3, 4, 5]); }); it("workspaces without the setting use the conservative defaults (nothing pruned at default scale)", async () => { const { ws } = await seedWorkspace({}); // no settings.versions const page = await prisma.page.create({ data: { workspaceId: ws.id, title: "Defaults", position: "a0" } }); // 15 versions, all within the 7d grace → defaults (100/7) keep all. await prisma.pageVersion.createMany({ data: Array.from({ length: 15 }, (_, i) => ({ pageId: page.id, version: i + 1, snapshot: { blocks: [] } as object, createdAt: new Date(Date.now() - i * 3_600_000) })) }); const res = await pruneWorkspacePageVersions(prisma, ws.id, { type: "SYSTEM", id: "worker:test" }); expect(res).toMatchObject({ pages: 0, versions: 0, maxVersions: 100, keepRecentDays: 7 }); }); });