feat(documents): folder service · listTree + createFolder
In-memory tree build (single SELECT + JS nesting); the folder tree is small enough that a recursive CTE buys nothing. Sibling-name conflict maps the Postgres unique-index 23505 to a typed ConflictError so the UI can render a clean toast. Cross-port parentId rejected at the service boundary. Also adds document_folders to the global teardown CTE so test ports can be cleaned up without FK violations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ export async function teardown() {
|
||||
, del_comp AS (DELETE FROM companies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
, del_cfd AS (DELETE FROM custom_field_definitions WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
, del_docs AS (DELETE FROM documents WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
, del_dfold AS (DELETE FROM document_folders WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
, del_dt AS (DELETE FROM document_templates WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
, del_emt AS (DELETE FROM email_threads WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
, del_ema AS (DELETE FROM email_accounts WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||
|
||||
96
tests/integration/document-folders-crud.test.ts
Normal file
96
tests/integration/document-folders-crud.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Task 3 — document-folders service: listTree + createFolder (TDD).
|
||||
*
|
||||
* Uses the makePort factory (not a "setupTestPort" helper — that name
|
||||
* doesn't exist in this codebase). TEST_USER_ID is resolved once via
|
||||
* beforeAll from any seeded user, matching the pattern in
|
||||
* alerts-tenant-isolation.test.ts and gdpr-export.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
|
||||
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 { makePort } from '../helpers/factories';
|
||||
|
||||
let TEST_USER_ID = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||
if (!u) throw new Error('No user available; run pnpm db:seed first');
|
||||
TEST_USER_ID = u.id;
|
||||
});
|
||||
|
||||
describe('document-folders service · listTree', () => {
|
||||
let portId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const port = await makePort();
|
||||
portId = port.id;
|
||||
await db.delete(documentFolders).execute();
|
||||
});
|
||||
|
||||
it('returns an empty array when no folders exist', async () => {
|
||||
const tree = await listTree(portId);
|
||||
expect(tree).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns root folders with children nested under them', async () => {
|
||||
const root = await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null });
|
||||
const child = await createFolder(portId, TEST_USER_ID, {
|
||||
name: 'Q1',
|
||||
parentId: root.id,
|
||||
});
|
||||
const tree = await listTree(portId);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.id).toBe(root.id);
|
||||
expect(tree[0]?.children).toHaveLength(1);
|
||||
expect(tree[0]?.children[0]?.id).toBe(child.id);
|
||||
});
|
||||
|
||||
it('only returns folders for the requested port', async () => {
|
||||
const otherPort = await makePort();
|
||||
await createFolder(otherPort.id, TEST_USER_ID, { name: 'Other Port', parentId: null });
|
||||
const tree = await listTree(portId);
|
||||
expect(tree).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('document-folders service · createFolder unique-sibling guard', () => {
|
||||
let portId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const port = await makePort();
|
||||
portId = port.id;
|
||||
await db.delete(documentFolders).execute();
|
||||
});
|
||||
|
||||
it('rejects a duplicate sibling name (case-insensitive)', async () => {
|
||||
await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null });
|
||||
await expect(
|
||||
createFolder(portId, TEST_USER_ID, { name: 'deals 2026', parentId: null }),
|
||||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it('allows the same name under different parents', async () => {
|
||||
const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null });
|
||||
const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: null });
|
||||
await createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: a.id });
|
||||
await expect(
|
||||
createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: b.id }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a parentId from another port', async () => {
|
||||
const otherPort = await makePort();
|
||||
const otherFolder = await createFolder(otherPort.id, TEST_USER_ID, {
|
||||
name: 'Other',
|
||||
parentId: null,
|
||||
});
|
||||
await expect(
|
||||
createFolder(portId, TEST_USER_ID, { name: 'Should fail', parentId: otherFolder.id }),
|
||||
).rejects.toThrow(/not found in this port/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user