Files
pn-new-crm/tests/unit/services/documenso-place-fields.test.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

357 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',
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/);
});
});