feat(documenso-phase-3): custom document upload-to-Documenso

Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.

Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
  full PDF → Documenso → local-state-update flow:
    1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
       same posture as berth-pdf + brochures).
    2. Stores the source PDF via getStorageBackend(), works on s3 +
       filesystem backends. Auto-files into the client's entity folder
       when resolvable.
    3. Inserts the documents row (status=draft → sent), with the file
       FK + interest link + clientId snapshot.
    4. Documenso round-trip via createDocument → sendDocument →
       placeFields. Per-port apiVersion drives v1 vs v2 (existing
       client handles both — v1: /api/v1/documents; v2: envelope/create
       multipart). meta.signingOrder + redirectUrl flow through.
    5. Captures recipient signingUrl + token into document_signers so
       the Phase 2 cascade picks them up.
    6. Auto-send first invitation when port.eoi_send_mode === 'auto';
       stamps invitedAt to suppress duplicate cascades.
    7. Advances pipeline stage to contract_sent.

- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
  POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
  size (≤50MB), all 11 Documenso field types. Permission-gated by
  documents.send_for_signing + interests.edit (matches the
  external-eoi precedent — the auto-advance side-effect is
  interest-mutating).

Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.

Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.

The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.

Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.

Tests: 1326 → 1334 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:52:21 +02:00
parent 3dc4c6ff14
commit 33d0426911
3 changed files with 736 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import {
uploadDocumentForSigning,
type CustomDocumentType,
type CustomRecipientRole,
} from '@/lib/services/custom-document-upload.service';
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
/**
* Phase 3 — Custom document upload-to-Documenso endpoint.
*
* POST `/api/v1/interests/[id]/upload-for-signing`
*
* Body: multipart/form-data
* - file: the source PDF (browser-supplied; magic-byte verified)
* - documentType: 'contract' | 'reservation_agreement'
* - title: customer-visible document title
* - recipients: JSON-encoded CustomDocumentRecipient[]
* - fields: JSON-encoded field placement array
*
* The Contract + Reservation tabs (Phase 4) post here from their
* drag-drop UI. Tests can invoke the service directly.
*
* Permission: documents.send_for_signing — sending a document for
* signing is destructive (queues an outbound email + an admin-visible
* Documenso doc). Plus interests.edit because the pipeline-stage
* auto-advance side-effect is interest-mutating (matches the
* external-eoi precedent).
*/
const recipientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
role: z.enum(['SIGNER', 'APPROVER', 'CC']),
signingOrder: z.number().int().positive(),
});
const fieldSchema = z.object({
recipientIndex: z.number().int().nonnegative(),
type: z.enum([
'SIGNATURE',
'FREE_SIGNATURE',
'INITIALS',
'DATE',
'EMAIL',
'NAME',
'TEXT',
'NUMBER',
'CHECKBOX',
'DROPDOWN',
'RADIO',
]),
pageNumber: z.number().int().positive(),
pageX: z.number().min(0).max(100),
pageY: z.number().min(0).max(100),
pageWidth: z.number().positive().max(100),
pageHeight: z.number().positive().max(100),
fieldMeta: z.record(z.string(), z.unknown()).optional(),
});
const documentTypeSchema = z.enum(['contract', 'reservation_agreement']);
const MAX_PDF_BYTES = 50 * 1024 * 1024;
function parseJsonField<T>(raw: unknown, schema: z.ZodType<T>, label: string): T {
if (typeof raw !== 'string') {
throw new ValidationError(`Missing or non-string '${label}' field`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new ValidationError(`'${label}' is not valid JSON`);
}
const result = schema.safeParse(parsed);
if (!result.success) {
throw new ValidationError(`'${label}' validation failed: ${result.error.issues[0]?.message}`);
}
return result.data;
}
export const POST = withAuth(
withPermission('documents', 'send_for_signing', async (req, ctx, params) => {
try {
const interestId = params.id;
if (!interestId) throw new NotFoundError('Interest');
if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) {
throw new ForbiddenError('interests.edit required to upload a document for signing');
}
const form = await req.formData();
// ─── file ──────────────────────────────────────────────────
const file = form.get('file');
if (!file || !(file instanceof File)) {
throw new ValidationError('Missing file');
}
if (file.size > MAX_PDF_BYTES) {
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
}
const buffer = Buffer.from(await file.arrayBuffer());
// Magic-byte check at the route boundary too — service repeats it
// as defense in depth but a bad upload should error before we hit
// any side-effecting code.
if (!isPdfMagic(buffer)) {
throw new ValidationError('Uploaded file is not a PDF');
}
// ─── scalar fields ─────────────────────────────────────────
const documentType = documentTypeSchema.parse(form.get('documentType')) as CustomDocumentType;
const title = z.string().min(1).max(255).parse(form.get('title'));
// ─── JSON fields ───────────────────────────────────────────
const recipients = parseJsonField(
form.get('recipients'),
z.array(recipientSchema).min(1).max(20),
'recipients',
);
const fields = parseJsonField(
form.get('fields'),
z.array(fieldSchema).min(1).max(200),
'fields',
);
const result = await uploadDocumentForSigning({
interestId,
portId: ctx.portId,
portSlug: ctx.portSlug,
documentType,
title,
pdfBuffer: buffer,
filename: file.name || `${documentType}.pdf`,
recipients: recipients.map((r) => ({
name: r.name,
email: r.email,
role: r.role as CustomRecipientRole,
signingOrder: r.signingOrder,
})),
fields,
meta: {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,447 @@
/**
* Phase 3 — Custom document upload-to-Documenso.
*
* The Contract + Reservation tabs upload a draft PDF, configure
* recipients + fields, and hand the bundle to Documenso for signing.
* This service is the backend foundation; the UI dialog (Phase 4)
* eventually POSTs to /api/v1/interests/[id]/upload-for-signing which
* delegates here.
*
* Flow:
* 1. Magic-byte verify the PDF (defense vs. mislabelled bytes —
* same posture as berth-pdf + brochures).
* 2. Insert a `files` row + push the PDF into storage. The row is
* port-scoped + entity-scoped (interest) so it appears in the
* Documents tab + the interest's entity folder.
* 3. Insert a `documents` row in `draft` status linked to the
* interest + the source file.
* 4. Documenso round-trip: createDocument → placeFields → sendDocument.
* Per-port apiVersion drives v1 vs v2 routing (existing client
* handles both — v1: legacy /api/v1/documents; v2: envelope/create
* multipart).
* 5. Capture per-recipient signingUrl + token into `document_signers`
* so the webhook cascade picks them up (Phase 2).
* 6. If the port's `eoi_send_mode === 'auto'`, fire the branded
* invitation to the first signer immediately + stamp `invitedAt`.
* Manual mode leaves it to the rep's "Send invitation" button.
*
* Multi-tenant guard: the interest is read with both `id` AND `portId`
* filters; cross-port upload attempts return NotFoundError before any
* Documenso traffic.
*/
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documents, documentSigners, files } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env';
import { getStorageBackend } from '@/lib/storage';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
import {
createDocument as documensoCreate,
sendDocument as documensoSend,
placeFields,
type DocumensoFieldPlacement,
type DocumensoRecipient,
} from '@/lib/services/documenso-client';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
sendSigningInvitation,
type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import { DOC_TYPE_LABEL, extractSigningToken } from '@/lib/services/documenso-signers';
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
import { advanceStageIfBehind } from '@/lib/services/interests.service';
import { emitToRoom } from '@/lib/socket/server';
import { logger } from '@/lib/logger';
/** Document types this service accepts. EOI is template-driven (uses
* the dedicated EOI path); contract + reservation_agreement upload a
* rep-supplied PDF and place fields per-deal. */
export type CustomDocumentType = 'contract' | 'reservation_agreement';
/** Documenso recipient role — narrowed from the full enum to the
* three values the custom-upload flow accepts. APPROVER + CC are
* documented in plan Q4. VIEWER + ASSISTANT are out of scope for
* marina contracts today. */
export type CustomRecipientRole = 'SIGNER' | 'APPROVER' | 'CC';
export interface CustomDocumentRecipient {
name: string;
email: string;
role: CustomRecipientRole;
signingOrder: number;
}
export interface UploadDocumentForSigningArgs {
interestId: string;
portId: string;
portSlug: string;
documentType: CustomDocumentType;
title: string;
pdfBuffer: Buffer;
filename: string;
recipients: CustomDocumentRecipient[];
/** Field placements come from Phase 4's drag-drop UI or auto-detect.
* `recipientId` is the INDEX into `recipients` — the service maps
* it to the resolved Documenso recipient id after createDocument
* responds. */
fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
meta: AuditMeta;
}
export interface UploadDocumentForSigningResult {
documentId: string;
documensoDocumentId: string;
/** Map of recipient email → branded embedded signing URL. The UI
* exposes these so a rep can copy a link out for manual delivery in
* manual-send mode. */
signingUrls: Record<string, string>;
}
const PDF_MIME = 'application/pdf';
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB — matches MAX_FILE_SIZE default
export async function uploadDocumentForSigning(
args: UploadDocumentForSigningArgs,
): Promise<UploadDocumentForSigningResult> {
const {
interestId,
portId,
portSlug,
documentType,
title,
pdfBuffer,
filename,
recipients,
fields,
meta,
} = args;
// ─── Validation ──────────────────────────────────────────────────
if (recipients.length === 0) {
throw new ValidationError('At least one recipient is required');
}
if (fields.length === 0) {
throw new ValidationError('At least one field placement is required');
}
if (pdfBuffer.length === 0) {
throw new ValidationError('PDF buffer is empty');
}
if (pdfBuffer.length > MAX_PDF_BYTES) {
throw new ValidationError(`PDF exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
}
if (!isPdfMagic(pdfBuffer)) {
throw new ValidationError('Uploaded file is not a PDF (magic-byte check failed)');
}
// Every field's recipientIndex must reference a real recipient. Out-
// of-range indexes silently maps to undefined in the recipient lookup
// below — fail loudly here instead.
for (const f of fields) {
if (f.recipientIndex < 0 || f.recipientIndex >= recipients.length) {
throw new ValidationError(
`Field recipientIndex=${f.recipientIndex} is out of range (have ${recipients.length} recipients)`,
);
}
}
// Defense-in-depth: a duplicate signing-order would let Documenso
// accept the doc but break the cascading-invite logic (next signer
// picker assumes a strict ordering).
const orders = new Set<number>();
for (const r of recipients) {
if (orders.has(r.signingOrder)) {
throw new ValidationError(`Duplicate signingOrder=${r.signingOrder} in recipients`);
}
orders.add(r.signingOrder);
}
// ─── Tenant guard ────────────────────────────────────────────────
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port');
// ─── Store source PDF ────────────────────────────────────────────
// The source PDF needs to live in storage so reps + admins can view
// the pre-signed draft in the Files tab. We also use the resolved
// storage key as the `documents.fileId` reference.
const sourceFileId = crypto.randomUUID();
const sourceStoragePath = buildStoragePath(
portSlug,
documentType === 'contract' ? 'contract-source' : 'reservation-source',
interestId,
sourceFileId,
'pdf',
);
const storage = await getStorageBackend();
await storage.put(sourceStoragePath, pdfBuffer, {
contentType: PDF_MIME,
sizeBytes: pdfBuffer.length,
});
// Look up the interest's primary client so the auto-filed folder
// ends up under the right entity subfolder. Falls back to root when
// the chain has no resolvable owner.
let entityFolderId: string | null = null;
if (interest.clientId) {
try {
const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system');
entityFolderId = folder.id;
} catch (err) {
logger.warn(
{ err, interestId, clientId: interest.clientId },
'ensureEntityFolder failed during custom-document-upload — filing at root',
);
}
}
const [sourceFileRecord] = await db
.insert(files)
.values({
portId,
clientId: interest.clientId,
folderId: entityFolderId,
filename,
originalName: filename,
mimeType: PDF_MIME,
sizeBytes: String(pdfBuffer.length),
storagePath: sourceStoragePath,
storageBucket: env.MINIO_BUCKET,
category: documentType,
uploadedBy: meta.userId,
})
.returning();
if (!sourceFileRecord) {
// Best-effort compensating delete — we put a blob but the DB row
// failed to land, leaving an orphan otherwise.
await storage.delete(sourceStoragePath).catch(() => {});
throw new ConflictError('Failed to record source file');
}
// ─── Insert the document row (status=draft) ───────────────────────
const [docRow] = await db
.insert(documents)
.values({
portId,
interestId,
clientId: interest.clientId,
fileId: sourceFileRecord.id,
documentType,
title,
status: 'draft',
createdBy: meta.userId,
})
.returning();
if (!docRow) throw new ConflictError('Failed to insert document row');
// ─── Local signer rows (pre-Documenso) ────────────────────────────
// Insert with status=pending; we'll fill signingUrl + signingToken
// after Documenso responds.
const signerRows = await db
.insert(documentSigners)
.values(
recipients.map((r) => ({
documentId: docRow.id,
signerName: r.name,
signerEmail: r.email,
// Map Documenso's enum back to our internal role taxonomy.
// APPROVER + CC both render with passive-recipient copy in our
// email templates.
signerRole: documensoRoleToLocal(r.role),
signingOrder: r.signingOrder,
status: 'pending' as const,
})),
)
.returning();
// ─── Documenso round-trip ────────────────────────────────────────
const docCfg = await getPortDocumensoConfig(portId);
const pdfBase64 = pdfBuffer.toString('base64');
const documensoRecipients: DocumensoRecipient[] = recipients.map((r) => ({
name: r.name,
email: r.email,
role: r.role,
signingOrder: r.signingOrder,
}));
const documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
});
// Map our recipientIndex → resolved Documenso recipient id (number/
// string). On v2 the envelope/create response doesn't include
// recipient ids; we resolve via the distribute response below
// (sendDocument returns the full doc with recipients).
const sentDoc = await documensoSend(documensoDoc.id, portId);
// Build email→recipientId map. v2 envelope create returns empty
// recipients; distribute fills them in. v1 already has them on create.
const emailToRecipientId = new Map<string, string>();
for (const dr of sentDoc.recipients) {
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
}
// Place fields (skipped silently when empty — but we validated above).
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
const recipient = recipients[f.recipientIndex]!;
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
if (!recipientId) {
throw new ConflictError(
`Documenso response missing recipientId for ${recipient.email} — cannot place fields`,
);
}
return {
recipientId,
type: f.type,
pageNumber: f.pageNumber,
pageX: f.pageX,
pageY: f.pageY,
pageWidth: f.pageWidth,
pageHeight: f.pageHeight,
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
};
});
await placeFields(documensoDoc.id, placements, portId);
// Update local signers with signingUrl + token from Documenso.
const signingUrls: Record<string, string> = {};
for (const dr of sentDoc.recipients) {
const local = signerRows.find((s) => s.signerEmail.toLowerCase() === dr.email?.toLowerCase());
if (!local) continue;
await db
.update(documentSigners)
.set({
signingUrl: dr.signingUrl ?? null,
embeddedUrl: dr.embeddedUrl ?? null,
signingToken: dr.token ?? extractSigningToken(dr.signingUrl ?? null),
})
.where(eq(documentSigners.id, local.id));
if (dr.signingUrl) signingUrls[dr.email] = dr.signingUrl;
}
// Promote the local document to `sent` + record the Documenso id so
// the webhook handler can resolve subsequent events back to this row.
await db
.update(documents)
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
.where(eq(documents.id, docRow.id));
// Pipeline transition: contract_sent stage when contract or
// reservation_agreement goes out for signing. eoi_sent is reserved
// for the template-driven EOI flow. No berth-rules trigger here —
// the rules engine fires on `contract_signed` (webhook-driven).
void advanceStageIfBehind(
interestId,
portId,
'contract_sent',
meta,
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
);
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: docRow.id,
newValue: {
documentType,
title,
documensoId: documensoDoc.id,
recipientCount: recipients.length,
fieldCount: fields.length,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:sent', {
documentId: docRow.id,
type: documentType,
signerCount: recipients.length,
documensoId: documensoDoc.id,
});
// ─── Auto-send first invitation ──────────────────────────────────
if (docCfg.sendMode === 'auto') {
const firstByOrder = [...signerRows].sort((a, b) => a.signingOrder - b.signingOrder)[0];
if (firstByOrder) {
// Re-read the row so we get the freshly-written signingUrl.
const refreshed = await db.query.documentSigners.findFirst({
where: eq(documentSigners.id, firstByOrder.id),
});
if (refreshed?.signingUrl) {
await sendSigningInvitation({
portId,
portName: port.name,
recipient: { name: refreshed.signerName, email: refreshed.signerEmail },
documensoSigningUrl: refreshed.signingUrl,
documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract',
signerRole: (refreshed.signerRole as SignerRole) ?? 'client',
senderName: docCfg.developerName ?? null,
}).catch((err) => {
logger.error(
{ err, documentId: docRow.id, signerId: refreshed.id },
'Auto-send invitation failed (manual retry via Send button still available)',
);
});
await db
.update(documentSigners)
.set({ invitedAt: new Date() })
.where(eq(documentSigners.id, refreshed.id));
}
}
}
return {
documentId: docRow.id,
documensoDocumentId: documensoDoc.id,
signingUrls,
};
}
/**
* Map Documenso's recipient role enum to our internal signerRole
* vocabulary (`client | developer | approver | witness | other`).
*
* The custom-upload flow doesn't know which role label fits — the rep
* picks SIGNER/APPROVER/CC in the dialog. We map SIGNER → 'other' (the
* generic case; matches the email template's neutral copy) UNLESS the
* recipient is the first signer in order, in which case the dialog
* defaults to the client (handled at the UI level in Phase 4 — the
* service stays role-blind).
*/
function documensoRoleToLocal(role: CustomRecipientRole): SignerRole {
switch (role) {
case 'APPROVER':
return 'approver';
case 'CC':
return 'other';
case 'SIGNER':
default:
return 'other';
}
}
// Re-export the client type so callers don't have to import from two
// places when building the field array.
export type { DocumensoFieldPlacement } from '@/lib/services/documenso-client';
// Re-export to silence unused-import lint when the union is consumed
// only indirectly via downstream type inference.
export type { CustomDocumentType as _CustomDocumentType };
// Keep the clients import referenced — used by future enhancements
// that resolve the client name for default recipient prefill.
void clients;

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Validation-path tests for uploadDocumentForSigning. The
// heavy-integration paths (storage put, Documenso round-trip, signer
// updates) are exercised by Playwright realapi specs; these tests
// pin the input-validation contract so a regression in the
// recipient/field/PDF guards is caught at unit-test time.
// Stub the heavy dependencies BEFORE importing the service so its
// module-level imports resolve to the stubs.
vi.mock('@/lib/db', () => ({
db: {
query: {
interests: {
findFirst: vi.fn().mockResolvedValue({ id: 'int-1', portId: 'port-1', clientId: 'c-1' }),
},
ports: { findFirst: vi.fn().mockResolvedValue({ id: 'port-1', name: 'Test Port' }) },
},
},
}));
vi.mock('@/lib/services/berth-pdf-parser', () => ({
isPdfMagic: (b: Buffer) => b.slice(0, 5).toString() === '%PDF-',
}));
import {
uploadDocumentForSigning,
type CustomDocumentRecipient,
} from '@/lib/services/custom-document-upload.service';
const PDF_HEADER = Buffer.from('%PDF-1.7\n');
const NON_PDF = Buffer.from('this is not a PDF');
const baseArgs = {
interestId: 'int-1',
portId: 'port-1',
portSlug: 'test-port',
documentType: 'contract' as const,
title: 'Sales Contract',
pdfBuffer: PDF_HEADER,
filename: 'contract.pdf',
recipients: [
{ name: 'Buyer', email: 'buyer@example.com', role: 'SIGNER', signingOrder: 1 },
{ name: 'Seller', email: 'seller@example.com', role: 'SIGNER', signingOrder: 2 },
] satisfies CustomDocumentRecipient[],
fields: [
{
recipientIndex: 0,
type: 'SIGNATURE' as const,
pageNumber: 1,
pageX: 10,
pageY: 80,
pageWidth: 30,
pageHeight: 5,
},
],
meta: {
userId: 'user-1',
portId: 'port-1',
ipAddress: '127.0.0.1',
userAgent: 'test',
},
};
describe('uploadDocumentForSigning validation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects empty recipient list', async () => {
await expect(uploadDocumentForSigning({ ...baseArgs, recipients: [] })).rejects.toThrow(
/at least one recipient/i,
);
});
it('rejects empty field list', async () => {
await expect(uploadDocumentForSigning({ ...baseArgs, fields: [] })).rejects.toThrow(
/at least one field/i,
);
});
it('rejects empty PDF buffer', async () => {
await expect(
uploadDocumentForSigning({ ...baseArgs, pdfBuffer: Buffer.alloc(0) }),
).rejects.toThrow(/PDF buffer is empty/);
});
it('rejects oversized PDF', async () => {
const oversized = Buffer.alloc(51 * 1024 * 1024, 0x20);
oversized.write('%PDF-1.7', 0);
await expect(uploadDocumentForSigning({ ...baseArgs, pdfBuffer: oversized })).rejects.toThrow(
/exceeds.*MB cap/i,
);
});
it('rejects non-PDF magic bytes', async () => {
await expect(uploadDocumentForSigning({ ...baseArgs, pdfBuffer: NON_PDF })).rejects.toThrow(
/not a PDF/,
);
});
it('rejects out-of-range recipientIndex on a field', async () => {
await expect(
uploadDocumentForSigning({
...baseArgs,
fields: [{ ...baseArgs.fields[0]!, recipientIndex: 5 }],
}),
).rejects.toThrow(/out of range/);
});
it('rejects negative recipientIndex on a field', async () => {
await expect(
uploadDocumentForSigning({
...baseArgs,
fields: [{ ...baseArgs.fields[0]!, recipientIndex: -1 }],
}),
).rejects.toThrow(/out of range/);
});
it('rejects duplicate signingOrder across recipients', async () => {
await expect(
uploadDocumentForSigning({
...baseArgs,
recipients: [
{ name: 'A', email: 'a@x.com', role: 'SIGNER', signingOrder: 1 },
{ name: 'B', email: 'b@x.com', role: 'SIGNER', signingOrder: 1 },
],
}),
).rejects.toThrow(/Duplicate signingOrder/);
});
});