Merge branch 'main' into feat/add-pdf-image-renderer
@@ -171,6 +171,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
@@ -180,6 +181,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -205,6 +207,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -107,10 +107,12 @@ test.describe('AutoSave Subject Step', () => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
await page.getByText('Email the owner when a recipient signs').click();
|
||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||
await page
|
||||
.getByText('Email recipients when the document is completed', { exact: true })
|
||||
.click();
|
||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -127,26 +129,34 @@ test.describe('AutoSave Subject Step', () => {
|
||||
|
||||
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
|
||||
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
await expect(
|
||||
page.getByText("Email recipients when they're removed from a pending document"),
|
||||
).toBeChecked({
|
||||
checked: emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
await expect(
|
||||
page.getByText('Email recipients when the document is completed', { exact: true }),
|
||||
).toBeChecked({
|
||||
checked: emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
await expect(
|
||||
page.getByText('Email recipients when a pending document is deleted'),
|
||||
).toBeChecked({
|
||||
checked: emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||
{
|
||||
checked: emailSettings?.documentPending,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||
checked: emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
@@ -162,10 +172,12 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
|
||||
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
|
||||
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
await page.getByText('Email the owner when a recipient signs').click();
|
||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||
await page
|
||||
.getByText('Email recipients when the document is completed', { exact: true })
|
||||
.click();
|
||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -191,26 +203,34 @@ test.describe('AutoSave Subject Step', () => {
|
||||
retrievedDocumentData.documentMeta?.message ?? '',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
await expect(
|
||||
page.getByText("Email recipients when they're removed from a pending document"),
|
||||
).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
await expect(
|
||||
page.getByText('Email recipients when the document is completed', { exact: true }),
|
||||
).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
await expect(
|
||||
page.getByText('Email recipients when a pending document is deleted'),
|
||||
).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||
{
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { type APIRequestContext, type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
import { RecipientRole } from '../../../prisma/generated/types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '../../../trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const signAndVerifyPageDimensions = async ({
|
||||
page,
|
||||
request,
|
||||
pdfFile,
|
||||
identifier,
|
||||
title,
|
||||
expectedWidth,
|
||||
expectedHeight,
|
||||
}: {
|
||||
page: Page;
|
||||
request: APIRequestContext;
|
||||
pdfFile: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
expectedWidth: number;
|
||||
expectedHeight: number;
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const pdfBuffer = fs.readFileSync(path.join(__dirname, `../../../../assets/${pdfFile}`));
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
const createEnvelopePayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title,
|
||||
recipients: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
identifier,
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: { type: 'signature' },
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 40,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
formData.append('payload', JSON.stringify(createEnvelopePayload));
|
||||
formData.append('files', new File([pdfBuffer], identifier, { type: 'application/pdf' }));
|
||||
|
||||
const createResponse = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createResponse.json();
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
include: { recipients: true },
|
||||
});
|
||||
|
||||
const distributeResponse = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeResponse.ok()).toBeTruthy();
|
||||
|
||||
// Pre-insert all fields via Prisma so we can skip the UI field interaction.
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id, inserted: false },
|
||||
});
|
||||
|
||||
for (const field of fields) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: {
|
||||
inserted: true,
|
||||
signature: {
|
||||
create: {
|
||||
recipientId: envelope.recipients[0].id,
|
||||
typedSignature: 'Test Signature',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const recipientToken = envelope.recipients[0].token;
|
||||
const signUrl = `/sign/${recipientToken}`;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: signUrl,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelope.id },
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { documentData: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of completedEnvelope.envelopeItems) {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token: recipientToken,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
expect(pdf.numPages).toBeGreaterThan(1);
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const pdfPage = await pdf.getPage(i);
|
||||
const viewport = pdfPage.getViewport({ scale: 1 });
|
||||
|
||||
expect(Math.round(viewport.width)).toBe(expectedWidth);
|
||||
expect(Math.round(viewport.height)).toBe(expectedHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test('cert and audit log pages match letter page dimensions', async ({ page, request }) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
pdfFile: 'letter-size.pdf',
|
||||
identifier: 'letter-doc',
|
||||
title: 'Letter Size Dimension Test',
|
||||
expectedWidth: 612,
|
||||
expectedHeight: 792,
|
||||
});
|
||||
});
|
||||
|
||||
test('cert and audit log pages match A4 page dimensions', async ({ page, request }) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
pdfFile: 'a4-size.pdf',
|
||||
identifier: 'a4-doc',
|
||||
title: 'A4 Size Dimension Test',
|
||||
expectedWidth: 595,
|
||||
expectedHeight: 842,
|
||||
});
|
||||
});
|
||||
|
||||
test('cert and audit log pages match tabloid landscape page dimensions', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
pdfFile: 'tabloid-landscape.pdf',
|
||||
identifier: 'tabloid-doc',
|
||||
title: 'Tabloid Landscape Dimension Test',
|
||||
expectedWidth: 1224,
|
||||
expectedHeight: 792,
|
||||
});
|
||||
});
|
||||
@@ -297,18 +297,34 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
*
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('download envelope images', async ({ page }) => {
|
||||
test.skip('download envelope images', async ({ page, request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const envelope = await seedAlignmentTestDocument({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: true,
|
||||
status: DocumentStatus.PENDING,
|
||||
status: DocumentStatus.DRAFT,
|
||||
});
|
||||
|
||||
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
const token = envelope.recipients[0].token;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
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 { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-send',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-expiry@test.documenso.com',
|
||||
name: 'Signer Expiry',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
// Check that recipients now have expiresAt set.
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).not.toBeNull();
|
||||
|
||||
// The default expiration period is 3 months. Verify it's roughly correct.
|
||||
const expiresAt = recipients[0].expiresAt!;
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// 3 months is roughly 89-92 days. Allow a generous range.
|
||||
expect(diffDays).toBeGreaterThan(80);
|
||||
expect(diffDays).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Set org expiration to 7 days.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'day', amount: 7 } },
|
||||
});
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-custom',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Custom Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-custom@test.documenso.com',
|
||||
name: 'Signer Custom',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).not.toBeNull();
|
||||
|
||||
// 7 days expiration.
|
||||
const expiresAt = recipients[0].expiresAt!;
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(diffDays).toBeGreaterThan(6);
|
||||
expect(diffDays).toBeLessThan(8);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document with expiration disabled', async ({ request }) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Disable expiration at org level.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { disabled: true } },
|
||||
});
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-disabled',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Disabled Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-disabled@test.documenso.com',
|
||||
name: 'Signer Disabled',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['resend-target@test.documenso.com']);
|
||||
|
||||
const recipient = document.recipients[0];
|
||||
|
||||
// Set an initial expiresAt that's 1 day from now.
|
||||
const initialExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: initialExpiresAt },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
// Open the document action menu and click Resend.
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeAttached();
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
|
||||
// Select the recipient and send.
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Verify expiresAt was refreshed.
|
||||
await expect(async () => {
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.expiresAt).not.toBeNull();
|
||||
expect(updatedRecipient.expiresAt!.getTime()).toBeGreaterThan(initialExpiresAt.getTime());
|
||||
}).toPass({ timeout: 10_000 });
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByRole('spinbutton');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('2');
|
||||
|
||||
// Find all triggers, the unit picker is the one showing Months/Days/etc.
|
||||
// In the duration mode, there's a mode select and a unit select.
|
||||
// The unit select is inside the duration row, after the number input.
|
||||
// Let's find the select trigger that contains the unit text.
|
||||
const unitTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(orgSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: 'Custom duration' });
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(orgSettings.envelopeExpirationPeriod).toEqual({ disabled: true });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: team inherits expiration from organisation', async () => {
|
||||
const { organisation, team } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Set org expiration to 2 weeks directly.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
|
||||
});
|
||||
|
||||
// Verify team settings inherit the org setting.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ page }) => {
|
||||
const { user, organisation, team } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Set org expiration to 2 weeks.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Scope to the "Default Envelope Expiration" form field section.
|
||||
const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
|
||||
// Switch to custom duration.
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Custom duration' }).click();
|
||||
|
||||
// Set to 5 days.
|
||||
const amountInput = expirationSection.getByRole('spinbutton');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('5');
|
||||
|
||||
const unitTrigger = expirationSection
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'day', amount: 5 });
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient is redirected to expired page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['expired-recipient@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Set expiresAt to the past so the recipient is expired.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
await page.waitForURL(`/sign/${recipient.token}/expired`);
|
||||
|
||||
await expect(page.getByText('Signing Deadline Expired')).toBeVisible();
|
||||
await expect(page.getByText('The signing deadline for this document has passed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: non-expired recipient can access signing page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['active-recipient@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Set expiresAt to 1 hour in the future.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: recipient with null expiresAt can sign normally', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['null-expiry@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Verify expiresAt is null (default from seed).
|
||||
const dbRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(dbRecipient.expiresAt).toBeNull();
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Use only a SIGNATURE field to simplify the signing flow.
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: [user],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/sign/${recipient.token}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Now expire the recipient while they're on the signing page.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
// Set up signature.
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Click the signature field to attempt to insert it.
|
||||
// The server will reject because the recipient is now expired.
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (signatureField) {
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
}
|
||||
|
||||
// The server should reject the signing attempt because the recipient has expired.
|
||||
// Verify the field was NOT inserted (stays data-inserted="false").
|
||||
if (signatureField) {
|
||||
await expect(async () => {
|
||||
const field = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: signatureField.id },
|
||||
});
|
||||
|
||||
expect(field.inserted).toBe(false);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
@@ -205,9 +205,13 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
|
||||
|
||||
// Update email document settings by enabling/disabling some checkboxes
|
||||
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email the owner when a recipient signs' }).uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email the signer if the document is still pending' })
|
||||
.uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' })
|
||||
.uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
@@ -225,6 +229,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false, // unchecked
|
||||
documentCompleted: true,
|
||||
documentDeleted: false, // unchecked
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
|
||||
@@ -240,12 +245,12 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
|
||||
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
|
||||
.uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
|
||||
.getByRole('checkbox', { name: 'Email the owner when the document is completed' })
|
||||
.uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
@@ -264,6 +269,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
});
|
||||
|
||||
@@ -284,6 +290,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
});
|
||||
|
||||
@@ -309,6 +316,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
|
||||
@@ -329,6 +337,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 246 KiB |