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:
Matt Ciaccio
2026-04-28 02:22:04 +02:00
parent af2db06244
commit da44e8ecbe
5 changed files with 551 additions and 5 deletions

View File

@@ -23,6 +23,7 @@ const envSchema = z.object({
// Documenso // Documenso
DOCUMENSO_API_URL: z.string().url(), DOCUMENSO_API_URL: z.string().url(),
DOCUMENSO_API_KEY: z.string().min(1), DOCUMENSO_API_KEY: z.string().min(1),
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16), DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8), DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192), DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),

View File

@@ -1,16 +1,23 @@
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
interface DocumensoCreds { interface DocumensoCreds {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
apiVersion: DocumensoApiVersion;
} }
async function resolveCreds(portId?: string): Promise<DocumensoCreds> { 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); 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( async function documensoFetch(
@@ -169,3 +176,198 @@ export async function checkDocumensoHealth(
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; 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}`);
}
}

View File

@@ -24,6 +24,7 @@ import {
createDocument as documensoCreate, createDocument as documensoCreate,
sendDocument as documensoSend, sendDocument as documensoSend,
downloadSignedPdf, downloadSignedPdf,
voidDocument as documensoVoid,
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import type { import type {
CreateDocumentInput, CreateDocumentInput,
@@ -832,7 +833,20 @@ export async function cancelDocument(
throw new ConflictError(`Document is already ${existing.status}`); 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 const [updated] = await db
.update(documents) .update(documents)

View File

@@ -27,6 +27,7 @@ export const SETTING_KEYS = {
// Documenso / EOI // Documenso / EOI
documensoApiUrlOverride: 'documenso_api_url_override', documensoApiUrlOverride: 'documenso_api_url_override',
documensoApiKeyOverride: 'documenso_api_key_override', documensoApiKeyOverride: 'documenso_api_key_override',
documensoApiVersionOverride: 'documenso_api_version_override',
documensoEoiTemplateId: 'documenso_eoi_template_id', documensoEoiTemplateId: 'documenso_eoi_template_id',
eoiDefaultPathway: 'eoi_default_pathway', eoiDefaultPathway: 'eoi_default_pathway',
@@ -119,18 +120,21 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
// ─── Documenso ────────────────────────────────────────────────────────────── // ─── Documenso ──────────────────────────────────────────────────────────────
export type EoiPathway = 'documenso-template' | 'inapp'; export type EoiPathway = 'documenso-template' | 'inapp';
export type DocumensoApiVersion = 'v1' | 'v2';
export interface PortDocumensoConfig { export interface PortDocumensoConfig {
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
apiVersion: DocumensoApiVersion;
eoiTemplateId: string | null; eoiTemplateId: string | null;
defaultPathway: EoiPathway; defaultPathway: EoiPathway;
} }
export async function getPortDocumensoConfig(portId: string): Promise<PortDocumensoConfig> { 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.documensoApiUrlOverride, portId),
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId), readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
readSetting<DocumensoApiVersion>(SETTING_KEYS.documensoApiVersionOverride, portId),
readSetting<string>(SETTING_KEYS.documensoEoiTemplateId, portId), readSetting<string>(SETTING_KEYS.documensoEoiTemplateId, portId),
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId), readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
]); ]);
@@ -138,6 +142,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
return { return {
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL, apiUrl: apiUrl ?? env.DOCUMENSO_API_URL,
apiKey: apiKey ?? env.DOCUMENSO_API_KEY, apiKey: apiKey ?? env.DOCUMENSO_API_KEY,
apiVersion: apiVersion ?? env.DOCUMENSO_API_VERSION,
eoiTemplateId: eoiTemplateId ?? null, eoiTemplateId: eoiTemplateId ?? null,
defaultPathway: defaultPathway ?? 'documenso-template', defaultPathway: defaultPathway ?? 'documenso-template',
}; };

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