Files
pn-new-crm/tests/unit/aggregated-projection.test.ts
Matt a4c49f5e5a fix(documents): surface signedFromDocumentId + hub cleanup
Three follow-ups from Task 15 code review:

1. (Important) The aggregated files API now LEFT JOINs against
   documents to surface signedFromDocumentId per file row. The
   "view signing details" button on EntityFolderView's Files
   section now passes the workflow id to SigningDetailsDialog
   instead of the file id. Previously the button always 404'd
   and the dialog hung in the loading state. Drops the v1
   filename-prefix heuristic.

2. (Minor) Drop dead initialTab prop + DocumentsHubTab import —
   leftover from the pre-refactor tab strip.

3. (Minor) FlatFolderListing remounts on folder switch via a key
   prop, restoring the pre-refactor typeFilter reset behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:44:48 +02:00

373 lines
13 KiB
TypeScript

/**
* Task 8 — aggregated projection (TDD).
*
* Tests for:
* 1. listFilesAggregatedByEntity (4 cases)
* 2. listInflightWorkflowsAggregatedByEntity (1 case)
* 3. applyEntityFkFromFolder (3 cases)
*
* Fixture convention: makePort / makeClient / makeCompany / makeYacht from
* helpers/factories; TEST_USER_ID resolved once via beforeAll from a seeded
* user — same pattern as document-folders-system-folders.test.ts.
*/
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files, documents, documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { user } from '@/lib/db/schema/users';
import { yachts } from '@/lib/db/schema/yachts';
import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service';
import { listFilesAggregatedByEntity, applyEntityFkFromFolder } from '@/lib/services/files';
import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service';
import { makePort, makeClient, makeCompany, makeYacht, 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;
folderId?: string | null;
} = {},
) {
const [row] = await db
.insert(files)
.values({
portId,
clientId: overrides.clientId ?? null,
companyId: overrides.companyId ?? null,
yachtId: overrides.yachtId ?? null,
folderId: overrides.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('files service · listFilesAggregatedByEntity', () => {
describe('groups DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT for a client view', () => {
let portId: string;
let clientId: string;
let companyId: string;
let yachtId: 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 });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: clientId });
yachtId = yacht.id;
// Three files: directly on client, on company, on yacht
await insertFile(portId, { clientId });
await insertFile(portId, { companyId });
await insertFile(portId, { yachtId });
});
it('returns DIRECTLY ATTACHED, FROM COMPANY, and FROM YACHT groups', async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const labels = result.groups.map((g) => g.label);
expect(labels).toContain('DIRECTLY ATTACHED');
expect(labels.some((l) => l.startsWith('FROM COMPANY'))).toBe(true);
expect(labels.some((l) => l.startsWith('FROM YACHT'))).toBe(true);
});
it('each group has the correct source tag', async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const sourceMap: Record<string, string> = {};
for (const g of result.groups) {
sourceMap[g.label] = g.source;
}
expect(sourceMap['DIRECTLY ATTACHED']).toBe('direct');
const companyGroup = result.groups.find((g) => g.label.startsWith('FROM COMPANY'));
expect(companyGroup?.source).toBe('company');
const yachtGroup = result.groups.find((g) => g.label.startsWith('FROM YACHT'));
expect(yachtGroup?.source).toBe('yacht');
});
it('file rows carry signedFromDocumentId=null when no workflow references them', async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(directGroup).toBeDefined();
for (const f of directGroup!.files) {
expect(f).toHaveProperty('signedFromDocumentId');
expect(f.signedFromDocumentId).toBeNull();
}
});
});
describe('caps each group at 20 rows + surfaces total', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
const client = await makeClient({ portId });
clientId = client.id;
// Insert 25 files all with clientId
await Promise.all(Array.from({ length: 25 }, () => insertFile(portId, { clientId })));
});
it('DIRECTLY ATTACHED group has 20 files and total=25', async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const group = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(group).toBeDefined();
expect(group!.files).toHaveLength(20);
expect(group!.total).toBe(25);
});
});
describe('file-FK snapshot survives yacht transfer', () => {
let portId: string;
let johnId: string;
let maryId: string;
let yachtId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
const john = await makeClient({ portId, overrides: { fullName: 'John Smith' } });
johnId = john.id;
const mary = await makeClient({ portId, overrides: { fullName: 'Mary Jones' } });
maryId = mary.id;
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: johnId });
yachtId = yacht.id;
// File attached to the yacht at the time john owns it
await insertFile(portId, { yachtId, clientId: johnId });
// Transfer yacht to Mary (update currentOwner in place — simulates transfer)
await db
.update(yachts)
.set({ currentOwnerType: 'client', currentOwnerId: maryId })
.where(and(eq(yachts.id, yachtId), eq(yachts.portId, portId)));
});
it("John's view still shows the file via yachtId FK", async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', johnId);
// clientId=johnId → DIRECTLY ATTACHED group
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(directGroup).toBeDefined();
expect(directGroup!.files.length).toBeGreaterThan(0);
});
it("Mary's view does NOT see john's file (it has clientId=john, not mary)", async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', maryId);
// Mary owns the yacht now, so FROM YACHT group will appear — but the
// file has clientId=johnId (snapshotted FK), so it WON'T appear under
// Mary's DIRECTLY ATTACHED. The FROM YACHT group WILL appear since the
// file still has yachtId set.
// The key invariant: there is no DIRECTLY ATTACHED for Mary.
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(directGroup).toBeUndefined();
});
});
describe('cross-port leakage rejected', () => {
let portId: string;
let otherPortId: string;
let otherClientId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
const otherPort = await makePort();
otherPortId = otherPort.id;
const otherClient = await makeClient({ portId: otherPortId });
otherClientId = otherClient.id;
// File for other client in other port
await insertFile(otherPortId, { clientId: otherClientId });
});
it('returns empty groups when entity belongs to a different port', async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', otherClientId);
expect(result.groups).toHaveLength(0);
});
});
describe('signedFromDocumentId reverse-link', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
const client = await makeClient({ portId });
clientId = client.id;
});
it('surfaces signedFromDocumentId when a workflow references the file', async () => {
const fileRow = await insertFile(portId, { clientId });
// Insert a document row with signedFileId pointing at the file
const [doc] = await db
.insert(documents)
.values({
portId,
clientId,
documentType: 'contract',
title: 'Signed Contract',
status: 'completed',
signedFileId: fileRow.id,
createdBy: TEST_USER_ID,
})
.returning();
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(directGroup).toBeDefined();
const linkedFile = directGroup!.files.find((f) => f.id === fileRow.id);
expect(linkedFile).toBeDefined();
expect(linkedFile!.signedFromDocumentId).toBe(doc!.id);
});
});
});
// ─── listInflightWorkflowsAggregatedByEntity ─────────────────────────────────
describe('documents service · listInflightWorkflowsAggregatedByEntity', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
const client = await makeClient({ portId });
clientId = client.id;
});
it('returns in-flight workflows in DIRECTLY ATTACHED group, hides completed', async () => {
// Insert two documents: one in-flight (status='sent'), one completed
await db.insert(documents).values([
{
portId,
clientId,
documentType: 'contract',
title: 'In-flight Doc',
status: 'sent',
createdBy: TEST_USER_ID,
},
{
portId,
clientId,
documentType: 'contract',
title: 'Completed Doc',
status: 'completed',
createdBy: TEST_USER_ID,
},
]);
const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId);
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(directGroup).toBeDefined();
expect(directGroup!.workflows).toHaveLength(1);
expect(directGroup!.workflows[0]!.status).toBe('sent');
expect(directGroup!.workflows[0]!.title).toBe('In-flight Doc');
});
it('rejects cross-port leakage with defense-in-depth port filter', async () => {
const otherPort = await makePort();
const [otherClient] = await db
.insert(clients)
.values({ portId: otherPort.id, fullName: 'Other Port Client' })
.returning();
const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', otherClient!.id);
expect(result.groups).toEqual([]);
});
});
// ─── applyEntityFkFromFolder ──────────────────────────────────────────────────
describe('files service · applyEntityFkFromFolder', () => {
let portId: string;
let clientId: string;
let entityFolderId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
await ensureSystemRoots(portId, TEST_USER_ID);
const client = await makeClient({ portId });
clientId = client.id;
const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
entityFolderId = folder.id;
});
it('sets clientId when uploading into a client entity folder', async () => {
const out = await applyEntityFkFromFolder(portId, { folderId: entityFolderId, clientId: null });
expect(out.clientId).toBe(clientId);
});
it('preserves existing entity FK when already set', async () => {
const out = await applyEntityFkFromFolder(portId, {
folderId: entityFolderId,
clientId: 'pre-existing-id',
});
expect(out.clientId).toBe('pre-existing-id');
});
it('is a no-op for non-system folders', async () => {
const [userFolder] = await db
.insert(documentFolders)
.values({
portId,
parentId: null,
name: 'My templates',
createdBy: TEST_USER_ID,
})
.returning();
const out = await applyEntityFkFromFolder(portId, {
folderId: userFolder!.id,
clientId: null,
});
expect(out.clientId).toBeNull();
});
});