import { ConflictException, INestApplication, NotFoundException } from '@nestjs/common'; import { MAX_PAGE_DEPTH } from '@dorfteich/shared'; import { PrismaClient, User } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp } from '../testing/test-app'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { PagesService } from './pages.service'; /** * Page hierarchy (issue #106): create-under-parent, reparent via reposition, * cycle/depth rejection, and the permission rule that a child of an unreadable * parent lists with `parentId: null` (no hidden-page id ever leaks). */ describe.skipIf(!hasTestDb)('Page hierarchy (db, issue #106)', () => { let app: INestApplication; let prisma: PrismaClient; let pages: PagesService; const suffix = uniqueSuffix(); let owner: User; let reader: User; let pondId: string; let secretLabelId: string; async function createChain(titles: string[]): Promise { const ids: string[] = []; for (const [index, title] of titles.entries()) { const page = await pages.create(owner, pondId, { title, parentId: index === 0 ? null : ids[index - 1], }); ids.push(page.id); } return ids; } beforeAll(async () => { prisma = createTestPrisma(); app = await createTestApp(); pages = app.get(PagesService); owner = await prisma.user.create({ data: { username: `tree-owner-${suffix}`, email: `tree-owner-${suffix}@example.test`, displayName: 'Tree Owner', }, }); reader = await prisma.user.create({ data: { username: `tree-reader-${suffix}`, email: `tree-reader-${suffix}@example.test`, displayName: 'Tree Reader', }, }); const pond = await prisma.pond.create({ data: { slug: `tree-pond-${suffix}`, name: 'Tree Pond', type: 'SHARED', ownerId: owner.id, }, }); pondId = pond.id; await grantOwnerAdmin(prisma, pondId, owner.id); // Reader grants BEFORE any permission resolution touches the pond — the // PondPermissionCache would otherwise serve the pre-grant state. const secret = await prisma.label.create({ data: { pondId, name: 'secret', color: '#334455' }, }); secretLabelId = secret.id; await prisma.roleGrant.createMany({ data: [ { pondId, subjectType: 'USER', subjectId: reader.id, role: 'READER', scopeType: 'POND', effect: 'ALLOW', createdBy: owner.id, }, { pondId, subjectType: 'USER', subjectId: reader.id, role: 'READER', scopeType: 'LABEL', scopeId: secret.id, effect: 'DENY', createdBy: owner.id, }, ], }); }); 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: { id: { in: [owner.id, reader.id] } } }); await prisma.$disconnect(); await app.close(); }); it('creates a page under a parent and at the root', async () => { const root = await pages.create(owner, pondId, { title: 'Root' }); expect(root.parentId).toBeNull(); const child = await pages.create(owner, pondId, { title: 'Child', parentId: root.id }); expect(child.parentId).toBe(root.id); const list = await pages.list(owner, pondId); expect(list.find((p) => p.id === child.id)?.parentId).toBe(root.id); expect(list.find((p) => p.id === root.id)?.parentId).toBeNull(); }); it('rejects an unknown, foreign, or trashed parent as 404', async () => { await expect( pages.create(owner, pondId, { title: 'Orphan', parentId: 'no-such-page' }), ).rejects.toBeInstanceOf(NotFoundException); const foreignPond = await prisma.pond.create({ data: { slug: `tree-foreign-${suffix}`, name: 'Foreign', type: 'PERSONAL', ownerId: owner.id, }, }); await grantOwnerAdmin(prisma, foreignPond.id, owner.id); const foreignPage = await pages.create(owner, foreignPond.id, { title: 'Elsewhere' }); await expect( pages.create(owner, pondId, { title: 'Crossing', parentId: foreignPage.id }), ).rejects.toBeInstanceOf(NotFoundException); const doomed = await pages.create(owner, pondId, { title: 'Doomed' }); await pages.softDelete(owner, doomed.id); await expect( pages.create(owner, pondId, { title: 'Under trash', parentId: doomed.id }), ).rejects.toBeInstanceOf(NotFoundException); await prisma.page.deleteMany({ where: { pondId: foreignPond.id } }); await prisma.roleGrant.deleteMany({ where: { pondId: foreignPond.id } }); await prisma.pond.delete({ where: { id: foreignPond.id } }); }); it(`rejects nesting beyond ${MAX_PAGE_DEPTH} levels on create`, async () => { const chain = await createChain( Array.from({ length: MAX_PAGE_DEPTH }, (_, i) => `Deep ${i + 1}`), ); await expect( pages.create(owner, pondId, { title: 'Too deep', parentId: chain[MAX_PAGE_DEPTH - 1] }), ).rejects.toBeInstanceOf(ConflictException); }); it('reparents atomically through the position endpoint', async () => { const a = await pages.create(owner, pondId, { title: 'Move A' }); const b = await pages.create(owner, pondId, { title: 'Move B' }); const child = await pages.create(owner, pondId, { title: 'Move child', parentId: a.id }); // Drag onto B: reparent + append, one call. const moved = await pages.reposition(owner, child.id, { afterId: b.id, beforeId: null, parentId: b.id, }); expect(moved.parentId).toBe(b.id); // Back to the root with `parentId: null`. const rooted = await pages.reposition(owner, child.id, { afterId: null, beforeId: a.id, parentId: null, }); expect(rooted.parentId).toBeNull(); // An absent `parentId` keeps the current parent (plain reorder). const reordered = await pages.reposition(owner, child.id, { afterId: b.id, beforeId: null }); expect(reordered.parentId).toBeNull(); }); it('rejects a move into the page own subtree as page_cycle', async () => { const [top, , grandchild] = await createChain(['Cycle 1', 'Cycle 2', 'Cycle 3']); let caught: unknown; await pages .reposition(owner, top!, { afterId: null, beforeId: null, parentId: grandchild! }) .catch((error: unknown) => { caught = error; }); expect(caught).toBeInstanceOf(ConflictException); expect((caught as ConflictException).getResponse()).toMatchObject({ code: 'page_cycle' }); // Self-parenting is the trivial cycle. await expect( pages.reposition(owner, top!, { afterId: null, beforeId: null, parentId: top! }), ).rejects.toBeInstanceOf(ConflictException); }); it('rejects a move that pushes the subtree past the depth limit', async () => { const deep = await createChain(['Limit 1', 'Limit 2', 'Limit 3', 'Limit 4']); const [subtreeTop] = await createChain(['Tall 1', 'Tall 2', 'Tall 3']); let caught: unknown; await pages .reposition(owner, subtreeTop!, { afterId: null, beforeId: null, parentId: deep[3]! }) .catch((error: unknown) => { caught = error; }); expect(caught).toBeInstanceOf(ConflictException); expect((caught as ConflictException).getResponse()).toMatchObject({ code: 'page_depth_exceeded', }); }); it('nulls the parentId of a child whose parent the caller may not read', async () => { const hidden = await pages.create(owner, pondId, { title: 'Hidden parent' }); await prisma.pageLabel.create({ data: { pageId: hidden.id, labelId: secretLabelId } }); const child = await pages.create(owner, pondId, { title: 'Visible child', parentId: hidden.id, }); const ownerList = await pages.list(owner, pondId); expect(ownerList.find((p) => p.id === child.id)?.parentId).toBe(hidden.id); const readerList = await pages.list(reader, pondId); expect(readerList.some((p) => p.id === hidden.id)).toBe(false); expect(readerList.find((p) => p.id === child.id)?.parentId).toBeNull(); }); });