feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)
Idempotent per-entity subfolder creation under the matching system root. Fast-path SELECT short-circuits the common case. Inserts race safely via uniq_document_folders_entity (partial unique on port_id+entity_type+entity_id) — the loser re-SELECTs the winner's row. Sibling-name collisions between two entities with the same display name append (2), (3), … to the new folder; existing folders never rename. Exports EntityType for use by downstream tasks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Task 2 — ensureSystemRoots (TDD).
|
||||
* Task 3 — ensureEntityFolder (TDD).
|
||||
*
|
||||
* Fixture convention: makePort from helpers/factories (async DB insert);
|
||||
* TEST_USER_ID resolved once via beforeAll from a seeded user — same pattern
|
||||
@@ -11,8 +12,9 @@ import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documentFolders } from '@/lib/db/schema/documents';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
|
||||
import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service';
|
||||
import { makePort } from '../helpers/factories';
|
||||
|
||||
let TEST_USER_ID = '';
|
||||
@@ -61,3 +63,78 @@ describe('document-folders service · ensureSystemRoots', () => {
|
||||
expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('document-folders service · ensureEntityFolder', () => {
|
||||
let portId: string;
|
||||
let clientId: string;
|
||||
let rootId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const port = await makePort();
|
||||
portId = port.id;
|
||||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||||
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
|
||||
rootId = roots.find((r) => r.name === 'Clients')!.id;
|
||||
|
||||
const [client] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: `Smith, John ${crypto.randomUUID().slice(0, 8)}`,
|
||||
})
|
||||
.returning();
|
||||
clientId = client!.id;
|
||||
});
|
||||
|
||||
it('creates a subfolder under the matching system root with system_managed=true', async () => {
|
||||
const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||
expect(folder.systemManaged).toBe(true);
|
||||
expect(folder.entityType).toBe('client');
|
||||
expect(folder.entityId).toBe(clientId);
|
||||
expect(folder.parentId).toBe(rootId);
|
||||
// name is the client's fullName verbatim
|
||||
const [row] = await db.select().from(clients).where(eq(clients.id, clientId));
|
||||
expect(folder.name).toBe(row!.fullName);
|
||||
});
|
||||
|
||||
it('is idempotent — returns the same row on second call', async () => {
|
||||
const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||
const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||
expect(a.id).toBe(b.id);
|
||||
const all = await db
|
||||
.select()
|
||||
.from(documentFolders)
|
||||
.where(
|
||||
and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||||
);
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('appends a numeric suffix on name collision with an existing folder', async () => {
|
||||
// Insert a second client with the exact same fullName as the first.
|
||||
const [firstClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId));
|
||||
const sharedName = firstClient!.fullName;
|
||||
|
||||
const [collidingClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: sharedName,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||
const second = await ensureEntityFolder(portId, 'client', collidingClient!.id, TEST_USER_ID);
|
||||
expect(second.name).toBe(`${sharedName} (2)`);
|
||||
});
|
||||
|
||||
it('rejects unknown entity types', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error -- runtime check
|
||||
ensureEntityFolder(portId, 'boat', clientId, TEST_USER_ID),
|
||||
).rejects.toThrow(/entity type/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user