/** * Task 9 — entity-aggregated API query params (TDD). * * Verifies: * 1. listFilesAggregatedByEntity returns DIRECTLY ATTACHED + FROM COMPANY * groups for a client with files attached directly and via company. * 2. listInflightWorkflowsAggregatedByEntity returns DIRECTLY ATTACHED * group for a client with in-flight documents. * 3. listFilesSchema refine: folderId + entityType together is rejected. * 4. listFilesSchema refine: entityType without entityId is rejected. * 5. listDocumentsSchema refine: same mutual-exclusion rules. * 6. signing-details route service composition: getDocumentById + * listDocumentSigners + listDocumentEvents compose into the expected shape. * * Uses makePort / makeClient / makeCompany / makeMembership factory pattern. */ import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { db } from '@/lib/db'; import { files, documents, documentSigners, documentEvents } from '@/lib/db/schema/documents'; import { user } from '@/lib/db/schema/users'; import { listFilesAggregatedByEntity } from '@/lib/services/files'; import { listInflightWorkflowsAggregatedByEntity, getDocumentById, listDocumentSigners, listDocumentEvents, } from '@/lib/services/documents.service'; import { listFilesSchema } from '@/lib/validators/files'; import { listDocumentsSchema } from '@/lib/validators/documents'; import { makePort, makeClient, makeCompany, makeMembership } 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; }); // ─── Helper to insert a file row directly ───────────────────────────────────── async function insertFile( portId: string, overrides: { clientId?: string | null; companyId?: string | null; yachtId?: string | null; } = {}, ) { const [row] = await db .insert(files) .values({ portId, clientId: overrides.clientId ?? null, companyId: overrides.companyId ?? null, yachtId: overrides.yachtId ?? null, folderId: null, filename: `file-${crypto.randomUUID().slice(0, 8)}.pdf`, originalName: `original-${crypto.randomUUID().slice(0, 8)}.pdf`, mimeType: 'application/pdf', sizeBytes: '1024', storagePath: `test/${crypto.randomUUID()}.pdf`, storageBucket: 'crm-files', uploadedBy: TEST_USER_ID, }) .returning(); return row!; } // ─── listFilesAggregatedByEntity ────────────────────────────────────────────── describe('GET /api/v1/files?entityType=client&entityId=… — service layer', () => { let portId: string; let clientId: string; let companyId: string; beforeEach(async () => { const port = await makePort(); portId = port.id; const client = await makeClient({ portId }); clientId = client.id; const company = await makeCompany({ portId }); companyId = company.id; await makeMembership({ companyId, clientId }); // One file directly on client, one on company await insertFile(portId, { clientId }); await insertFile(portId, { companyId }); }); it('returns { groups: [...] } envelope', async () => { const result = await listFilesAggregatedByEntity(portId, 'client', clientId); expect(result).toHaveProperty('groups'); expect(Array.isArray(result.groups)).toBe(true); }); it('includes DIRECTLY ATTACHED group', async () => { const result = await listFilesAggregatedByEntity(portId, 'client', clientId); const labels = result.groups.map((g) => g.label); expect(labels).toContain('DIRECTLY ATTACHED'); }); it('includes FROM COMPANY group for files attached to a company the client belongs to', async () => { const result = await listFilesAggregatedByEntity(portId, 'client', clientId); const hasCompanyGroup = result.groups.some((g) => g.label.startsWith('FROM COMPANY')); expect(hasCompanyGroup).toBe(true); }); it('each group has label, source, files, and total fields', async () => { const result = await listFilesAggregatedByEntity(portId, 'client', clientId); for (const g of result.groups) { expect(g).toHaveProperty('label'); expect(g).toHaveProperty('source'); expect(g).toHaveProperty('files'); expect(g).toHaveProperty('total'); } }); it('does not include other-port files', async () => { const otherPort = await makePort(); const otherClient = await makeClient({ portId: otherPort.id }); const otherFile = await insertFile(otherPort.id, { clientId: otherClient.id }); const result = await listFilesAggregatedByEntity(portId, 'client', clientId); // Groups are only for the correct port — the other-port client's file must not appear const allFileIds = result.groups.flatMap((g) => g.files.map((f) => (f as { id: string }).id)); expect(result.groups.length).toBeGreaterThan(0); expect(allFileIds.length).toBeGreaterThan(0); // Explicit cross-port isolation assertion — leakage would cause this to fail expect(allFileIds).not.toContain(otherFile.id); }); }); // ─── listInflightWorkflowsAggregatedByEntity ────────────────────────────────── describe('GET /api/v1/documents?entityType=client&entityId=… — service layer', () => { let portId: string; let clientId: string; beforeEach(async () => { const port = await makePort(); portId = port.id; const client = await makeClient({ portId }); clientId = client.id; await db.insert(documents).values([ { portId, clientId, documentType: 'contract', title: 'In-flight', status: 'sent', createdBy: TEST_USER_ID, }, { portId, clientId, documentType: 'contract', title: 'Completed', status: 'completed', createdBy: TEST_USER_ID, }, ]); }); it('returns { groups: [...] } envelope', async () => { const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId); expect(result).toHaveProperty('groups'); expect(Array.isArray(result.groups)).toBe(true); }); it('includes DIRECTLY ATTACHED group with only in-flight workflows', async () => { const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId); const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); expect(direct).toBeDefined(); expect(direct!.workflows.every((w) => (w as { status: string }).status !== 'completed')).toBe( true, ); }); }); // ─── Validator refine rules ──────────────────────────────────────────────────── describe('listFilesSchema refine rules', () => { const BASE = { page: 1, limit: 20, sort: 'createdAt', order: 'desc', includeArchived: 'false' }; it('rejects folderId + entityType together', () => { const result = listFilesSchema.safeParse({ ...BASE, folderId: crypto.randomUUID(), entityType: 'client', entityId: crypto.randomUUID(), }); expect(result.success).toBe(false); }); it('rejects entityType without entityId', () => { const result = listFilesSchema.safeParse({ ...BASE, entityType: 'client', }); expect(result.success).toBe(false); }); it('rejects entityId without entityType', () => { const result = listFilesSchema.safeParse({ ...BASE, entityId: crypto.randomUUID(), }); expect(result.success).toBe(false); }); it('accepts entityType + entityId without folderId', () => { const result = listFilesSchema.safeParse({ ...BASE, entityType: 'client', entityId: crypto.randomUUID(), }); expect(result.success).toBe(true); }); it('accepts folderId alone (no entityType/entityId)', () => { const result = listFilesSchema.safeParse({ ...BASE, folderId: crypto.randomUUID(), }); expect(result.success).toBe(true); }); }); describe('listDocumentsSchema refine rules', () => { const BASE = { page: 1, limit: 20, sort: 'createdAt', order: 'desc', includeArchived: 'false' }; it('rejects folderId + entityType together', () => { const result = listDocumentsSchema.safeParse({ ...BASE, folderId: crypto.randomUUID(), entityType: 'client', entityId: crypto.randomUUID(), }); expect(result.success).toBe(false); }); it('rejects entityType without entityId', () => { const result = listDocumentsSchema.safeParse({ ...BASE, entityType: 'company', }); expect(result.success).toBe(false); }); it('accepts entityType + entityId without folderId', () => { const result = listDocumentsSchema.safeParse({ ...BASE, entityType: 'yacht', entityId: crypto.randomUUID(), }); expect(result.success).toBe(true); }); }); // ─── Signing-details route service composition ──────────────────────────────── describe('signing-details route service composition', () => { let portId: string; let docId: string; beforeEach(async () => { const port = await makePort(); portId = port.id; const client = await makeClient({ portId }); const [doc] = await db .insert(documents) .values({ portId, clientId: client.id, documentType: 'contract', title: 'Signing Details Test', status: 'sent', createdBy: TEST_USER_ID, }) .returning(); docId = doc!.id; // Insert one signer await db.insert(documentSigners).values({ documentId: docId, signerName: 'Alice Signer', signerEmail: 'alice@example.com', signerRole: 'client', signingOrder: 1, status: 'pending', }); // Insert one event await db.insert(documentEvents).values({ documentId: docId, eventType: 'sent', }); }); it('returns workflow, signers, and events for a document', async () => { const [doc, signers, events] = await Promise.all([ getDocumentById(docId, portId), listDocumentSigners(docId, portId), listDocumentEvents(docId, portId), ]); expect(doc).toBeDefined(); expect(doc.id).toBe(docId); expect(signers).toBeInstanceOf(Array); expect(events).toBeInstanceOf(Array); expect(signers.length).toBeGreaterThanOrEqual(1); expect(events.length).toBeGreaterThanOrEqual(1); }); it('signers carry expected fields', async () => { const signers = await listDocumentSigners(docId, portId); const signer = signers[0]!; expect(signer).toHaveProperty('signerName', 'Alice Signer'); expect(signer).toHaveProperty('signerEmail', 'alice@example.com'); expect(signer).toHaveProperty('signerRole', 'client'); expect(signer).toHaveProperty('signingOrder', 1); expect(signer).toHaveProperty('status', 'pending'); }); it('events carry expected fields', async () => { const events = await listDocumentEvents(docId, portId); const event = events[0]!; expect(event).toHaveProperty('eventType', 'sent'); expect(event).toHaveProperty('documentId', docId); }); });