merge: PR2 — Documenso v1/v2 abstraction (Phase A)
This commit is contained in:
@@ -23,6 +23,7 @@ const envSchema = z.object({
|
||||
// Documenso
|
||||
DOCUMENSO_API_URL: z.string().url(),
|
||||
DOCUMENSO_API_KEY: z.string().min(1),
|
||||
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
|
||||
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
|
||||
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
|
||||
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
|
||||
|
||||
interface DocumensoCreds {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiVersion: DocumensoApiVersion;
|
||||
}
|
||||
|
||||
async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
|
||||
if (!portId) return { baseUrl: env.DOCUMENSO_API_URL, apiKey: env.DOCUMENSO_API_KEY };
|
||||
if (!portId) {
|
||||
return {
|
||||
baseUrl: env.DOCUMENSO_API_URL,
|
||||
apiKey: env.DOCUMENSO_API_KEY,
|
||||
apiVersion: env.DOCUMENSO_API_VERSION,
|
||||
};
|
||||
}
|
||||
const cfg = await getPortDocumensoConfig(portId);
|
||||
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey };
|
||||
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
|
||||
}
|
||||
|
||||
async function documensoFetch(
|
||||
@@ -169,3 +176,198 @@ export async function checkDocumensoHealth(
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Version-aware abstractions (Phase A PR2) ─────────────────────────────────
|
||||
//
|
||||
// Documenso v1.13 and v2.x diverge on field placement and document deletion:
|
||||
//
|
||||
// v1.13: per-field POST /api/v1/documents/{id}/fields with PIXEL coords;
|
||||
// DELETE /api/v1/documents/{id} for void.
|
||||
// v2.x: bulk POST /api/v2/envelope/field/create-many with PERCENT
|
||||
// coords (0-100) and rich `fieldMeta`;
|
||||
// DELETE /api/v2/envelope/{id} for void.
|
||||
//
|
||||
// Callers always work in PERCENT (0-100). For v1 the abstraction multiplies by
|
||||
// the page dimensions returned by Documenso (cached per docId for the lifetime
|
||||
// of the process — fields for a given doc usually go in a single batch).
|
||||
|
||||
export type DocumensoFieldType = 'SIGNATURE' | 'INITIALS' | 'DATE' | 'TEXT' | 'EMAIL';
|
||||
|
||||
export interface DocumensoFieldPlacement {
|
||||
/** Documenso recipient id; v1 expects number, v2 string — coerced internally. */
|
||||
recipientId: number | string;
|
||||
type: DocumensoFieldType;
|
||||
pageNumber: number;
|
||||
/** All four are 0-100 percent of page dimensions. */
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
/** Optional v2 fieldMeta — passed through verbatim, ignored on v1. */
|
||||
fieldMeta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DocumensoPageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt
|
||||
|
||||
const pageDimensionCache = new Map<string, DocumensoPageDimensions>();
|
||||
|
||||
/** Test seam — clears the page-dimension memoization. */
|
||||
export function __resetDocumensoCachesForTests(): void {
|
||||
pageDimensionCache.clear();
|
||||
}
|
||||
|
||||
async function getPageDimensions(docId: string, portId?: string): Promise<DocumensoPageDimensions> {
|
||||
const cached = pageDimensionCache.get(docId);
|
||||
if (cached) return cached;
|
||||
// v1 doesn't expose page dimensions cleanly via the public API; the auto-
|
||||
// placement use case is footer-anchored signature fields, where a default A4
|
||||
// page rendered by Documenso is a safe assumption. Real page dims can be
|
||||
// wired in a follow-up by parsing the document/document-data endpoints.
|
||||
void portId;
|
||||
pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS);
|
||||
return DEFAULT_PAGE_DIMENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place one or more fields on a Documenso document. Coordinates are PERCENT
|
||||
* (0-100) and converted to pixels for v1 internally.
|
||||
*
|
||||
* v1: dispatches one POST per field (no bulk endpoint).
|
||||
* v2: single bulk POST.
|
||||
*/
|
||||
export async function placeFields(
|
||||
docId: string,
|
||||
fields: DocumensoFieldPlacement[],
|
||||
portId?: string,
|
||||
): Promise<void> {
|
||||
if (fields.length === 0) return;
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
|
||||
if (apiVersion === 'v2') {
|
||||
const v2Fields = fields.map((f) => ({
|
||||
recipientId: String(f.recipientId),
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
positionX: f.pageX,
|
||||
positionY: f.pageY,
|
||||
width: f.pageWidth,
|
||||
height: f.pageHeight,
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
}));
|
||||
// Note: v2 endpoint shape (envelopeId/recipientId types) must be
|
||||
// confirmed against a live Documenso 2.x instance — see PR11 realapi
|
||||
// suite. Spec risk register flags this drift as the top v2 risk.
|
||||
const res = await fetch(`${baseUrl}/api/v2/envelope/field/create-many`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: docId, fields: v2Fields }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
logger.error({ docId, status: res.status, err, portId }, 'Documenso v2 placeFields error');
|
||||
throw new Error(`Documenso v2 placeFields error: ${res.status}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = await getPageDimensions(docId, portId);
|
||||
for (const f of fields) {
|
||||
const body = {
|
||||
recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
pageX: Math.round((f.pageX / 100) * dims.width),
|
||||
pageY: Math.round((f.pageY / 100) * dims.height),
|
||||
pageWidth: Math.round((f.pageWidth / 100) * dims.width),
|
||||
pageHeight: Math.round((f.pageHeight / 100) * dims.height),
|
||||
};
|
||||
const res = await fetch(`${baseUrl}/api/v1/documents/${docId}/fields`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
logger.error({ docId, status: res.status, err, portId }, 'Documenso v1 placeField error');
|
||||
throw new Error(`Documenso v1 placeField error: ${res.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-position one SIGNATURE field per recipient at the last-page footer,
|
||||
* staggered horizontally so multiple signers don't overlap. Used by the
|
||||
* upload-path wizard — admins can refine in Documenso afterwards.
|
||||
*
|
||||
* Layout (percent of page):
|
||||
* y = 88 (footer band)
|
||||
* height = 6
|
||||
* width = min(20, 80 / N)
|
||||
* x = i * (80/N) + (40 - 80/N * N / 2) (centered row)
|
||||
*/
|
||||
export async function placeDefaultSignatureFields(
|
||||
docId: string,
|
||||
recipients: Array<{ id: number | string; pageNumber: number }>,
|
||||
portId?: string,
|
||||
): Promise<void> {
|
||||
if (recipients.length === 0) return;
|
||||
const fields: DocumensoFieldPlacement[] = computeDefaultSignatureLayout(recipients);
|
||||
await placeFields(docId, fields, portId);
|
||||
}
|
||||
|
||||
/** Pure function exported for unit testing layout math. */
|
||||
export function computeDefaultSignatureLayout(
|
||||
recipients: Array<{ id: number | string; pageNumber: number }>,
|
||||
): DocumensoFieldPlacement[] {
|
||||
const n = recipients.length;
|
||||
if (n === 0) return [];
|
||||
const slot = Math.min(20, 80 / n); // percent width per signer
|
||||
const rowWidth = slot * n;
|
||||
const startX = 50 - rowWidth / 2;
|
||||
return recipients.map((r, i) => ({
|
||||
recipientId: r.id,
|
||||
type: 'SIGNATURE',
|
||||
pageNumber: r.pageNumber,
|
||||
pageX: Math.max(0, startX + i * slot),
|
||||
pageY: 88,
|
||||
pageWidth: slot,
|
||||
pageHeight: 6,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Void/cancel a Documenso document.
|
||||
*
|
||||
* v1: DELETE /api/v1/documents/{id}
|
||||
* v2: DELETE /api/v2/envelope/{id}
|
||||
*
|
||||
* Idempotent on 404 (already gone) — logs and resolves.
|
||||
*/
|
||||
export async function voidDocument(docId: string, portId?: string): Promise<void> {
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
if (res.status === 404) {
|
||||
logger.warn({ docId, portId }, 'Documenso voidDocument: already deleted');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
logger.error({ docId, status: res.status, err, portId }, 'Documenso voidDocument error');
|
||||
throw new Error(`Documenso voidDocument error: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createDocument as documensoCreate,
|
||||
sendDocument as documensoSend,
|
||||
downloadSignedPdf,
|
||||
voidDocument as documensoVoid,
|
||||
} from '@/lib/services/documenso-client';
|
||||
import type {
|
||||
CreateDocumentInput,
|
||||
@@ -832,7 +833,20 @@ export async function cancelDocument(
|
||||
throw new ConflictError(`Document is already ${existing.status}`);
|
||||
}
|
||||
|
||||
// PR2 will wire the Documenso void here.
|
||||
// CRM is the system of record for cancellation status. A transient
|
||||
// Documenso failure shouldn't block the user from marking the doc cancelled
|
||||
// here — voidDocument already treats 404 as success, and the periodic
|
||||
// webhook receiver will reconcile if the remote void eventually lands.
|
||||
if (existing.documensoId) {
|
||||
try {
|
||||
await documensoVoid(existing.documensoId, portId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, documentId, documensoId: existing.documensoId },
|
||||
'Documenso void failed; cancelling locally anyway',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(documents)
|
||||
|
||||
@@ -27,6 +27,7 @@ export const SETTING_KEYS = {
|
||||
// Documenso / EOI
|
||||
documensoApiUrlOverride: 'documenso_api_url_override',
|
||||
documensoApiKeyOverride: 'documenso_api_key_override',
|
||||
documensoApiVersionOverride: 'documenso_api_version_override',
|
||||
documensoEoiTemplateId: 'documenso_eoi_template_id',
|
||||
eoiDefaultPathway: 'eoi_default_pathway',
|
||||
|
||||
@@ -119,18 +120,21 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
||||
// ─── Documenso ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type EoiPathway = 'documenso-template' | 'inapp';
|
||||
export type DocumensoApiVersion = 'v1' | 'v2';
|
||||
|
||||
export interface PortDocumensoConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
apiVersion: DocumensoApiVersion;
|
||||
eoiTemplateId: string | null;
|
||||
defaultPathway: EoiPathway;
|
||||
}
|
||||
|
||||
export async function getPortDocumensoConfig(portId: string): Promise<PortDocumensoConfig> {
|
||||
const [apiUrl, apiKey, eoiTemplateId, defaultPathway] = await Promise.all([
|
||||
const [apiUrl, apiKey, apiVersion, eoiTemplateId, defaultPathway] = await Promise.all([
|
||||
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
|
||||
readSetting<DocumensoApiVersion>(SETTING_KEYS.documensoApiVersionOverride, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoEoiTemplateId, portId),
|
||||
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
|
||||
]);
|
||||
@@ -138,6 +142,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
return {
|
||||
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL,
|
||||
apiKey: apiKey ?? env.DOCUMENSO_API_KEY,
|
||||
apiVersion: apiVersion ?? env.DOCUMENSO_API_VERSION,
|
||||
eoiTemplateId: eoiTemplateId ?? null,
|
||||
defaultPathway: defaultPathway ?? 'documenso-template',
|
||||
};
|
||||
|
||||
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