/** * Regression test for the audit finding "branded post-completion email * not firing when Documenso webhook is unreachable". * * Both delivery paths (webhook receiver + polling fallback) call * handleDocumentCompleted, so the branded "all signed" email fan-out * inside that function should fire identically regardless of which path * triggered it. This test exercises the polling path explicitly and * asserts the email is queued. */ import { describe, it, expect, beforeAll, vi } from 'vitest'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documents, documentFolders, documentSigners } from '@/lib/db/schema/documents'; import { user } from '@/lib/db/schema/users'; import { handleDocumentCompleted } from '@/lib/services/documents.service'; import { ensureSystemRoots } from '@/lib/services/document-folders.service'; import { makeClient, makePort } from '../helpers/factories'; // Stub Documenso download so we don't hit the network. vi.mock('@/lib/services/documenso-client', async (importOriginal) => { const real = await importOriginal(); return { ...real, downloadSignedPdf: vi.fn(async () => Buffer.from('%PDF-1.4 stub\n')), }; }); const stubPuts = new Map(); vi.mock('@/lib/storage', async (importOriginal) => { const real = await importOriginal(); return { ...real, getStorageBackend: vi.fn(async () => ({ put: async (path: string, data: Buffer) => { stubPuts.set(path, data); }, get: async (path: string) => stubPuts.get(path) ?? Buffer.alloc(0), head: async (path: string) => { const buf = stubPuts.get(path); return buf ? { sizeBytes: buf.length, contentType: 'application/pdf' } : null; }, delete: async (path: string) => { stubPuts.delete(path); }, presignedGet: async () => 'http://stub-url', presignedPut: async () => ({ url: 'http://stub-url', fields: {} }), })), }; }); // Spy on the email fan-out — replace it with a vi.fn we can assert on. type SendArgs = Parameters< (typeof import('@/lib/services/document-signing-emails.service'))['sendSigningCompleted'] >[0]; const sendSigningCompletedSpy = vi.fn<(args: SendArgs) => Promise>(async () => {}); vi.mock('@/lib/services/document-signing-emails.service', async (importOriginal) => { const real = await importOriginal(); return { ...real, sendSigningCompleted: (args: SendArgs) => sendSigningCompletedSpy(args), }; }); 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('handleDocumentCompleted · email fan-out (polling-path regression)', () => { it('queues sendSigningCompleted exactly once for the polling-driven completion', async () => { sendSigningCompletedSpy.mockClear(); const port = await makePort(); const portId = port.id; await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); await ensureSystemRoots(portId, TEST_USER_ID); const client = await makeClient({ portId }); const documensoId = `docu-email-fanout-${Date.now()}`; const [doc] = await db .insert(documents) .values({ portId, clientId: client.id, documensoId, documentType: 'eoi', title: 'Email fan-out test EOI', status: 'sent', createdBy: TEST_USER_ID, }) .returning(); await db.insert(documentSigners).values({ documentId: doc!.id, signerName: 'Test Signer', signerEmail: 'signer@example.com', signerRole: 'client', signingOrder: 1, status: 'signed', }); // Polling path: invoked from src/jobs/processors/documenso-poll.ts the // exact same way the webhook receiver invokes it. If this stops calling // the fan-out, the audit symptom returns. await handleDocumentCompleted({ documentId: documensoId, portId }); expect(sendSigningCompletedSpy).toHaveBeenCalledTimes(1); const firstCall = sendSigningCompletedSpy.mock.calls[0]; expect(firstCall).toBeDefined(); const args = firstCall![0]; expect(args.recipients.some((r: { email: string }) => r.email === 'signer@example.com')).toBe( true, ); expect(args.documentLabel).toBeTruthy(); expect(args.signedPdfFileId).toBeTruthy(); // Idempotency: a second call (e.g. webhook arriving after the poll // already completed) must NOT re-fire the email. The status+signedFileId // gate in handleDocumentCompleted short-circuits before reaching the // fan-out branch. await handleDocumentCompleted({ documentId: documensoId, portId }); expect(sendSigningCompletedSpy).toHaveBeenCalledTimes(1); }); });