2026-04-28 02:22:04 +02:00
|
|
|
/**
|
|
|
|
|
* 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,
|
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints
Closes the second wave of HIGH-priority audit findings:
* fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps
Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can
no longer pin a worker concurrency slot indefinitely. OpenAI client
passes timeout: 30_000. ImapFlow gets socket / greeting / connection
timeouts.
* SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP,
closes Socket.io, and disconnects Redis before exit; compose
stop_grace_period bumped to 30s. Adds closeSocketServer() helper.
* env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and
filesystem.ts now reads from env (a typo can no longer silently
disable the multi-node guard).
* Per-port Documenso template + recipient IDs land in system_settings
with env fallback (PortDocumensoConfig now exposes eoiTemplateId,
clientRecipientId, developerRecipientId, approvalRecipientId).
document-templates.ts uses the per-port config and threads portId
into documensoGenerateFromTemplate().
* Migration 0042 wires the eleven HIGH-tier missing FK constraints
(documents/files/interests/reminders/berth_waiting_list/
form_submissions) plus polymorphic CHECK round 2
(yacht_ownership_history.owner_type, document_sends.document_kind),
invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK.
Drizzle schema columns updated to .references(...) where possible
so the misleading "FK wired in relations.ts" comments are gone.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 +
MED §§14,15,16,18.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
|
|
|
eoiTemplateId: 8,
|
2026-04-28 02:22:04 +02:00
|
|
|
defaultPathway: 'documenso-template',
|
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints
Closes the second wave of HIGH-priority audit findings:
* fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps
Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can
no longer pin a worker concurrency slot indefinitely. OpenAI client
passes timeout: 30_000. ImapFlow gets socket / greeting / connection
timeouts.
* SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP,
closes Socket.io, and disconnects Redis before exit; compose
stop_grace_period bumped to 30s. Adds closeSocketServer() helper.
* env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and
filesystem.ts now reads from env (a typo can no longer silently
disable the multi-node guard).
* Per-port Documenso template + recipient IDs land in system_settings
with env fallback (PortDocumensoConfig now exposes eoiTemplateId,
clientRecipientId, developerRecipientId, approvalRecipientId).
document-templates.ts uses the per-port config and threads portId
into documensoGenerateFromTemplate().
* Migration 0042 wires the eleven HIGH-tier missing FK constraints
(documents/files/interests/reminders/berth_waiting_list/
form_submissions) plus polymorphic CHECK round 2
(yacht_ownership_history.owner_type, document_sends.document_kind),
invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK.
Drizzle schema columns updated to .references(...) where possible
so the misleading "FK wired in relations.ts" comments are gone.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 +
MED §§14,15,16,18.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
|
|
|
clientRecipientId: 192,
|
|
|
|
|
developerRecipientId: 193,
|
|
|
|
|
approvalRecipientId: 194,
|
2026-04-28 02:22:04 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|