325 lines
9.6 KiB
TypeScript
325 lines
9.6 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',
|
||
|
|
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/);
|
||
|
|
});
|
||
|
|
});
|