Files
pn-new-crm/tests/integration/documents-completion-email-fanout.test.ts
Matt 14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
Wave A (Interest+EOI form quick wins):
- Auto-select yacht after inline-create from interest form
- EOI generate dialog: "View EOI" action toast
- Interest form berth picker: formatBerthRange compact label
- Remove "Generate EOI" button from Documents tab (clean removal)
- Interest auto-assign: only sales_agent/sales_manager auto-claim
  ownership on create (explicit role check via user_port_roles join)
- LinkedBerthRowItem dims: drop "D" suffix + "L × W" format
- ExternalEoiUploadDialog: prefillSignatories prop threaded from
  active EOI signers
- EOI signature progress on Overview milestone card footer

Wave B (a11y + i18n sweeps):
- aria-live on supplemental-info error state
- text-[10px] -> text-xs in client-pipeline-summary
- Currency formatter: locale default removed (Intl uses runtime)
- en-US/en-GB hardcoded toLocaleString swept across 13 components

Wave C (Primary berth always in EOI bundle):
- Service guard strengthened on update path
- Migration 0083 backfills historical primary rows

Wave D (Onboarding super_admin discoverability):
- /api/v1/admin/onboarding/status endpoint + shared service
- Topbar OnboardingBanner (super_admin, session-dismissible)
- OnboardingTile dashboard widget (rail group, self-hides at 100%)
- Celebration toast + invalidate of shared status on last tick

Wave E (Branded post-completion email idempotency):
- Verified handleDocumentCompleted already owns the email fan-out
- Added regression test for the polling path + idempotency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:40:37 +02:00

130 lines
4.9 KiB
TypeScript

/**
* 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<typeof import('@/lib/services/documenso-client')>();
return {
...real,
downloadSignedPdf: vi.fn(async () => Buffer.from('%PDF-1.4 stub\n')),
};
});
const stubPuts = new Map<string, Buffer>();
vi.mock('@/lib/storage', async (importOriginal) => {
const real = await importOriginal<typeof import('@/lib/storage')>();
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<void>>(async () => {});
vi.mock('@/lib/services/document-signing-emails.service', async (importOriginal) => {
const real =
await importOriginal<typeof import('@/lib/services/document-signing-emails.service')>();
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);
});
});