/** * 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/); }); });