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>
130 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|