dorfteich/apps/api/src/trash/tree-trash.e2e.db.test.ts
Claude Fable 5 12ff3c099f
All checks were successful
CD / Build and push images (push) Successful in 3m59s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m20s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m42s
CI / Import/export fidelity gate (push) Successful in 47s
Delete modes and subtree trash semantics for the page tree (#107)
DELETE /pages/:id?mode=promote|subtree — promote (the default) moves
the page's live children up to its parent; subtree trashes every live
descendant with one timestamp and requires write permission on all of
them (no partial deletes; trash access is write capability, ADR 0013).

Trashed pages keep their parentId. Restore re-attaches to the nearest
live ancestor (else root), which makes restore order-independent:
restoring a parent afterwards never re-claims an already-restored
child. Purge promotes any remaining children to the purged page's
parent; the FK's SetNull stays as backstop only.

tree-trash.e2e.db.test.ts covers promote, subtree + one-timestamp,
the 403 descendant gate (label-DENY editor), order-independent
restore, and child promotion on purge.

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

228 lines
8.3 KiB
TypeScript

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<string> {
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<PageView> {
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<PageView[]> {
const res = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
return res.body as PageView[];
}
async function trashTitles(): Promise<string[]> {
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();
});
});