import { randomUUID } from 'node:crypto'; import { INestApplication, NotFoundException } from '@nestjs/common'; import { PrismaClient, User } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { createTestApp } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { VERSION_RETENTION_DAYS, VersionsService } from './versions.service'; describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => { let app: INestApplication; let prisma: PrismaClient; let versions: VersionsService; const suffix = uniqueSuffix(); let owner: User; let pondId: string; const pageIds: string[] = []; async function createPage(): Promise { const id = randomUUID(); await prisma.page.create({ data: { id, pondId, title: 'Versioned', slug: `p-${id.slice(0, 8)}`, ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())), sortKey: 'a0', createdBy: owner.id, }, }); pageIds.push(id); return id; } beforeAll(async () => { prisma = createTestPrisma(); app = await createTestApp(); versions = app.get(VersionsService); owner = await prisma.user.create({ data: { username: `ver-owner-${suffix}`, email: `ver-owner-${suffix}@example.test`, displayName: 'Version Owner', }, }); const pond = await prisma.pond.create({ data: { slug: `ver-pond-${suffix}`, name: 'Version Pond', type: 'PERSONAL', ownerId: owner.id, }, }); pondId = pond.id; }); afterAll(async () => { if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } }); await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.user.deleteMany({ where: { id: owner.id } }); await prisma.$disconnect(); await app.close(); }); it('creates a named version storing label and creator', async () => { const pageId = await createPage(); const view = await versions.createNamed(owner, pageId, { label: 'before restructuring' }); expect(view).toMatchObject({ trigger: 'manual', label: 'before restructuring', createdBy: owner.id, }); const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } }); expect(row.label).toBe('before restructuring'); expect(row.createdBy).toBe(owner.id); expect(row.trigger).toBe('MANUAL'); expect(row.ydocSnapshot.byteLength).toBeGreaterThan(0); }); it('creates an unnamed manual snapshot when no label is given (#125)', async () => { const pageId = await createPage(); const view = await versions.createNamed(owner, pageId, {}); expect(view).toMatchObject({ trigger: 'manual', label: null, createdBy: owner.id }); const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } }); expect(row.label).toBeNull(); }); it('captures and clears the pending contributor set', async () => { const pageId = await createPage(); const authorA = randomUUID(); const authorB = randomUUID(); await prisma.pagePendingContributor.createMany({ data: [ { pageId, userId: authorA }, { pageId, userId: authorB }, ], }); const view = await versions.createNamed(owner, pageId, { label: 'snapshot' }); expect([...view.contributorIds].sort()).toEqual([authorA, authorB].sort()); // The accumulator is consumed so the next version does not re-attribute them. expect(await prisma.pagePendingContributor.count({ where: { pageId } })).toBe(0); }); it('lists versions newest first and renders a version read-only', async () => { const pageId = await createPage(); const first = await versions.createNamed(owner, pageId, { label: 'first' }); const second = await versions.createNamed(owner, pageId, { label: 'second' }); const list = await versions.list(owner, pageId); expect(list.map((v) => v.id)).toEqual([second.id, first.id]); // newest first const content = await versions.getContent(owner, pageId, first.id); expect(content.id).toBe(first.id); expect(typeof content.html).toBe('string'); expect(typeof content.markdown).toBe('string'); }); // Write-access gating of every history route moved to the route guard // with #52 — covered by the permission e2e pack. it('restore returns the target version and emits without mutating history', async () => { const pageId = await createPage(); const version = await versions.createNamed(owner, pageId, { label: 'target' }); const before = await prisma.pageVersion.count({ where: { pageId } }); // The api side only checks permission and signals collab; it must not delete // or alter any version (history is append-only — collab appends pre-restore). const restored = await versions.restore(owner, pageId, version.id); expect(restored.id).toBe(version.id); expect(await prisma.pageVersion.count({ where: { pageId } })).toBe(before); await expect(versions.restore(owner, pageId, randomUUID())).rejects.toBeInstanceOf( NotFoundException, ); }); it('thins auto versions beyond the window to the newest per day, keeping the rest', async () => { const pageId = await createPage(); const daysAgo = (days: number, hour: number): Date => { const d = new Date(); d.setDate(d.getDate() - days); d.setHours(hour, 0, 0, 0); return d; }; const snapshot = new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())); const auto = (createdAt: Date) => ({ pageId, ydocSnapshot: snapshot, trigger: 'AUTO' as const, contributorIds: [], createdAt, }); // Two auto versions on one day beyond the window (older + newer), one auto // on another day beyond the window, one manual beyond the window, and one // auto inside the window. const oldDayOlder = daysAgo(120, 8); const oldDayNewer = daysAgo(120, 20); const otherOldDay = daysAgo(200, 12); await prisma.pageVersion.createMany({ data: [ auto(oldDayOlder), auto(oldDayNewer), auto(otherOldDay), { ...auto(daysAgo(150, 10)), trigger: 'MANUAL', label: 'keep me', createdBy: owner.id }, auto(daysAgo(3, 10)), // within the window ], }); const removed = await versions.thinDueVersions(); expect(removed).toBeGreaterThanOrEqual(1); const remaining = await prisma.pageVersion.findMany({ where: { pageId }, orderBy: { createdAt: 'asc' }, select: { trigger: true, createdAt: true, label: true }, }); const times = remaining.map((v) => v.createdAt.getTime()); // The older of the two same-day beyond-window auto versions is gone… expect(times).not.toContain(oldDayOlder.getTime()); // …its newer same-day sibling survives, as does the other-day one. expect(times).toContain(oldDayNewer.getTime()); expect(times).toContain(otherOldDay.getTime()); // Manual and within-window auto are always kept. expect(remaining.some((v) => v.label === 'keep me')).toBe(true); expect(remaining.length).toBe(4); expect(VERSION_RETENTION_DAYS).toBe(90); }); });