import { INestApplication } from '@nestjs/common'; import { PageView } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * Delete modes and subtree trash semantics (issue #107): promote vs subtree, * the write-on-every-descendant gate, order-independent restore re-attachment, * and child promotion on purge. */ describe.skipIf(!hasTestDb)('page tree trash semantics (e2e, issue #107)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'branches fall upward 12'; let ownerCookie: string; let editorCookie: string; let pondId: string; let secretLabelId: string; const api = () => request(app.getHttpServer()); async function login(username: string): Promise { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); return sessionCookieOf(res); } async function createPage(title: string, parentId?: string): Promise { const res = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send(parentId ? { title, parentId } : { title }) .expect(201); return res.body as PageView; } async function listPages(): Promise { const res = await api() .get(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .expect(200); return res.body as PageView[]; } async function trashTitles(): Promise { const res = await api() .get(`/api/v1/ponds/${pondId}/trash`) .set('Cookie', ownerCookie) .expect(200); return (res.body as PageView[]).map((p) => p.title); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); const users = app.get(UsersService); const ownerUser = await users.createUser({ username: `boris-branch-${suffix}`, email: `boris-branch-${suffix}@example.org`, displayName: 'Boris Branch', password, locale: 'en', }); await users.markEmailVerified(ownerUser.id); const editorUser = await users.createUser({ username: `edda-editor-${suffix}`, email: `edda-editor-${suffix}@example.org`, displayName: 'Edda Editor', password, locale: 'en', }); await users.markEmailVerified(editorUser.id); // Pond, labels, and ALL grants provisioned before any permission // resolution touches the pond — raw rows created later would be invisible // to the warmed PondPermissionCache. const pond = await prisma.pond.create({ data: { slug: `tree-trash-${suffix}`, name: 'Tree Trash', type: 'SHARED', ownerId: ownerUser.id, }, }); pondId = pond.id; await grantOwnerAdmin(prisma, pondId, ownerUser.id); const secret = await prisma.label.create({ data: { pondId, name: 'secret', color: '#334455' }, }); secretLabelId = secret.id; await prisma.roleGrant.createMany({ data: [ { pondId, subjectType: 'USER', subjectId: editorUser.id, role: 'EDITOR', scopeType: 'POND', effect: 'ALLOW', createdBy: ownerUser.id, }, { pondId, subjectType: 'USER', subjectId: editorUser.id, role: 'EDITOR', scopeType: 'LABEL', scopeId: secret.id, effect: 'DENY', createdBy: ownerUser.id, }, ], }); ownerCookie = await login(`boris-branch-${suffix}`); editorCookie = await login(`edda-editor-${suffix}`); }); afterAll(async () => { await prisma.roleGrant.deleteMany({ where: { pondId } }); await prisma.label.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('promote (the default) re-attaches live children to the deleted page parent', async () => { const a = await createPage('Promote A'); const b = await createPage('Promote B', a.id); const c = await createPage('Promote C', b.id); await api().delete(`/api/v1/pages/${a.id}`).set('Cookie', ownerCookie).expect(204); const pages = await listPages(); expect(pages.find((p) => p.id === b.id)?.parentId).toBeNull(); // promoted to A's parent expect(pages.find((p) => p.id === c.id)?.parentId).toBe(b.id); // untouched expect(await trashTitles()).toContain('Promote A'); // Restoring A later does not re-claim the promoted children. await api().post(`/api/v1/pages/${a.id}/restore`).set('Cookie', ownerCookie).expect(201); const after = await listPages(); expect(after.find((p) => p.id === b.id)?.parentId).toBeNull(); }); it('subtree trashes every live descendant together; restore is order-independent', async () => { const x = await createPage('Subtree X'); const y = await createPage('Subtree Y', x.id); const z = await createPage('Subtree Z', y.id); await api().delete(`/api/v1/pages/${x.id}?mode=subtree`).set('Cookie', ownerCookie).expect(204); const titles = await trashTitles(); expect(titles).toEqual(expect.arrayContaining(['Subtree X', 'Subtree Y', 'Subtree Z'])); // One shared timestamp marks the subtree operation. const rows = await prisma.page.findMany({ where: { id: { in: [x.id, y.id, z.id] } } }); const stamps = new Set(rows.map((r) => r.deletedAt?.toISOString())); expect(stamps.size).toBe(1); // Restore the deepest page first: its ancestors are still trashed, so it // re-attaches to the nearest live ancestor — the root. const zRestored = await api() .post(`/api/v1/pages/${z.id}/restore`) .set('Cookie', ownerCookie) .expect(201); expect((zRestored.body as PageView).parentId).toBeNull(); // Restore the top, then the middle: Y finds X live again and nests under it. await api().post(`/api/v1/pages/${x.id}/restore`).set('Cookie', ownerCookie).expect(201); const yRestored = await api() .post(`/api/v1/pages/${y.id}/restore`) .set('Cookie', ownerCookie) .expect(201); expect((yRestored.body as PageView).parentId).toBe(x.id); // Z stays where its restore put it — X does not re-claim it. const pages = await listPages(); expect(pages.find((p) => p.id === z.id)?.parentId).toBeNull(); }); it('subtree requires write on every live descendant (403, nothing deleted)', async () => { const parent = await createPage('Gate parent'); const child = await createPage('Gate child', parent.id); await prisma.pageLabel.create({ data: { pageId: child.id, labelId: secretLabelId } }); await api() .delete(`/api/v1/pages/${parent.id}?mode=subtree`) .set('Cookie', editorCookie) .expect(403); // Nothing was trashed by the refused subtree delete. const pages = await listPages(); expect(pages.some((p) => p.id === parent.id)).toBe(true); expect(pages.some((p) => p.id === child.id)).toBe(true); // The plain promote delete only needs write on the page itself. await api().delete(`/api/v1/pages/${parent.id}`).set('Cookie', editorCookie).expect(204); }); it('purging a parent promotes its children instead of orphaning them', async () => { const u = await createPage('Purge U'); const v = await createPage('Purge V', u.id); await api().delete(`/api/v1/pages/${u.id}?mode=subtree`).set('Cookie', ownerCookie).expect(204); await api().delete(`/api/v1/pages/${u.id}/purge`).set('Cookie', ownerCookie).expect(204); const vRow = await prisma.page.findUniqueOrThrow({ where: { id: v.id } }); expect(vRow.parentId).toBeNull(); // U's parent was the root const restored = await api() .post(`/api/v1/pages/${v.id}/restore`) .set('Cookie', ownerCookie) .expect(201); expect((restored.body as PageView).parentId).toBeNull(); }); });