feat(documenso): version-aware field placement + void abstractions

Adds DOCUMENSO_API_VERSION env (default v1) plus per-port override.
Introduces placeFields, placeDefaultSignatureFields, and voidDocument
that hide v1 (per-field POST, pixel coords) vs v2 (bulk POST, percent +
fieldMeta) differences. cancelDocument now voids in Documenso first and
treats transient void failures as recoverable so the CRM stays the
system of record. 16 unit specs cover dispatch, layout math, idempotent
404, and v1 pixel conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:22:04 +02:00
parent af2db06244
commit da44e8ecbe
5 changed files with 551 additions and 5 deletions

View File

@@ -0,0 +1,324 @@
/**
* 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',
apiVersion: version,
eoiTemplateId: null,
defaultPathway: 'documenso-template',
});
}
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',
[
{
recipientId: 'rec-a',
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));
expect(body.envelopeId).toBe('env-123');
expect(body.fields[0]).toMatchObject({
recipientId: 'rec-a',
type: 'SIGNATURE',
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(/v2 placeFields/);
});
});
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));
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));
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(/v1 placeField/);
});
});
describe('placeDefaultSignatureFields integration', () => {
it('places staggered defaults on v2 envelope', async () => {
configurePort('v2');
fetchMock.mockResolvedValueOnce(okResponse());
await placeDefaultSignatureFields(
'env-x',
[
{ id: 'r1', pageNumber: 4 },
{ id: 'r2', pageNumber: 4 },
{ id: 'r3', pageNumber: 4 },
],
'port-1',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body));
expect(body.fields).toHaveLength(3);
expect(body.fields.every((f: { type: string }) => f.type === 'SIGNATURE')).toBe(true);
expect(body.fields.every((f: { pageNumber: number }) => f.pageNumber === 4)).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));
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(/voidDocument/);
});
});