dorfteich/apps/api/src/pages/hierarchy.db.test.ts
Claude Fable 5 eb6b0d5d02
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Page hierarchy: parentId, create-under-parent, reparent (#106)
Pages form a tree via a nullable parent_id self-relation (SetNull
backstop; the real trash/purge semantics follow with #107). Slugs and
URLs stay flat and pond-unique, so moving a page never breaks links.

- Shared: generic parent-id tree helpers in tree.ts (labels re-export
  them; buildLabelTree keeps its name-sorted behavior), MAX_PAGE_DEPTH=6,
  parentId on PageView, createPageInputSchema.parentId (nullish),
  repositionPageInputSchema.parentId (optional; absent = keep parent).
- API: create validates the parent (same pond, live, depth);
  PATCH /pages/:id/position reparents atomically with the placement,
  rejecting cycles (page_cycle) and depth violations
  (page_depth_exceeded); GET /ponds/:id/pages nulls parentId when the
  caller may not read the parent, so hidden page ids never leak.
- New error codes translated de+en; hierarchy.db.test.ts covers create,
  404s, depth, cycle, atomic reparent, and the permission nulling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:57:47 +02:00

234 lines
8.3 KiB
TypeScript

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<string[]> {
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();
});
});