feat(documents): folder service · rename + move + soft-rescue delete

renameFolder + moveFolder enforce sibling-name uniqueness via the
shared isSiblingNameConflict helper and reject cross-port leakage at
the service boundary. moveFolder walks the destination's ancestor
chain to refuse cycles before the write.

deleteFolderSoftRescue re-parents every child folder and document up
to the deleted folder's parent (or to root) inside a transaction,
then drops the folder row. Children never disappear silently — a
wrong click moves work up the tree, never deletes it. Audit-logged
with rescuedTo metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:41:25 +02:00
parent 5c5ab49218
commit e9251a399a
3 changed files with 305 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
/**
* Task 3 — document-folders service: listTree + createFolder (TDD).
* Task 4 — renameFolder + moveFolder (TDD).
*
* Uses the makePort factory (not a "setupTestPort" helper — that name
* doesn't exist in this codebase). TEST_USER_ID is resolved once via
@@ -14,7 +15,12 @@ import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders } from '@/lib/db/schema/documents';
import { user } from '@/lib/db/schema/users';
import { listTree, createFolder } from '@/lib/services/document-folders.service';
import {
listTree,
createFolder,
renameFolder,
moveFolder,
} from '@/lib/services/document-folders.service';
import { makePort } from '../helpers/factories';
let TEST_USER_ID = '';
@@ -96,3 +102,71 @@ describe('document-folders service · createFolder unique-sibling guard', () =>
).rejects.toThrow(/invalid parent/i);
});
});
describe('document-folders service · renameFolder', () => {
let portId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
});
it('renames a folder and bumps updatedAt', async () => {
const folder = await createFolder(portId, TEST_USER_ID, { name: 'Old', parentId: null });
const before = folder.updatedAt.getTime();
await new Promise((r) => setTimeout(r, 10));
const renamed = await renameFolder(portId, folder.id, 'New');
expect(renamed.name).toBe('New');
expect(renamed.updatedAt.getTime()).toBeGreaterThan(before);
});
it('rejects rename to an existing sibling name', async () => {
await createFolder(portId, TEST_USER_ID, { name: 'Existing', parentId: null });
const folder = await createFolder(portId, TEST_USER_ID, { name: 'Mine', parentId: null });
await expect(renameFolder(portId, folder.id, 'Existing')).rejects.toThrow(/already exists/i);
});
it('throws NotFound when the folder belongs to another port', async () => {
const otherPort = await makePort();
const folder = await createFolder(otherPort.id, TEST_USER_ID, { name: 'X', parentId: null });
await expect(renameFolder(portId, folder.id, 'Y')).rejects.toThrow(/couldn't find/i);
});
});
describe('document-folders service · moveFolder', () => {
let portId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
});
it('moves a folder under a new parent', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const orphan = await createFolder(portId, TEST_USER_ID, { name: 'Orphan', parentId: null });
const moved = await moveFolder(portId, orphan.id, root.id);
expect(moved.parentId).toBe(root.id);
});
it('moves a folder back to root with parentId=null', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const child = await createFolder(portId, TEST_USER_ID, { name: 'Child', parentId: root.id });
const moved = await moveFolder(portId, child.id, null);
expect(moved.parentId).toBeNull();
});
it('rejects a move that would create a cycle', async () => {
const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null });
const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: a.id });
const c = await createFolder(portId, TEST_USER_ID, { name: 'C', parentId: b.id });
// moving A under C would create A → B → C → A
await expect(moveFolder(portId, a.id, c.id)).rejects.toThrow(/cycle/i);
});
it('rejects moving a folder under itself', async () => {
const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null });
await expect(moveFolder(portId, a.id, a.id)).rejects.toThrow(/cycle/i);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, beforeAll } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, documents } from '@/lib/db/schema/documents';
import { user } from '@/lib/db/schema/users';
import {
createFolder,
deleteFolderSoftRescue,
} from '@/lib/services/document-folders.service';
import { makePort } from '../helpers/factories';
describe('document-folders · deleteFolderSoftRescue', () => {
let portId: string;
let testUserId: string;
beforeAll(async () => {
const [u] = await db.select({ id: user.id }).from(user).limit(1);
testUserId = u!.id;
});
beforeEach(async () => {
const port = await makePort();
portId = port.id;
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
});
it('moves child subfolders up to the deleted folder\'s parent', async () => {
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
const middle = await createFolder(portId, testUserId, { name: 'Middle', parentId: root.id });
const leaf = await createFolder(portId, testUserId, { name: 'Leaf', parentId: middle.id });
await deleteFolderSoftRescue(portId, middle.id, testUserId);
const survivor = await db.query.documentFolders.findFirst({
where: eq(documentFolders.id, leaf.id),
});
expect(survivor?.parentId).toBe(root.id);
});
it('moves child documents to the deleted folder\'s parent', async () => {
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
const child = await createFolder(portId, testUserId, { name: 'Child', parentId: root.id });
const [doc] = await db
.insert(documents)
.values({
portId,
documentType: 'other',
title: 'Orphan-rescue test',
createdBy: testUserId,
folderId: child.id,
})
.returning();
await deleteFolderSoftRescue(portId, child.id, testUserId);
const updatedDoc = await db.query.documents.findFirst({
where: eq(documents.id, doc!.id),
});
expect(updatedDoc?.folderId).toBe(root.id);
});
it('moves root-folder children to root (folderId=null) when the deleted folder is at root', async () => {
const folder = await createFolder(portId, testUserId, { name: 'TopLevel', parentId: null });
const child = await createFolder(portId, testUserId, {
name: 'Survivor',
parentId: folder.id,
});
await deleteFolderSoftRescue(portId, folder.id, testUserId);
const survivor = await db.query.documentFolders.findFirst({
where: eq(documentFolders.id, child.id),
});
expect(survivor?.parentId).toBeNull();
});
it('throws NotFound for a folder in another port', async () => {
const otherPort = await makePort();
const folder = await createFolder(otherPort.id, testUserId, { name: 'X', parentId: null });
await expect(deleteFolderSoftRescue(portId, folder.id, testUserId)).rejects.toThrow(
/couldn't find/i,
);
});
});