EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.
Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local: CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
multiline address wrap), uploads the flattened PDF to Documenso,
and places ONLY the 6 page-3 signature fields. Documenso never
re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.
Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.
- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
keeps its label/required
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for the version-aware Documenso placement abstraction.
|
|
* Covers v1/v2 dispatch, percent→pixel coord conversion for v1, and the pure
|
|
* default-signature layout math for 1/2/3/5 recipients.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@/lib/services/port-config', () => ({
|
|
getPortDocumensoConfig: vi.fn(),
|
|
}));
|
|
|
|
import * as portConfig from '@/lib/services/port-config';
|
|
import {
|
|
__resetDocumensoCachesForTests,
|
|
computeDefaultSignatureLayout,
|
|
placeFields,
|
|
placeDefaultSignatureFields,
|
|
voidDocument,
|
|
} from '@/lib/services/documenso-client';
|
|
|
|
const fetchMock = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
__resetDocumensoCachesForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchMock.mockReset();
|
|
vi.unstubAllGlobals();
|
|
vi.mocked(portConfig.getPortDocumensoConfig).mockReset();
|
|
});
|
|
|
|
function configurePort(version: 'v1' | 'v2'): void {
|
|
vi.mocked(portConfig.getPortDocumensoConfig).mockResolvedValue({
|
|
apiUrl: 'https://documenso.test',
|
|
apiKey: 'sk_test',
|
|
apiKeySource: 'port',
|
|
apiUrlSource: 'port',
|
|
apiVersion: version,
|
|
eoiTemplateId: 8,
|
|
defaultPathway: 'documenso-template',
|
|
eoiFillMethod: 'local',
|
|
clientRecipientId: 192,
|
|
developerRecipientId: 193,
|
|
approvalRecipientId: 194,
|
|
developerName: 'Test Developer',
|
|
developerEmail: 'dev@test.invalid',
|
|
approverName: 'Test Approver',
|
|
approverEmail: 'approver@test.invalid',
|
|
sendMode: 'manual',
|
|
embeddedSigningHost: null,
|
|
contractTemplateId: null,
|
|
reservationTemplateId: null,
|
|
developerLabel: 'Developer',
|
|
approverLabel: 'Approver',
|
|
developerUserId: null,
|
|
approverUserId: null,
|
|
signingOrder: null,
|
|
redirectUrl: null,
|
|
});
|
|
}
|
|
|
|
function okResponse(body: unknown = {}): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
describe('computeDefaultSignatureLayout', () => {
|
|
it('returns one centered field for a single recipient', () => {
|
|
const fields = computeDefaultSignatureLayout([{ id: 1, pageNumber: 3 }]);
|
|
expect(fields).toHaveLength(1);
|
|
expect(fields[0]).toMatchObject({
|
|
recipientId: 1,
|
|
type: 'SIGNATURE',
|
|
pageNumber: 3,
|
|
pageWidth: 20, // 80/1 capped at 20
|
|
pageHeight: 6,
|
|
pageY: 88,
|
|
});
|
|
expect(fields[0]!.pageX).toBeCloseTo(40, 5); // 50 - 20/2
|
|
});
|
|
|
|
it('staggers two recipients without overlap', () => {
|
|
const fields = computeDefaultSignatureLayout([
|
|
{ id: 1, pageNumber: 1 },
|
|
{ id: 2, pageNumber: 1 },
|
|
]);
|
|
expect(fields).toHaveLength(2);
|
|
expect(fields[1]!.pageX).toBeGreaterThan(fields[0]!.pageX + fields[0]!.pageWidth - 0.001);
|
|
});
|
|
|
|
it('keeps total row width <= 80% for 5 recipients', () => {
|
|
const fields = computeDefaultSignatureLayout(
|
|
[1, 2, 3, 4, 5].map((id) => ({ id, pageNumber: 1 })),
|
|
);
|
|
const totalWidth = fields[fields.length - 1]!.pageX + fields[0]!.pageWidth - fields[0]!.pageX;
|
|
expect(totalWidth).toBeLessThanOrEqual(80 + 0.001);
|
|
expect(fields.every((f) => f.pageX >= 0)).toBe(true);
|
|
expect(fields.every((f) => f.pageX + f.pageWidth <= 100)).toBe(true);
|
|
});
|
|
|
|
it('returns empty array for zero recipients', () => {
|
|
expect(computeDefaultSignatureLayout([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('placeFields v2 dispatch', () => {
|
|
beforeEach(() => configurePort('v2'));
|
|
|
|
it('makes a single bulk POST to envelope/field/create-many', async () => {
|
|
fetchMock.mockResolvedValueOnce(okResponse());
|
|
await placeFields(
|
|
'env-123',
|
|
[
|
|
{
|
|
// v2 recipient ids are numeric - Documenso's distribute response
|
|
// returns them as numbers. The CRM custom-document-upload
|
|
// service preserves them as strings or numbers; the v2 placeFields
|
|
// coercion normalises to number for the upstream payload.
|
|
recipientId: '42',
|
|
type: 'SIGNATURE',
|
|
pageNumber: 1,
|
|
pageX: 25,
|
|
pageY: 88,
|
|
pageWidth: 20,
|
|
pageHeight: 6,
|
|
fieldMeta: { label: 'Sign here' },
|
|
},
|
|
],
|
|
'port-1',
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe('https://documenso.test/api/v2/envelope/field/create-many');
|
|
expect((init as RequestInit).method).toBe('POST');
|
|
const body = JSON.parse(String((init as RequestInit).body)) as any;
|
|
expect(body.envelopeId).toBe('env-123');
|
|
// 2026-05-22: Documenso v2 expects the array under `data` (trpc-style
|
|
// createMany input), not `fields`. recipientId is a number, and the
|
|
// page-index key is `page` (not `pageNumber`).
|
|
expect(body.data[0]).toMatchObject({
|
|
recipientId: 42,
|
|
type: 'SIGNATURE',
|
|
page: 1,
|
|
positionX: 25,
|
|
positionY: 88,
|
|
width: 20,
|
|
height: 6,
|
|
fieldMeta: { label: 'Sign here' },
|
|
});
|
|
});
|
|
|
|
it('throws on non-2xx response', async () => {
|
|
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }));
|
|
await expect(
|
|
placeFields(
|
|
'env-123',
|
|
[
|
|
{
|
|
recipientId: 'rec-a',
|
|
type: 'SIGNATURE',
|
|
pageNumber: 1,
|
|
pageX: 0,
|
|
pageY: 0,
|
|
pageWidth: 10,
|
|
pageHeight: 10,
|
|
},
|
|
],
|
|
'port-1',
|
|
),
|
|
).rejects.toThrow(/signing service didn't respond/);
|
|
});
|
|
});
|
|
|
|
describe('placeFields v1 dispatch', () => {
|
|
beforeEach(() => configurePort('v1'));
|
|
|
|
it('issues one POST per field with pixel coords on a default A4 page', async () => {
|
|
fetchMock.mockResolvedValue(okResponse());
|
|
await placeFields(
|
|
'doc-123',
|
|
[
|
|
{
|
|
recipientId: 42,
|
|
type: 'SIGNATURE',
|
|
pageNumber: 1,
|
|
pageX: 50, // 50% of 595 = 298 (rounded)
|
|
pageY: 88, // 88% of 842 = 741
|
|
pageWidth: 20, // 20% of 595 = 119
|
|
pageHeight: 6, // 6% of 842 = 51
|
|
},
|
|
{
|
|
recipientId: 43,
|
|
type: 'TEXT',
|
|
pageNumber: 2,
|
|
pageX: 10,
|
|
pageY: 10,
|
|
pageWidth: 30,
|
|
pageHeight: 5,
|
|
},
|
|
],
|
|
'port-1',
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const firstCall = fetchMock.mock.calls[0]!;
|
|
expect(firstCall[0]).toBe('https://documenso.test/api/v1/documents/doc-123/fields');
|
|
const firstBody = JSON.parse(String((firstCall[1] as RequestInit).body)) as any;
|
|
expect(firstBody).toMatchObject({
|
|
recipientId: 42,
|
|
type: 'SIGNATURE',
|
|
pageNumber: 1,
|
|
});
|
|
expect(firstBody.pageX).toBe(298);
|
|
expect(firstBody.pageY).toBe(741);
|
|
expect(firstBody.pageWidth).toBe(119);
|
|
expect(firstBody.pageHeight).toBe(51);
|
|
});
|
|
|
|
it('coerces string recipientId to number on v1', async () => {
|
|
fetchMock.mockResolvedValue(okResponse());
|
|
await placeFields(
|
|
'doc-1',
|
|
[
|
|
{
|
|
recipientId: '99',
|
|
type: 'SIGNATURE',
|
|
pageNumber: 1,
|
|
pageX: 0,
|
|
pageY: 0,
|
|
pageWidth: 1,
|
|
pageHeight: 1,
|
|
},
|
|
],
|
|
'port-1',
|
|
);
|
|
const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)) as any;
|
|
expect(body.recipientId).toBe(99);
|
|
});
|
|
|
|
it('throws on non-2xx response', async () => {
|
|
fetchMock.mockResolvedValueOnce(new Response('nope', { status: 422 }));
|
|
await expect(
|
|
placeFields(
|
|
'doc-1',
|
|
[
|
|
{
|
|
recipientId: 1,
|
|
type: 'SIGNATURE',
|
|
pageNumber: 1,
|
|
pageX: 0,
|
|
pageY: 0,
|
|
pageWidth: 1,
|
|
pageHeight: 1,
|
|
},
|
|
],
|
|
'port-1',
|
|
),
|
|
).rejects.toThrow(/signing service didn't respond/);
|
|
});
|
|
});
|
|
|
|
describe('placeDefaultSignatureFields integration', () => {
|
|
it('places staggered defaults on v2 envelope', async () => {
|
|
configurePort('v2');
|
|
fetchMock.mockResolvedValueOnce(okResponse());
|
|
await placeDefaultSignatureFields(
|
|
'env-x',
|
|
[
|
|
{ id: '101', pageNumber: 4 },
|
|
{ id: '102', pageNumber: 4 },
|
|
{ id: '103', pageNumber: 4 },
|
|
],
|
|
'port-1',
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)) as any;
|
|
// 2026-05-22: Documenso v2 expects `data` (not `fields`), `page`
|
|
// (not `pageNumber`), and numeric recipientIds.
|
|
expect(body.data).toHaveLength(3);
|
|
expect(body.data.every((f: { type: string }) => f.type === 'SIGNATURE')).toBe(true);
|
|
expect(body.data.every((f: { page: number }) => f.page === 4)).toBe(true);
|
|
expect(
|
|
body.data.every((f: { recipientId: unknown }) => typeof f.recipientId === 'number'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('skips the API call entirely with zero recipients', async () => {
|
|
configurePort('v1');
|
|
await placeDefaultSignatureFields('doc-y', [], 'port-1');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('issues N per-field POSTs with pixel-converted coords on v1', async () => {
|
|
configurePort('v1');
|
|
fetchMock.mockResolvedValue(okResponse());
|
|
await placeDefaultSignatureFields(
|
|
'doc-z',
|
|
[
|
|
{ id: 7, pageNumber: 1 },
|
|
{ id: 8, pageNumber: 1 },
|
|
],
|
|
'port-1',
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
for (const call of fetchMock.mock.calls) {
|
|
expect(call[0]).toBe('https://documenso.test/api/v1/documents/doc-z/fields');
|
|
const body = JSON.parse(String((call[1] as RequestInit).body)) as any;
|
|
expect(body.type).toBe('SIGNATURE');
|
|
expect(body.pageNumber).toBe(1);
|
|
// 88% of 842 = 741 (footer band)
|
|
expect(body.pageY).toBe(741);
|
|
// height = 6% of 842 = 51
|
|
expect(body.pageHeight).toBe(51);
|
|
// width = 20% of 595 = 119
|
|
expect(body.pageWidth).toBe(119);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('voidDocument', () => {
|
|
it('issues DELETE to /api/v1/documents/{id} on v1', async () => {
|
|
configurePort('v1');
|
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
await voidDocument('doc-1', 'port-1');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://documenso.test/api/v1/documents/doc-1',
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
);
|
|
});
|
|
|
|
it('issues DELETE to /api/v2/envelope/{id} on v2', async () => {
|
|
configurePort('v2');
|
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
await voidDocument('env-1', 'port-1');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://documenso.test/api/v2/envelope/env-1',
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
);
|
|
});
|
|
|
|
it('treats 404 as idempotent success', async () => {
|
|
configurePort('v1');
|
|
fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 }));
|
|
await expect(voidDocument('doc-1', 'port-1')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('throws on other non-2xx responses', async () => {
|
|
configurePort('v2');
|
|
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }));
|
|
await expect(voidDocument('env-1', 'port-1')).rejects.toThrow(/signing service didn't respond/);
|
|
});
|
|
});
|