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>
This commit is contained in:
129
tests/integration/documents-completion-email-fanout.test.ts
Normal file
129
tests/integration/documents-completion-email-fanout.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user