/** * API V2-based seed fixtures for E2E tests. * * These fixtures create documents, templates, envelopes, recipients, fields, * and folders through the API V2 endpoints instead of direct Prisma calls. * This ensures all creation-time side effects (PDF normalization, field meta * defaults, etc.) are exercised the same way a real user would trigger them. * * Usage: * import { apiSeedDraftDocument, apiSeedPendingDocument, ... } from '../fixtures/api-seeds'; * * test('my test', async ({ request }) => { * const { envelope, token, user, team } = await apiSeedDraftDocument(request, { * title: 'My Document', * recipients: [{ email: 'signer@example.com', name: 'Signer', role: 'SIGNER' }], * }); * }); */ import fs from 'node:fs'; import path from 'node:path'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { seedUser } from '@documenso/prisma/seed/users'; import type { TCreateEnvelopePayload, TCreateEnvelopeResponse, } from '@documenso/trpc/server/envelope-router/create-envelope.types'; import type { TDistributeEnvelopeRequest, TDistributeEnvelopeResponse, } from '@documenso/trpc/server/envelope-router/distribute-envelope.types'; import type { TCreateEnvelopeRecipientsResponse } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; import { type APIRequestContext, expect } from '@playwright/test'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type ApiRecipient = { email: string; name?: string; role?: 'SIGNER' | 'APPROVER' | 'VIEWER' | 'CC' | 'ASSISTANT'; signingOrder?: number; accessAuth?: string[]; actionAuth?: string[]; }; export type ApiField = { recipientId: number; envelopeItemId?: string; type: string; page?: number; positionX?: number; positionY?: number; width?: number; height?: number; fieldMeta?: Record; placeholder?: string; matchAll?: boolean; }; export type ApiSeedContext = { user: Awaited>['user']; team: Awaited>['team']; token: string; }; export type ApiSeedEnvelopeOptions = { title?: string; type?: 'DOCUMENT' | 'TEMPLATE'; externalId?: string; visibility?: string; globalAccessAuth?: string[]; globalActionAuth?: string[]; folderId?: string; pdfFile?: { name: string; data: Buffer }; meta?: TCreateEnvelopePayload['meta']; recipients?: Array< ApiRecipient & { fields?: Array<{ type: string; identifier?: string | number; page?: number; positionX?: number; positionY?: number; width?: number; height?: number; fieldMeta?: Record; }>; } >; }; // --------------------------------------------------------------------------- // Core API helpers (low-level) // --------------------------------------------------------------------------- /** * Create a fresh user + team + API token for test isolation. * Every high-level seed function calls this internally, but you can also * call it directly if you need the context for multiple operations. */ export const apiCreateTestContext = async (tokenName = 'e2e-seed'): Promise => { const { user, team } = await seedUser(); const { token } = await createApiToken({ userId: user.id, teamId: team.id, tokenName, expiresIn: null, }); return { user, team, token }; }; const authHeader = (token: string) => ({ Authorization: `Bearer ${token}`, }); /** * Create an envelope via API V2 with a PDF file attached. * * This is the lowest-level envelope creation function. It creates the * envelope with optional inline recipients and fields in a single call. */ export const apiCreateEnvelope = async ( request: APIRequestContext, token: string, options: ApiSeedEnvelopeOptions = {}, ): Promise => { const { title = '[TEST] API Seeded Envelope', type = 'DOCUMENT', externalId, visibility, globalAccessAuth, globalActionAuth, folderId, pdfFile, meta, recipients, } = options; // Build payload as a plain object. The API receives this as a JSON string // inside multipart form data, so strict TypeScript union narrowing is not // required - the server validates with Zod at runtime. const payload: Record = { title, type, }; if (externalId !== undefined) { payload.externalId = externalId; } if (visibility !== undefined) { payload.visibility = visibility; } if (globalAccessAuth !== undefined) { payload.globalAccessAuth = globalAccessAuth; } if (globalActionAuth !== undefined) { payload.globalActionAuth = globalActionAuth; } if (folderId !== undefined) { payload.folderId = folderId; } if (meta !== undefined) { payload.meta = meta; } if (recipients !== undefined) { payload.recipients = recipients.map((r) => { const recipientPayload: Record = { email: r.email, name: r.name ?? r.email, role: r.role ?? 'SIGNER', }; if (r.signingOrder !== undefined) { recipientPayload.signingOrder = r.signingOrder; } if (r.accessAuth !== undefined) { recipientPayload.accessAuth = r.accessAuth; } if (r.actionAuth !== undefined) { recipientPayload.actionAuth = r.actionAuth; } if (r.fields !== undefined) { recipientPayload.fields = r.fields.map((f) => { const fieldPayload: Record = { type: f.type, page: f.page ?? 1, positionX: f.positionX ?? 10, positionY: f.positionY ?? 10, width: f.width ?? 15, height: f.height ?? 5, }; if (f.identifier !== undefined) { fieldPayload.identifier = f.identifier; } if (f.fieldMeta !== undefined) { fieldPayload.fieldMeta = f.fieldMeta; } return fieldPayload; }); } return recipientPayload; }); } const formData = new FormData(); formData.append('payload', JSON.stringify(payload)); const pdf = pdfFile ?? { name: 'example.pdf', data: examplePdfBuffer }; formData.append('files', new File([pdf.data], pdf.name, { type: 'application/pdf' })); const res = await request.post(`${API_BASE_URL}/envelope/create`, { headers: authHeader(token), multipart: formData, }); expect(res.ok(), `envelope/create failed: ${await res.text()}`).toBeTruthy(); return (await res.json()) as TCreateEnvelopeResponse; }; /** * Get full envelope data via API V2. */ export const apiGetEnvelope = async ( request: APIRequestContext, token: string, envelopeId: string, ): Promise => { const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, { headers: authHeader(token), }); expect(res.ok(), `envelope/get failed: ${await res.text()}`).toBeTruthy(); return (await res.json()) as TGetEnvelopeResponse; }; /** * Add recipients to an existing envelope via API V2. */ export const apiCreateRecipients = async ( request: APIRequestContext, token: string, envelopeId: string, recipients: ApiRecipient[], ): Promise => { const data = { envelopeId, data: recipients.map((r) => { const recipientPayload: Record = { email: r.email, name: r.name ?? r.email, role: r.role ?? 'SIGNER', }; if (r.signingOrder !== undefined) { recipientPayload.signingOrder = r.signingOrder; } if (r.accessAuth !== undefined) { recipientPayload.accessAuth = r.accessAuth; } if (r.actionAuth !== undefined) { recipientPayload.actionAuth = r.actionAuth; } return recipientPayload; }), }; const res = await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, { headers: { ...authHeader(token), 'Content-Type': 'application/json' }, data, }); expect(res.ok(), `recipient/create-many failed: ${await res.text()}`).toBeTruthy(); return (await res.json()) as TCreateEnvelopeRecipientsResponse; }; /** * Add fields to an existing envelope via API V2. * * If `recipientId` is not set on fields, the first recipient is used. * If `envelopeItemId` is not set, the first envelope item is used. */ export const apiCreateFields = async ( request: APIRequestContext, token: string, envelopeId: string, fields: ApiField[], ): Promise => { // Build as plain object - the deeply discriminated union types for fields // (type + fieldMeta combinations) are validated by Zod on the server. const data = { envelopeId, data: fields.map((f) => { const fieldPayload: Record = { recipientId: f.recipientId, type: f.type, }; if (f.envelopeItemId !== undefined) { fieldPayload.envelopeItemId = f.envelopeItemId; } if (f.fieldMeta !== undefined) { fieldPayload.fieldMeta = f.fieldMeta; } if (f.placeholder) { fieldPayload.placeholder = f.placeholder; if (f.width !== undefined) { fieldPayload.width = f.width; } if (f.height !== undefined) { fieldPayload.height = f.height; } if (f.matchAll !== undefined) { fieldPayload.matchAll = f.matchAll; } } else { fieldPayload.page = f.page ?? 1; fieldPayload.positionX = f.positionX ?? 10; fieldPayload.positionY = f.positionY ?? 10; fieldPayload.width = f.width ?? 15; fieldPayload.height = f.height ?? 5; } return fieldPayload; }), }; const res = await request.post(`${API_BASE_URL}/envelope/field/create-many`, { headers: { ...authHeader(token), 'Content-Type': 'application/json' }, data, }); expect(res.ok(), `field/create-many failed: ${await res.text()}`).toBeTruthy(); }; /** * Distribute (send) an envelope via API V2. * Returns the distribute response which includes signing URLs for recipients. */ export const apiDistributeEnvelope = async ( request: APIRequestContext, token: string, envelopeId: string, meta?: TDistributeEnvelopeRequest['meta'], ): Promise => { const data: TDistributeEnvelopeRequest = { envelopeId, ...(meta !== undefined && { meta }), }; const res = await request.post(`${API_BASE_URL}/envelope/distribute`, { headers: { ...authHeader(token), 'Content-Type': 'application/json' }, data, }); expect(res.ok(), `envelope/distribute failed: ${await res.text()}`).toBeTruthy(); return (await res.json()) as TDistributeEnvelopeResponse; }; /** * Create a folder via API V2. */ export const apiCreateFolder = async ( request: APIRequestContext, token: string, options: { name?: string; parentId?: string; type?: 'DOCUMENT' | 'TEMPLATE'; } = {}, ): Promise<{ id: string; name: string }> => { const { name = 'Test Folder', parentId, type } = options; const res = await request.post(`${API_BASE_URL}/folder/create`, { headers: { ...authHeader(token), 'Content-Type': 'application/json' }, data: { name, ...(parentId !== undefined && { parentId }), ...(type !== undefined && { type }), }, }); expect(res.ok(), `folder/create failed: ${await res.text()}`).toBeTruthy(); return (await res.json()) as { id: string; name: string }; }; /** * Create a direct template link via API V2. */ export const apiCreateDirectTemplateLink = async ( request: APIRequestContext, token: string, templateId: number, directRecipientId?: number, ): Promise<{ id: number; token: string; enabled: boolean; directTemplateRecipientId: number }> => { const res = await request.post(`${API_BASE_URL}/template/direct/create`, { headers: { ...authHeader(token), 'Content-Type': 'application/json' }, data: { templateId, ...(directRecipientId !== undefined && { directRecipientId }), }, }); expect(res.ok(), `template/direct/create failed: ${await res.text()}`).toBeTruthy(); return await res.json(); }; // --------------------------------------------------------------------------- // High-level seed functions (composites) // --------------------------------------------------------------------------- export type ApiSeedResult = { /** The created envelope/document/template. */ envelope: TGetEnvelopeResponse; /** API token for further API calls. */ token: string; /** The seeded user. */ user: ApiSeedContext['user']; /** The seeded team. */ team: ApiSeedContext['team']; }; export type ApiSeedDocumentOptions = { /** Document title. Default: '[TEST] API Document - Draft' */ title?: string; /** Recipients to add to the document. */ recipients?: ApiRecipient[]; /** Fields to add per recipient. If provided, must match recipients order. */ fieldsPerRecipient?: Array< Array<{ type: string; page?: number; positionX?: number; positionY?: number; width?: number; height?: number; fieldMeta?: Record; }> >; /** External ID for the envelope. */ externalId?: string; /** Document visibility setting. */ visibility?: string; /** Global access auth requirements. */ globalAccessAuth?: string[]; /** Global action auth requirements. */ globalActionAuth?: string[]; /** Folder ID to place the document in. */ folderId?: string; /** Document meta settings. */ meta?: TCreateEnvelopePayload['meta']; /** Custom PDF file. Default: example.pdf */ pdfFile?: { name: string; data: Buffer }; /** Reuse an existing test context instead of creating a new one. */ context?: ApiSeedContext; }; /** * Create a draft document via API V2. * * Creates a user, team, API token, and a DRAFT document. Optionally adds * recipients and fields. * * @example * ```ts * const { envelope, token, user, team } = await apiSeedDraftDocument(request, { * title: 'My Document', * recipients: [{ email: 'signer@test.com', name: 'Test Signer' }], * }); * ``` */ export const apiSeedDraftDocument = async ( request: APIRequestContext, options: ApiSeedDocumentOptions = {}, ): Promise => { const ctx = options.context ?? (await apiCreateTestContext('e2e-draft-doc')); // Create the envelope with inline recipients if provided const createOptions: ApiSeedEnvelopeOptions = { title: options.title ?? '[TEST] API Document - Draft', type: 'DOCUMENT', externalId: options.externalId, visibility: options.visibility, globalAccessAuth: options.globalAccessAuth, globalActionAuth: options.globalActionAuth, folderId: options.folderId, meta: options.meta, pdfFile: options.pdfFile, }; // If we have recipients but no per-recipient fields, use inline creation if (options.recipients && !options.fieldsPerRecipient) { createOptions.recipients = options.recipients.map((r) => ({ ...r, role: r.role ?? 'SIGNER', })); } const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions); // If we need per-recipient fields, add recipients and fields separately if (options.recipients && options.fieldsPerRecipient) { const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, options.recipients); // Get envelope to resolve envelope item IDs const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId); const firstItemId = envelopeData.envelopeItems[0]?.id; // Create fields for each recipient for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) { if (recipientFields.length === 0) { continue; } const recipientId = recipientsRes.data[index].id; await apiCreateFields( request, ctx.token, envelopeId, recipientFields.map((f) => ({ ...f, recipientId, envelopeItemId: firstItemId, type: f.type, })), ); } } const envelope = await apiGetEnvelope(request, ctx.token, envelopeId); return { envelope, token: ctx.token, user: ctx.user, team: ctx.team }; }; export type ApiSeedPendingDocumentOptions = ApiSeedDocumentOptions & { /** Distribution meta (subject, message, etc.). */ distributeMeta?: TDistributeEnvelopeRequest['meta']; }; /** * Seed a pending (distributed) document via API V2. * * Creates the document, adds recipients with SIGNATURE fields, then * distributes (sends) it. The response includes signing URLs for each * recipient. * * Every SIGNER recipient must have at least one SIGNATURE field for * distribution to succeed. If you don't provide `fieldsPerRecipient`, * a default SIGNATURE field is added for each SIGNER/APPROVER recipient. * * @example * ```ts * const { envelope, distributeResult, token } = await apiSeedPendingDocument(request, { * recipients: [ * { email: 'signer@test.com', name: 'Signer' }, * { email: 'viewer@test.com', name: 'Viewer', role: 'VIEWER' }, * ], * }); * * // Access signing URL: * const signingUrl = distributeResult.recipients[0].signingUrl; * ``` */ export const apiSeedPendingDocument = async ( request: APIRequestContext, options: ApiSeedPendingDocumentOptions = {}, ): Promise< ApiSeedResult & { distributeResult: TDistributeEnvelopeResponse; } > => { const ctx = options.context ?? (await apiCreateTestContext('e2e-pending-doc')); const recipients = options.recipients ?? [ { email: `signer-${Date.now()}@test.documenso.com`, name: 'Test Signer', role: 'SIGNER' as const, }, ]; // Create the base envelope const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, { title: options.title ?? '[TEST] API Document - Pending', type: 'DOCUMENT', externalId: options.externalId, visibility: options.visibility, globalAccessAuth: options.globalAccessAuth, globalActionAuth: options.globalActionAuth, folderId: options.folderId, meta: options.meta, pdfFile: options.pdfFile, }); // Add recipients const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, recipients); // Get envelope for item IDs const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId); const firstItemId = envelopeData.envelopeItems[0]?.id; // Add fields if (options.fieldsPerRecipient) { for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) { if (recipientFields.length === 0) { continue; } await apiCreateFields( request, ctx.token, envelopeId, recipientFields.map((f) => ({ ...f, recipientId: recipientsRes.data[index].id, envelopeItemId: firstItemId, type: f.type, })), ); } } else { // Auto-add a SIGNATURE field for each SIGNER/APPROVER recipient const signerFields: ApiField[] = []; for (const [index, r] of recipientsRes.data.entries()) { const role = recipients[index].role ?? 'SIGNER'; if (role === 'SIGNER' || role === 'APPROVER') { signerFields.push({ recipientId: r.id, envelopeItemId: firstItemId, type: 'SIGNATURE', page: 1, positionX: 10, positionY: 10 + index * 10, width: 15, height: 5, }); } } if (signerFields.length > 0) { await apiCreateFields(request, ctx.token, envelopeId, signerFields); } } // Distribute const distributeResult = await apiDistributeEnvelope(request, ctx.token, envelopeId, options.distributeMeta); const envelope = await apiGetEnvelope(request, ctx.token, envelopeId); return { envelope, distributeResult, token: ctx.token, user: ctx.user, team: ctx.team, }; }; export type ApiSeedTemplateOptions = Omit & { /** Folder ID to place the template in. */ folderId?: string; }; /** * Seed a template via API V2. * * Creates a TEMPLATE envelope with optional recipients and fields. * * @example * ```ts * const { envelope, token } = await apiSeedTemplate(request, { * title: 'My Template', * recipients: [{ email: 'recipient@test.com', name: 'Signer', role: 'SIGNER' }], * }); * ``` */ export const apiSeedTemplate = async ( request: APIRequestContext, options: ApiSeedTemplateOptions = {}, ): Promise => { const ctx = options.context ?? (await apiCreateTestContext('e2e-template')); const createOptions: ApiSeedEnvelopeOptions = { title: options.title ?? '[TEST] API Template', type: 'TEMPLATE', externalId: options.externalId, visibility: options.visibility, globalAccessAuth: options.globalAccessAuth, globalActionAuth: options.globalActionAuth, folderId: options.folderId, meta: options.meta, pdfFile: options.pdfFile, }; if (options.recipients && !options.fieldsPerRecipient) { createOptions.recipients = options.recipients.map((r) => ({ ...r, role: r.role ?? 'SIGNER', })); } const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions); if (options.recipients && options.fieldsPerRecipient) { const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, options.recipients); const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId); const firstItemId = envelopeData.envelopeItems[0]?.id; for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) { if (recipientFields.length === 0) { continue; } await apiCreateFields( request, ctx.token, envelopeId, recipientFields.map((f) => ({ ...f, recipientId: recipientsRes.data[index].id, envelopeItemId: firstItemId, type: f.type, })), ); } } const envelope = await apiGetEnvelope(request, ctx.token, envelopeId); return { envelope, token: ctx.token, user: ctx.user, team: ctx.team }; }; /** * Seed a template with a direct link via API V2. * * Creates a template with a recipient, then creates a direct link for it. * * @example * ```ts * const { envelope, directLink, token } = await apiSeedDirectTemplate(request, { * title: 'Direct Template', * }); * * // Use directLink.token for the signing URL * ``` */ export const apiSeedDirectTemplate = async ( request: APIRequestContext, options: ApiSeedTemplateOptions & { /** Custom recipient for the direct link. Default: a SIGNER placeholder. */ directRecipient?: ApiRecipient; } = {}, ): Promise< ApiSeedResult & { directLink: { id: number; token: string; enabled: boolean; directTemplateRecipientId: number }; } > => { const recipients = options.recipients ?? [ options.directRecipient ?? { email: 'direct-template-recipient@documenso.com', name: 'Direct Template Recipient', role: 'SIGNER' as const, }, ]; const templateResult = await apiSeedTemplate(request, { ...options, recipients, }); // Find the recipient ID for the direct link const directRecipientEmail = options.directRecipient?.email ?? recipients[0].email; const directRecipient = templateResult.envelope.recipients.find((r) => r.email === directRecipientEmail); if (!directRecipient) { throw new Error(`Direct template recipient not found: ${directRecipientEmail}`); } const numericTemplateId = mapSecondaryIdToTemplateId(templateResult.envelope.secondaryId); const directLink = await apiCreateDirectTemplateLink( request, templateResult.token, numericTemplateId, directRecipient.id, ); // Re-fetch envelope to include directLink data const envelope = await apiGetEnvelope(request, templateResult.token, templateResult.envelope.id); return { ...templateResult, envelope, directLink, }; }; /** * Seed multiple draft documents in parallel for a single user context. * * Useful for tests that need multiple documents (e.g., bulk actions, find/filter tests). * * @example * ```ts * const { documents, token, user, team } = await apiSeedMultipleDraftDocuments(request, [ * { title: 'Doc A' }, * { title: 'Doc B' }, * { title: 'Doc C' }, * ]); * ``` */ export const apiSeedMultipleDraftDocuments = async ( request: APIRequestContext, documents: ApiSeedDocumentOptions[], context?: ApiSeedContext, ): Promise<{ documents: TGetEnvelopeResponse[]; token: string; user: ApiSeedContext['user']; team: ApiSeedContext['team']; }> => { const ctx = context ?? (await apiCreateTestContext('e2e-multi-doc')); const results = await Promise.all( documents.map(async (docOptions) => apiSeedDraftDocument(request, { ...docOptions, context: ctx })), ); return { documents: results.map((r) => r.envelope), token: ctx.token, user: ctx.user, team: ctx.team, }; };