Files
documenso/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts
T
2026-05-08 16:04:22 +10:00

488 lines
17 KiB
TypeScript

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 { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } 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 { expect, type Page, test } from '@playwright/test';
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Create a V2 document envelope via the API with a single SIGNER recipient
* and a SIGNATURE field, suitable for testing save-as-template with data.
*
* Returns the envelope ID, user, team, and recipient email.
*/
const createDocumentWithRecipientAndField = async () => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `e2e-save-as-template-${Date.now()}`,
expiresIn: null,
});
const recipientEmail = `save-template-${Date.now()}@test.documenso.com`;
// 1. Create envelope with a PDF.
const payload = {
type: EnvelopeType.DOCUMENT,
title: `E2E Save as Template ${Date.now()}`,
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
expect(createRes.ok).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// 2. Create a SIGNER recipient.
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: recipientEmail,
name: 'Template Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createRecipientsRequest),
});
expect(recipientsRes.ok).toBeTruthy();
const recipientsResponse = await recipientsRes.json();
const recipients = recipientsResponse.data;
// 3. Get envelope to find the envelope item ID.
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
const envelopeItem = envelope.envelopeItems[0];
// 4. Create a SIGNATURE field for the recipient.
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipients[0].id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createFieldsRequest),
});
expect(fieldsRes.ok).toBeTruthy();
return {
user,
team,
envelopeId: createResponse.id,
recipientEmail,
};
};
test.describe('document editor', () => {
test('save document as template from editor sidebar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a recipient via the UI.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Navigate to the add fields step and place a signature field.
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
// Navigate back to the upload step so the sidebar actions are available.
await clickEnvelopeEditorStep(surface.root, 'upload');
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The save as template dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Both checkboxes should be checked by default.
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the new template envelope was created in the database with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
expect(templateEnvelopes.length).toBeGreaterThanOrEqual(1);
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix, not document_.
// This confirms incrementTemplateId was called, not incrementDocumentId.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(surface.userEmail);
// Verify fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
expect(createdTemplate!.recipients[0].fields[0].type).toBe(FieldType.SIGNATURE);
});
test('save document as template without recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Recipients" - "Include Fields" should auto-disable.
await page.locator('#envelopeIncludeRecipients').click();
await expect(page.locator('#envelopeIncludeRecipients')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeDisabled();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created without recipients.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: true,
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify no recipients were included.
expect(createdTemplate!.recipients.length).toBe(0);
});
test('save document as template without fields but with recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Fields" but keep "Include Recipients".
await page.locator('#envelopeIncludeFields').click();
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with recipients but no fields.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(recipientEmail);
// Verify no fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(0);
});
});
test.describe('documents table', () => {
test('save as template from document row dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Wait for the documents table to load.
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the actions dropdown for the document row.
await page.getByTestId('document-table-action-btn').first().click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients and fields were included (defaults are both checked).
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
});
});
test.describe('document index page', () => {
test('save as template from document page dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}`,
});
// Wait for the document page to load.
await expect(page.getByRole('heading', { name: /E2E Save as Template/ })).toBeVisible();
// Click the more actions dropdown trigger.
await page.getByTestId('document-page-view-action-btn').click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
});
});
test.describe('legacy ID correctness', () => {
test('save as template uses template counter, not document counter', async ({ page }) => {
// Record the current counter values before the operation.
const [documentCounterBefore, templateCounterBefore] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
const surface = await openDocumentEnvelopeEditor(page);
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
await expectToastTextToBeVisible(page, 'Template Created');
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Record the counter values after the operation.
const [documentCounterAfter, templateCounterAfter] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
// The template counter MUST have incremented (at least once - could be more due to
// the seedBlankDocument call in openDocumentEnvelopeEditor seeding other templates).
expect(templateCounterAfter!.value).toBeGreaterThan(templateCounterBefore!.value);
// Verify the created template's secondaryId matches the template counter.
const createdTemplate = await prisma.envelope.findFirst({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
orderBy: { createdAt: 'desc' },
});
expect(createdTemplate).not.toBeNull();
expect(createdTemplate!.secondaryId).toBe(`template_${templateCounterAfter!.value}`);
expect(createdTemplate!.secondaryId).not.toMatch(/^document_/);
});
});