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:
324
tests/unit/services/documenso-place-fields.test.ts
Normal file
324
tests/unit/services/documenso-place-fields.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user