mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
chore: merge main, resolve biome formatting conflicts
Reconcile toolbar-filters changes with main's Kysely-based query rewrite and biome reformatting. Notable: - find-documents.ts: rebase on main's Kysely impl while preserving PR semantics (status[] / source[] filters via OR-of-predicates, period 'all' bypass). - get-stats.ts: skip period filter when 'all'. - find-templates.ts: preserve PR templateType array + query filter on top of main's where/include structure. - find-documents-internal: drop preloaded team plumbing (main resolves team internally) and switch appMetaTags to msg`...`. - Restore packages/lib/translations/ from HEAD (autogenerated).
This commit is contained in:
@@ -1,6 +1,3 @@
|
||||
import { TsRestHttpError, fetchRequestHandler } from '@ts-rest/serverless/fetch';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { ApiContractV1 } from '@documenso/api/v1/contract';
|
||||
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
|
||||
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||
@@ -10,6 +7,8 @@ import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/sub
|
||||
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
|
||||
// This is a bit nasty. Todo: Extract
|
||||
import type { HonoEnv } from '@documenso/remix/server/router';
|
||||
import { fetchRequestHandler, TsRestHttpError } from '@ts-rest/serverless/fetch';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// This is bad, ts-router will be created on each request.
|
||||
// But don't really have a choice here.
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"files": [
|
||||
@@ -26,4 +24,4 @@
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { initContract } from '@ts-rest/core';
|
||||
|
||||
import {
|
||||
ZCreateTemplateV2RequestSchema,
|
||||
ZCreateTemplateV2ResponseSchema,
|
||||
} from '@documenso/trpc/server/template-router/schema';
|
||||
import { initContract } from '@ts-rest/core';
|
||||
|
||||
import {
|
||||
ZAuthorizationHeadersSchema,
|
||||
|
||||
+199
-224
@@ -1,24 +1,20 @@
|
||||
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { tsr } from '@ts-rest/serverless/fetch';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { tsr } from '@ts-rest/serverless/fetch';
|
||||
import { match } from 'ts-pattern';
|
||||
import '@documenso/lib/constants/time-zones';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import {
|
||||
getEnvelopeById,
|
||||
getEnvelopeWhereInput,
|
||||
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getEnvelopeById, getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
|
||||
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
@@ -42,16 +38,10 @@ import {
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import {
|
||||
getPresignGetUrl,
|
||||
getPresignPostUrl,
|
||||
} from '@documenso/lib/universal/upload/server-actions';
|
||||
import { getPresignGetUrl, getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
@@ -67,6 +57,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
perPage,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
folderId: args.query.folderId,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -77,6 +68,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
externalId: document.externalId,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId,
|
||||
folderId: document.folderId,
|
||||
title: document.title,
|
||||
status: document.status,
|
||||
createdAt: document.createdAt,
|
||||
@@ -164,6 +156,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
externalId: envelope.externalId,
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
folderId: envelope.folderId,
|
||||
title: envelope.title,
|
||||
status: envelope.status,
|
||||
createdAt: envelope.createdAt,
|
||||
@@ -524,9 +517,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const timezone = meta?.timezone
|
||||
? TIME_ZONES.find((tz) => tz === meta?.timezone)
|
||||
: DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
const timezone = meta?.timezone ? TIME_ZONES.find((tz) => tz === meta?.timezone) : DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const isTimeZoneValid = meta?.timezone ? TIME_ZONES.includes(String(timezone)) : true;
|
||||
|
||||
@@ -725,64 +716,176 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
}
|
||||
}),
|
||||
|
||||
createDocumentFromTemplate: authenticatedMiddleware(
|
||||
async (args, user, team, { logger, metadata }) => {
|
||||
const { body, params } = args;
|
||||
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
|
||||
const { body, params } = args;
|
||||
|
||||
logger.info({
|
||||
input: {
|
||||
templateId: params.templateId,
|
||||
logger.info({
|
||||
input: {
|
||||
templateId: params.templateId,
|
||||
},
|
||||
});
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
|
||||
const templateId = Number(params.templateId);
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
};
|
||||
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||
|
||||
const template = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
if (template.envelopeItems.length !== 1) {
|
||||
throw new Error('API V1 does not support templates with multiple documents');
|
||||
}
|
||||
|
||||
// V1 API request schema uses indices for recipients
|
||||
// So we remap the recipients to attach the IDs
|
||||
const mappedRecipients = body.recipients.map((recipient, index) => {
|
||||
const existingRecipient = template.recipients.at(index);
|
||||
|
||||
if (!existingRecipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
}
|
||||
|
||||
const templateId = Number(params.templateId);
|
||||
return {
|
||||
id: existingRecipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
signingOrder: recipient.signingOrder,
|
||||
role: recipient.role, // You probably shouldn't be able to change the role.
|
||||
};
|
||||
});
|
||||
|
||||
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||
const createdEnvelope = await createDocumentFromTemplate({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipients: mappedRecipients,
|
||||
override: {
|
||||
...body.meta,
|
||||
title: body.title,
|
||||
},
|
||||
attachments: body.attachments,
|
||||
formValues: body.formValues,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
const template = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
const envelopeItems = await prisma.envelopeItem.findMany({
|
||||
where: {
|
||||
envelopeId: createdEnvelope.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItemData = envelopeItems[0].documentData;
|
||||
|
||||
if (!firstEnvelopeItemData) {
|
||||
throw new Error('Document data not found.');
|
||||
}
|
||||
|
||||
if (body.formValues) {
|
||||
const pdf = await getFileServerSide(firstEnvelopeItemData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(pdf),
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
if (template.envelopeItems.length !== 1) {
|
||||
throw new Error('API V1 does not support templates with multiple documents');
|
||||
}
|
||||
const newDocumentData = await putNormalizedPdfFileServerSide({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
// V1 API request schema uses indices for recipients
|
||||
// So we remap the recipients to attach the IDs
|
||||
const mappedRecipients = body.recipients.map((recipient, index) => {
|
||||
const existingRecipient = template.recipients.at(index);
|
||||
await prisma.envelopeItem.update({
|
||||
where: {
|
||||
id: firstEnvelopeItemData.id,
|
||||
},
|
||||
data: {
|
||||
title: body.title || fileName,
|
||||
documentDataId: newDocumentData.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingRecipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
}
|
||||
if (body.authOptions || body.formValues) {
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: createdEnvelope.id,
|
||||
},
|
||||
data: {
|
||||
formValues: body.formValues,
|
||||
authOptions: body.authOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: existingRecipient.id,
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
documentId: mapSecondaryIdToDocumentId(createdEnvelope.secondaryId),
|
||||
recipients: createdEnvelope.recipients.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
role: recipient.role, // You probably shouldn't be able to change the role.
|
||||
};
|
||||
});
|
||||
|
||||
const createdEnvelope = await createDocumentFromTemplate({
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
|
||||
const { body, params } = args;
|
||||
|
||||
logger.info({
|
||||
input: {
|
||||
templateId: params.templateId,
|
||||
},
|
||||
});
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const templateId = Number(params.templateId);
|
||||
|
||||
let envelope: Awaited<ReturnType<typeof createDocumentFromTemplate>> | null = null;
|
||||
|
||||
try {
|
||||
envelope = await createDocumentFromTemplate({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
@@ -790,165 +893,49 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipients: mappedRecipients,
|
||||
recipients: body.recipients,
|
||||
prefillFields: body.prefillFields,
|
||||
folderId: body.folderId,
|
||||
override: {
|
||||
...body.meta,
|
||||
title: body.title,
|
||||
...body.meta,
|
||||
},
|
||||
attachments: body.attachments,
|
||||
formValues: body.formValues,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
} catch (err) {
|
||||
return AppError.toRestAPIError(err);
|
||||
}
|
||||
|
||||
const envelopeItems = await prisma.envelopeItem.findMany({
|
||||
if (body.authOptions) {
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
envelopeId: createdEnvelope.id,
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
data: {
|
||||
authOptions: body.authOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const firstEnvelopeItemData = envelopeItems[0].documentData;
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (!firstEnvelopeItemData) {
|
||||
throw new Error('Document data not found.');
|
||||
}
|
||||
|
||||
if (body.formValues) {
|
||||
const pdf = await getFileServerSide(firstEnvelopeItemData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(pdf),
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
const newDocumentData = await putNormalizedPdfFileServerSide({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
await prisma.envelopeItem.update({
|
||||
where: {
|
||||
id: firstEnvelopeItemData.id,
|
||||
},
|
||||
data: {
|
||||
title: body.title || fileName,
|
||||
documentDataId: newDocumentData.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (body.authOptions || body.formValues) {
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: createdEnvelope.id,
|
||||
},
|
||||
data: {
|
||||
formValues: body.formValues,
|
||||
authOptions: body.authOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
documentId: mapSecondaryIdToDocumentId(createdEnvelope.secondaryId),
|
||||
recipients: createdEnvelope.recipients.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
|
||||
generateDocumentFromTemplate: authenticatedMiddleware(
|
||||
async (args, user, team, { logger, metadata }) => {
|
||||
const { body, params } = args;
|
||||
|
||||
logger.info({
|
||||
input: {
|
||||
templateId: params.templateId,
|
||||
},
|
||||
});
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const templateId = Number(params.templateId);
|
||||
|
||||
let envelope: Awaited<ReturnType<typeof createDocumentFromTemplate>> | null = null;
|
||||
|
||||
try {
|
||||
envelope = await createDocumentFromTemplate({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipients: body.recipients,
|
||||
prefillFields: body.prefillFields,
|
||||
folderId: body.folderId,
|
||||
override: {
|
||||
title: body.title,
|
||||
...body.meta,
|
||||
},
|
||||
formValues: body.formValues,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
} catch (err) {
|
||||
return AppError.toRestAPIError(err);
|
||||
}
|
||||
|
||||
if (body.authOptions) {
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
authOptions: body.authOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
documentId: legacyDocumentId,
|
||||
recipients: envelope.recipients.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
documentId: legacyDocumentId,
|
||||
recipients: envelope.recipients.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
sendDocument: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
|
||||
const { id: documentId } = args.params;
|
||||
@@ -1371,22 +1358,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
return Promise.all(
|
||||
fields.map(async (fieldData) => {
|
||||
const {
|
||||
recipientId,
|
||||
type,
|
||||
pageNumber,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
pageX,
|
||||
pageY,
|
||||
fieldMeta,
|
||||
} = fieldData;
|
||||
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = fieldData;
|
||||
|
||||
if (pageNumber <= 0) {
|
||||
throw new Error('Invalid page number');
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
const recipient = await tx.recipient.findFirst({
|
||||
where: {
|
||||
id: Number(recipientId),
|
||||
envelopeId: envelope.id,
|
||||
@@ -1401,9 +1379,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
throw new Error('Recipient has already signed the document');
|
||||
}
|
||||
|
||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(
|
||||
type,
|
||||
);
|
||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
|
||||
|
||||
if (advancedField && !fieldMeta) {
|
||||
throw new Error(
|
||||
@@ -1507,8 +1483,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
|
||||
updateField: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
|
||||
const { id: documentId, fieldId } = args.params;
|
||||
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } =
|
||||
args.body;
|
||||
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = args.body;
|
||||
|
||||
logger.info({
|
||||
input: {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import type { TsRestRequest } from '@ts-rest/serverless';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import type { BaseApiLog, RootApiLog } from '@documenso/lib/types/api-logs';
|
||||
@@ -9,6 +5,9 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
||||
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import type { TsRestRequest } from '@ts-rest/serverless';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
type B = {
|
||||
// appRoute: any;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { generateOpenApi } from '@ts-rest/open-api';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateOpenApi } from '@ts-rest/open-api';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
|
||||
|
||||
+36
-48
@@ -1,17 +1,4 @@
|
||||
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentDistributionMethod,
|
||||
DocumentSigningOrder,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -24,6 +11,19 @@ import {
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentDistributionMethod,
|
||||
DocumentSigningOrder,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ZNoBodyMutationSchema = null;
|
||||
export const ZGetDocumentsQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
perPage: z.coerce.number().min(1).optional().default(10),
|
||||
folderId: z.string().describe('Filter documents by folder ID. When omitted, returns root documents.').optional(),
|
||||
});
|
||||
|
||||
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
||||
@@ -48,6 +49,7 @@ export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||
externalId: z.string().nullish(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullish(),
|
||||
folderId: z.string().nullish(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -78,9 +80,7 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
|
||||
),
|
||||
});
|
||||
|
||||
export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
||||
typeof ZSuccessfulGetDocumentResponseSchema
|
||||
>;
|
||||
export type TSuccessfulGetDocumentResponseSchema = z.infer<typeof ZSuccessfulGetDocumentResponseSchema>;
|
||||
|
||||
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
||||
|
||||
@@ -103,9 +103,7 @@ export const ZResendDocumentForSigningMutationSchema = z.object({
|
||||
recipients: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type TResendDocumentForSigningMutationSchema = z.infer<
|
||||
typeof ZResendDocumentForSigningMutationSchema
|
||||
>;
|
||||
export type TResendDocumentForSigningMutationSchema = z.infer<typeof ZResendDocumentForSigningMutationSchema>;
|
||||
|
||||
export const ZSuccessfulResendDocumentResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
@@ -145,7 +143,7 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
signingOrder: z.number().nullish(),
|
||||
}),
|
||||
@@ -155,16 +153,14 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
timezone: z.string().default(DEFAULT_DOCUMENT_TIME_ZONE).openapi({
|
||||
description:
|
||||
'The timezone of the date. Must be one of the options listed in the list below.',
|
||||
description: 'The timezone of the date. Must be one of the options listed in the list below.',
|
||||
enum: TIME_ZONES,
|
||||
}),
|
||||
dateFormat: z
|
||||
.string()
|
||||
.default(DEFAULT_DOCUMENT_DATE_FORMAT)
|
||||
.openapi({
|
||||
description:
|
||||
'The format of the date. Must be one of the options listed in the list below.',
|
||||
description: 'The format of the date. Must be one of the options listed in the list below.',
|
||||
enum: DATE_FORMATS.map((format) => format.value),
|
||||
}),
|
||||
redirectUrl: z.string(),
|
||||
@@ -219,7 +215,7 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().nullish(),
|
||||
@@ -229,9 +225,7 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export type TCreateDocumentMutationResponseSchema = z.infer<
|
||||
typeof ZCreateDocumentMutationResponseSchema
|
||||
>;
|
||||
export type TCreateDocumentMutationResponseSchema = z.infer<typeof ZCreateDocumentMutationResponseSchema>;
|
||||
|
||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
@@ -239,7 +233,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
signingOrder: z.number().nullish(),
|
||||
}),
|
||||
@@ -283,9 +277,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||
>;
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<typeof ZCreateDocumentFromTemplateMutationSchema>;
|
||||
|
||||
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
@@ -294,7 +286,7 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
signingOrder: z.number().nullish(),
|
||||
@@ -321,7 +313,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
email: zEmail(),
|
||||
name: z.string().optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
@@ -370,9 +362,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
|
||||
});
|
||||
|
||||
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||
typeof ZGenerateDocumentFromTemplateMutationSchema
|
||||
>;
|
||||
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<typeof ZGenerateDocumentFromTemplateMutationSchema>;
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
@@ -381,7 +371,7 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().nullish(),
|
||||
@@ -397,7 +387,7 @@ export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZCreateRecipientMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
signingOrder: z.number().nullish(),
|
||||
authOptions: z
|
||||
@@ -432,13 +422,13 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||
// !: This handles the fact that we have null documentId's for templates
|
||||
// !: while we won't need the default we must add it to satisfy typescript
|
||||
documentId: z.number().nullish().default(-1),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().nullish(),
|
||||
token: z.string(),
|
||||
// !: Not used for now
|
||||
// expired: z.string(),
|
||||
expiresAt: z.date().nullish(),
|
||||
expirationNotifiedAt: z.date().nullish(),
|
||||
signedAt: z.date().nullable(),
|
||||
readStatus: z.nativeEnum(ReadStatus),
|
||||
signingStatus: z.nativeEnum(SigningStatus),
|
||||
@@ -463,10 +453,7 @@ const ZCreateFieldSchema = z.object({
|
||||
fieldMeta: ZFieldMetaSchema.openapi({}),
|
||||
});
|
||||
|
||||
export const ZCreateFieldMutationSchema = z.union([
|
||||
ZCreateFieldSchema,
|
||||
z.array(ZCreateFieldSchema).min(1),
|
||||
]);
|
||||
export const ZCreateFieldMutationSchema = z.union([ZCreateFieldSchema, z.array(ZCreateFieldSchema).min(1)]);
|
||||
|
||||
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
|
||||
|
||||
@@ -571,12 +558,13 @@ export const ZRecipientSchema = z.object({
|
||||
id: z.number(),
|
||||
documentId: z.number().nullish(),
|
||||
templateId: z.number().nullish(),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
name: z.string(),
|
||||
token: z.string(),
|
||||
signingOrder: z.number().nullish(),
|
||||
documentDeletedAt: z.date().nullish(),
|
||||
expired: z.date().nullish(),
|
||||
expiresAt: z.date().nullish(),
|
||||
expirationNotifiedAt: z.date().nullish(),
|
||||
signedAt: z.date().nullish(),
|
||||
authOptions: z.unknown(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
export type FieldTestData = TFieldAndMeta & {
|
||||
page: number;
|
||||
@@ -25,11 +24,7 @@ const fullColumnWidth = 57.37499999999998;
|
||||
const rowHeight = 6.7;
|
||||
const rowPadding = 0;
|
||||
|
||||
const calculatePositionPageOne = (
|
||||
row: number,
|
||||
column: number,
|
||||
width: 'full' | 'column' = 'column',
|
||||
) => {
|
||||
const calculatePositionPageOne = (row: number, column: number, width: 'full' | 'column' = 'column') => {
|
||||
const alignmentGridStartX = 31;
|
||||
const alignmentGridStartY = 19;
|
||||
|
||||
@@ -41,11 +36,7 @@ const calculatePositionPageOne = (
|
||||
};
|
||||
};
|
||||
|
||||
const calculatePositionPageTwo = (
|
||||
row: number,
|
||||
column: number,
|
||||
width: 'full' | 'column' = 'column',
|
||||
) => {
|
||||
const calculatePositionPageTwo = (row: number, column: number, width: 'full' | 'column' = 'column') => {
|
||||
const alignmentGridStartX = 31;
|
||||
const alignmentGridStartY = 16.35;
|
||||
|
||||
@@ -86,6 +77,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'email',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(0, 0),
|
||||
@@ -96,6 +88,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'email',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(0, 1),
|
||||
@@ -107,6 +100,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'email',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(0, 2),
|
||||
@@ -156,6 +150,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'date',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(2, 0),
|
||||
@@ -166,6 +161,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'date',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(2, 1),
|
||||
@@ -177,6 +173,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'date',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(2, 2),
|
||||
@@ -424,6 +421,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
type: 'signature',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(9, 0),
|
||||
@@ -434,6 +432,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: {
|
||||
type: 'signature',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(9, 1),
|
||||
@@ -445,6 +444,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
type: 'signature',
|
||||
overflow: 'auto',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(9, 2),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import {
|
||||
CheckboxValidationRules,
|
||||
numberFormatValues,
|
||||
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import type { FieldTestData } from './field-alignment-pdf';
|
||||
import { signatureBase64Demo } from './field-alignment-pdf';
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import type { FieldTestData } from './field-alignment-pdf';
|
||||
|
||||
/**
|
||||
* Overflow test data extends FieldTestData with a `seedFieldMeta` property.
|
||||
*
|
||||
* - `fieldMeta`: Minimal field meta sent via the API. Omit properties that the API
|
||||
* auto-applies via ZEnvelopeFieldAndMetaSchema defaults (e.g. `overflow: 'auto'` for
|
||||
* date/email/signature fields). This tests that the API correctly sets defaults.
|
||||
*
|
||||
* - `seedFieldMeta`: Full field meta written directly to the DB by the seed function.
|
||||
* Must include ALL properties explicitly since the seed bypasses API validation/defaults.
|
||||
* Used by `seedOverflowTestDocument` in initial-seed.ts.
|
||||
*/
|
||||
export type OverflowFieldTestData = Omit<FieldTestData, 'fieldMeta'> & {
|
||||
fieldMeta?: FieldTestData['fieldMeta'];
|
||||
seedFieldMeta: FieldTestData['fieldMeta'];
|
||||
};
|
||||
|
||||
const SINGLE_LINE_HEIGHT = 1.75;
|
||||
const MULTI_LINE_HEIGHT = 12;
|
||||
const DEFAULT_BOX_WIDTH = 25;
|
||||
const SINGLE_TYPE_BOX_WIDTH = 35;
|
||||
const DEFAULT_START_X = 10;
|
||||
|
||||
/**
|
||||
* Pages 1-3: Date, Email, Signature
|
||||
* Single-line section (rows 0-2): single column, full width
|
||||
* Pages 1-2 multi-line: 3×3 grid (rows = TA_LEFT/CENTER/RIGHT, columns = short/medium/long text)
|
||||
* Page 3 multi-line: stacked single column (signature has no text align control)
|
||||
*/
|
||||
const SINGLE_TYPE_ML_COLUMN_X = [2.5, 35, 67.5];
|
||||
const SINGLE_TYPE_ML_BOX_WIDTH = 30;
|
||||
const SINGLE_TYPE_ML_ROW_Y = [45, 63, 83];
|
||||
|
||||
const calculateSingleLinePosition = (row: number) => {
|
||||
const singleLineYPositions = [15, 23, 31];
|
||||
|
||||
return {
|
||||
positionX: DEFAULT_START_X,
|
||||
positionY: singleLineYPositions[row],
|
||||
width: SINGLE_TYPE_BOX_WIDTH,
|
||||
height: SINGLE_LINE_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
/** Pages 1-2: multi-line 3×3 grid */
|
||||
const calculateMultiLinePosition = (row: number, column: number) => {
|
||||
return {
|
||||
positionX: SINGLE_TYPE_ML_COLUMN_X[column],
|
||||
positionY: SINGLE_TYPE_ML_ROW_Y[row],
|
||||
width: SINGLE_TYPE_ML_BOX_WIDTH,
|
||||
height: MULTI_LINE_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
/** Page 3: multi-line stacked single column */
|
||||
const calculateStackedMultiLinePosition = (row: number) => {
|
||||
const yPositions = [45, 63, 81];
|
||||
|
||||
return {
|
||||
positionX: DEFAULT_START_X,
|
||||
positionY: yPositions[row],
|
||||
width: SINGLE_TYPE_BOX_WIDTH,
|
||||
height: MULTI_LINE_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pages 4-5: Text Auto Mode (3x3 grid)
|
||||
*/
|
||||
const TEXT_AUTO_COLUMN_X = [5, 35.5, 66];
|
||||
const TEXT_AUTO_BOX_WIDTH = 28;
|
||||
|
||||
const calculateTextAutoPosition = (row: number, column: number, isSingleLine: boolean) => {
|
||||
if (isSingleLine) {
|
||||
// Single-line: all 9 items evenly spaced down the page.
|
||||
// Order: row0-col0, row0-col1, row0-col2, row1-col0, ...
|
||||
const startY = 10;
|
||||
const endY = 92;
|
||||
const spacing = (endY - startY) / 8; // 9 items, 8 gaps = 10.25%
|
||||
const itemIndex = row * 3 + column;
|
||||
|
||||
return {
|
||||
positionX: TEXT_AUTO_COLUMN_X[column],
|
||||
positionY: startY + itemIndex * spacing,
|
||||
width: TEXT_AUTO_BOX_WIDTH,
|
||||
height: SINGLE_LINE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-line: 3 rows evenly spaced, bottom row near page bottom.
|
||||
// Box is 12% tall. Top of last box at 80% so bottom edge is at 92%.
|
||||
const multiLineYPositions = [10, 45, 80];
|
||||
|
||||
return {
|
||||
positionX: TEXT_AUTO_COLUMN_X[column],
|
||||
positionY: multiLineYPositions[row],
|
||||
width: TEXT_AUTO_BOX_WIDTH,
|
||||
height: MULTI_LINE_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Page 6: Explicit Modes
|
||||
*/
|
||||
const HORIZONTAL_CENTERED_X = (100 - DEFAULT_BOX_WIDTH) / 2; // 37.5%
|
||||
|
||||
const calculateExplicitHorizontalPosition = (row: number) => {
|
||||
const yPositions = [15, 21, 27];
|
||||
|
||||
return {
|
||||
positionX: HORIZONTAL_CENTERED_X,
|
||||
positionY: yPositions[row],
|
||||
width: DEFAULT_BOX_WIDTH,
|
||||
height: SINGLE_LINE_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateExplicitVerticalPosition = (column: number) => {
|
||||
const xPositions = [5, 37.5, 70];
|
||||
|
||||
return {
|
||||
positionX: xPositions[column],
|
||||
positionY: 43,
|
||||
width: DEFAULT_BOX_WIDTH,
|
||||
height: MULTI_LINE_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
export const OVERFLOW_TEST_FIELDS: OverflowFieldTestData[] = [
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 1: DATE OVERFLOW
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Single-line: Row 0-2 (default date meta — API auto-adds overflow: 'auto')
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto' },
|
||||
page: 1,
|
||||
...calculateSingleLinePosition(0),
|
||||
customText: 'Apr 16 2026',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto' },
|
||||
page: 1,
|
||||
...calculateSingleLinePosition(1),
|
||||
customText: 'Wednesday, April 16, 2026 at 14:30:45 UTC',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto' },
|
||||
page: 1,
|
||||
...calculateSingleLinePosition(2),
|
||||
customText: 'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia',
|
||||
},
|
||||
// Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long)
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(0, 0),
|
||||
customText: 'Apr 16 2026',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(0, 1),
|
||||
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(0, 2),
|
||||
customText:
|
||||
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
// Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long)
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(1, 0),
|
||||
customText: 'Apr 16 2026',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(1, 1),
|
||||
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(1, 2),
|
||||
customText:
|
||||
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
// Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long)
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(2, 0),
|
||||
customText: 'Apr 16 2026',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(2, 1),
|
||||
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: { type: 'date', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
|
||||
page: 1,
|
||||
...calculateMultiLinePosition(2, 2),
|
||||
customText:
|
||||
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 2: EMAIL OVERFLOW
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Single-line: Row 0-2 (default email meta — API auto-adds overflow: 'auto')
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto' },
|
||||
page: 2,
|
||||
...calculateSingleLinePosition(0),
|
||||
customText: 'example@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto' },
|
||||
page: 2,
|
||||
...calculateSingleLinePosition(1),
|
||||
customText: 'example+medium-overflow-test@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto' },
|
||||
page: 2,
|
||||
...calculateSingleLinePosition(2),
|
||||
customText:
|
||||
'example+maximum-overflow-testing-across-the-page-width-to-verify-text-extends-beyond-field@documenso.com',
|
||||
},
|
||||
// Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long)
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(0, 0),
|
||||
customText: 'example@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(0, 1),
|
||||
customText: 'example+medium-wrapped-text@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(0, 2),
|
||||
customText:
|
||||
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
|
||||
},
|
||||
// Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long)
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(1, 0),
|
||||
customText: 'example@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(1, 1),
|
||||
customText: 'example+medium-wrapped-text@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(1, 2),
|
||||
customText:
|
||||
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
|
||||
},
|
||||
// Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long)
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(2, 0),
|
||||
customText: 'example@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(2, 1),
|
||||
customText: 'example+medium-wrapped-text@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: { type: 'email', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
|
||||
page: 2,
|
||||
...calculateMultiLinePosition(2, 2),
|
||||
customText:
|
||||
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 3: SIGNATURE OVERFLOW
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Single-line: Row 0-2 (default signature meta — API auto-adds overflow: 'auto')
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 3,
|
||||
...calculateSingleLinePosition(0),
|
||||
customText: '',
|
||||
signature: 'John Doe',
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 3,
|
||||
...calculateSingleLinePosition(1),
|
||||
customText: '',
|
||||
signature: 'My Signature should overflow the field width',
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 3,
|
||||
...calculateSingleLinePosition(2),
|
||||
customText: '',
|
||||
signature:
|
||||
'My Signature should overflow the full signature field width and continue across the page to verify text is no longer clipped by the box boundary',
|
||||
},
|
||||
// Multi-line stacked: short / medium / long
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 3,
|
||||
...calculateStackedMultiLinePosition(0),
|
||||
customText: '',
|
||||
signature: 'John Doe',
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 3,
|
||||
...calculateStackedMultiLinePosition(1),
|
||||
customText: '',
|
||||
signature: 'My Signature wraps within the tall field',
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 3,
|
||||
...calculateStackedMultiLinePosition(2),
|
||||
customText: '',
|
||||
signature:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 4: TEXT AUTO - SINGLE-LINE (3x3 grid)
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Row 0 (top)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(0, 0, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(0, 1, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(0, 2, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
// Row 1 (middle)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(1, 0, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(1, 1, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(1, 2, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
// Row 2 (bottom)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(2, 0, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(2, 1, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
|
||||
page: 4,
|
||||
...calculateTextAutoPosition(2, 2, true),
|
||||
customText: 'This text should overflow horizontally',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 5: TEXT AUTO - MULTI-LINE (3x3 grid)
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Row 0 (top)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(0, 0, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(0, 1, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(0, 2, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
// Row 1 (middle)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(1, 0, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(1, 1, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(1, 2, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
// Row 2 (bottom)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(2, 0, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(2, 1, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
|
||||
page: 5,
|
||||
...calculateTextAutoPosition(2, 2, false),
|
||||
customText:
|
||||
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 6: TEXT AUTO - MULTI-LINE HEIGHT OVERFLOW
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Same 3×3 grid as page 5 but with longer text that overflows vertically.
|
||||
// left / top
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(0, 0, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// center / top
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(0, 1, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// right / top
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(0, 2, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// left / middle
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(1, 0, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// center / middle
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(1, 1, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// right / middle
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(1, 2, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// left / bottom
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(2, 0, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// center / bottom
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(2, 1, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
// right / bottom
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
|
||||
page: 6,
|
||||
...calculateTextAutoPosition(2, 2, false),
|
||||
customText:
|
||||
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 7: EXPLICIT MODES
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Section A: Horizontal mode (3 boxes in a row)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' },
|
||||
page: 7,
|
||||
...calculateExplicitHorizontalPosition(0),
|
||||
customText: 'Explicit horizontal overflow text that should extend beyond the field',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' },
|
||||
page: 7,
|
||||
...calculateExplicitHorizontalPosition(1),
|
||||
customText: 'Explicit horizontal overflow text that should extend beyond the field',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' },
|
||||
page: 7,
|
||||
...calculateExplicitHorizontalPosition(2),
|
||||
customText: 'Explicit horizontal overflow text that should extend beyond the field',
|
||||
},
|
||||
// Section B: Vertical mode (3 boxes in a column)
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' },
|
||||
page: 7,
|
||||
...calculateExplicitVerticalPosition(0),
|
||||
customText:
|
||||
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' },
|
||||
page: 7,
|
||||
...calculateExplicitVerticalPosition(1),
|
||||
customText:
|
||||
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' },
|
||||
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' },
|
||||
page: 7,
|
||||
...calculateExplicitVerticalPosition(2),
|
||||
customText:
|
||||
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
|
||||
},
|
||||
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 8: CROP MODE
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// Box 1: Single-line crop
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'text' },
|
||||
page: 8,
|
||||
positionX: 10,
|
||||
positionY: 15,
|
||||
width: 25,
|
||||
height: SINGLE_LINE_HEIGHT,
|
||||
customText: 'This text should be cropped and not overflow',
|
||||
},
|
||||
// Box 2: Multi-line crop
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: undefined,
|
||||
seedFieldMeta: { type: 'text' },
|
||||
page: 8,
|
||||
positionX: 10,
|
||||
positionY: 30,
|
||||
width: 25,
|
||||
height: MULTI_LINE_HEIGHT,
|
||||
customText:
|
||||
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
|
||||
},
|
||||
] as const;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedCompletedDocument, seedDocuments, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const trpcDocumentSearch = async (page: Page, query: string) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/document.search?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
data: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json as Array<{
|
||||
title: string;
|
||||
path: string;
|
||||
value: string;
|
||||
}>)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Visibility ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Document Search - Visibility', () => {
|
||||
test('should respect team document visibility per role', async ({ page }) => {
|
||||
const { user: owner, organisation, team } = await seedUser();
|
||||
|
||||
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [
|
||||
{ organisationRole: OrganisationMemberRole.ADMIN },
|
||||
{ organisationRole: OrganisationMemberRole.MEMBER },
|
||||
{ organisationRole: OrganisationMemberRole.MEMBER },
|
||||
],
|
||||
});
|
||||
|
||||
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
|
||||
where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER },
|
||||
include: { organisationGroup: true },
|
||||
});
|
||||
|
||||
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
|
||||
where: { organisationId: organisation.id, userId: managerUser.id },
|
||||
});
|
||||
|
||||
await prisma.organisationGroupMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: managerTeamGroup.organisationGroupId,
|
||||
organisationMemberId: managerOrganisationMember.id,
|
||||
},
|
||||
});
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: owner,
|
||||
teamId: team.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
visibility: 'EVERYONE',
|
||||
title: 'Searchable Document for Everyone',
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: owner,
|
||||
teamId: team.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
visibility: 'MANAGER_AND_ABOVE',
|
||||
title: 'Searchable Document for Managers',
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: owner,
|
||||
teamId: team.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Searchable Document for Admins',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const testCases = [
|
||||
{ user: adminUser, visibleDocs: 3 },
|
||||
{ user: managerUser, visibleDocs: 2 },
|
||||
{ user: memberUser, visibleDocs: 1 },
|
||||
];
|
||||
|
||||
for (const { user, visibleDocs } of testCases) {
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'Searchable Document');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data).toHaveLength(visibleDocs);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('should respect visibility when searching by recipient email', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: uniqueRecipient } = await seedUser();
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: owner,
|
||||
recipients: [uniqueRecipient],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
teamId: team.id,
|
||||
documentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Admin Document for Unique Recipient',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Admin can find the ADMIN-visibility document by recipient email.
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { data: adminData } = await trpcDocumentSearch(page, uniqueRecipient.email);
|
||||
|
||||
expect(adminData).not.toBeNull();
|
||||
expect(adminData).toHaveLength(1);
|
||||
expect(adminData![0].title).toBe('Admin Document for Unique Recipient');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Member cannot find the ADMIN-visibility document by recipient email.
|
||||
await apiSignin({ page, email: memberUser.email });
|
||||
|
||||
const { data: memberData } = await trpcDocumentSearch(page, uniqueRecipient.email);
|
||||
|
||||
expect(memberData).not.toBeNull();
|
||||
expect(memberData).toHaveLength(0);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-Team Isolation ────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Document Search - Cross-Team Isolation', () => {
|
||||
test('should not reveal documents from other teams', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
|
||||
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: ownerA,
|
||||
teamId: teamA.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
visibility: 'EVERYONE',
|
||||
title: 'Unique Team A Document',
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: ownerB,
|
||||
teamId: teamB.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
visibility: 'EVERYONE',
|
||||
title: 'Unique Team B Document',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({ page, email: memberA.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'Unique');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
const titles = data!.map((d) => d.title);
|
||||
|
||||
expect(titles).toContain('Unique Team A Document');
|
||||
expect(titles).not.toContain('Unique Team B Document');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not cross team boundaries when searching SQL wildcard "%"', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: ownerA,
|
||||
teamId: teamA.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: { title: 'Wildcard Doc A' },
|
||||
},
|
||||
]);
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: ownerB,
|
||||
teamId: teamB.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: { title: 'Wildcard Doc B' },
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, '%');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.map((d) => d.title)).not.toContain('Wildcard Doc B');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not cross team boundaries when searching SQL wildcard "_"', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: ownerA,
|
||||
teamId: teamA.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: { title: 'Underscore A' },
|
||||
},
|
||||
]);
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: ownerB,
|
||||
teamId: teamB.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: { title: 'Underscore B' },
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, '_');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.map((d) => d.title)).not.toContain('Underscore B');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Recipient Search ────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Document Search - Recipient', () => {
|
||||
test('should find documents where user is a recipient', async ({ page }) => {
|
||||
const { team: senderTeam, owner: sender } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Recipient Search Test Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'Recipient Search Test');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const result = data!.find((d) => d.title === 'Recipient Search Test Doc');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.path).toMatch(/^\/sign\/.+/);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Token Masking ───────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Document Search - Token Masking', () => {
|
||||
test('should not expose signing token in value field', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const doc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Token Leakage Test Document' },
|
||||
});
|
||||
|
||||
const recipientRecord = await prisma.recipient.findFirstOrThrow({
|
||||
where: { envelopeId: doc.id, email: recipient.email },
|
||||
select: { token: true },
|
||||
});
|
||||
|
||||
const signingToken = recipientRecord.token;
|
||||
|
||||
// Owner should see the doc but not any signing token.
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'Token Leakage Test');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const result = data!.find((d) => d.title === 'Token Leakage Test Document');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.value).not.toContain(signingToken);
|
||||
expect(result!.path).not.toContain('/sign/');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipient should get their own /sign/ path but token still hidden from value.
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
const { data: recipientData } = await trpcDocumentSearch(page, 'Token Leakage Test');
|
||||
|
||||
expect(recipientData).not.toBeNull();
|
||||
expect(recipientData!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const recipientResult = recipientData!.find((d) => d.title === 'Token Leakage Test Document');
|
||||
expect(recipientResult).toBeDefined();
|
||||
expect(recipientResult!.path).toBe(`/sign/${signingToken}`);
|
||||
expect(recipientResult!.value).not.toContain(signingToken);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not expose other recipients signing tokens to the owner', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { user: recipientA } = await seedUser();
|
||||
const { user: recipientB } = await seedUser();
|
||||
|
||||
const doc = await seedPendingDocument(owner, team.id, [recipientA, recipientB], {
|
||||
createDocumentOptions: { title: 'Multi Recipient Token Test' },
|
||||
});
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: doc.id },
|
||||
select: { token: true, email: true },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'Multi Recipient Token');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
const result = data!.find((d) => d.title === 'Multi Recipient Token Test');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
for (const r of recipients) {
|
||||
expect(result!.value).not.toContain(r.token);
|
||||
expect(result!.path).not.toContain(r.token);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Filtering ───────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Document Search - Filtering', () => {
|
||||
test('should exclude soft-deleted documents', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedCompletedDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { title: 'Active Deletable Document' },
|
||||
});
|
||||
|
||||
const deletedDoc = await seedCompletedDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { title: 'Deleted Deletable Document' },
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: { id: deletedDoc.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'Deletable Document');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data![0].title).toBe('Active Deletable Document');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should find documents by externalId', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: owner,
|
||||
teamId: team.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: {
|
||||
title: 'ExternalId Test Doc',
|
||||
externalId: 'ext-unique-abc-123',
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: owner,
|
||||
teamId: team.id,
|
||||
recipients: [],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: {
|
||||
title: 'Other Test Doc',
|
||||
externalId: 'ext-other-xyz',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const { data } = await trpcDocumentSearch(page, 'ext-unique-abc');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data![0].title).toBe('ExternalId Test Doc');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Document Search - Authentication', () => {
|
||||
test('should reject unauthenticated requests', async ({ page }) => {
|
||||
const { res } = await trpcDocumentSearch(page, 'anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const trpcTemplateSearch = async (page: Page, query: string) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/template.search?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
data: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json as Array<{
|
||||
title: string;
|
||||
path: string;
|
||||
value: string;
|
||||
}>)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Visibility ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Template Search - Visibility', () => {
|
||||
test('should respect team template visibility per role', async ({ page }) => {
|
||||
const { user: owner, organisation, team } = await seedUser();
|
||||
|
||||
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [
|
||||
{ organisationRole: OrganisationMemberRole.ADMIN },
|
||||
{ organisationRole: OrganisationMemberRole.MEMBER },
|
||||
{ organisationRole: OrganisationMemberRole.MEMBER },
|
||||
],
|
||||
});
|
||||
|
||||
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
|
||||
where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER },
|
||||
include: { organisationGroup: true },
|
||||
});
|
||||
|
||||
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
|
||||
where: { organisationId: organisation.id, userId: managerUser.id },
|
||||
});
|
||||
|
||||
await prisma.organisationGroupMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: managerTeamGroup.organisationGroupId,
|
||||
organisationMemberId: managerOrganisationMember.id,
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(owner, team.id, {
|
||||
createTemplateOptions: {
|
||||
visibility: 'EVERYONE',
|
||||
title: 'Searchable Template for Everyone',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(owner, team.id, {
|
||||
createTemplateOptions: {
|
||||
visibility: 'MANAGER_AND_ABOVE',
|
||||
title: 'Searchable Template for Managers',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(owner, team.id, {
|
||||
createTemplateOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Searchable Template for Admins',
|
||||
},
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{ user: adminUser, visibleTemplates: 3 },
|
||||
{ user: managerUser, visibleTemplates: 2 },
|
||||
{ user: memberUser, visibleTemplates: 1 },
|
||||
];
|
||||
|
||||
for (const { user, visibleTemplates } of testCases) {
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const { data } = await trpcTemplateSearch(page, 'Searchable Template');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data).toHaveLength(visibleTemplates);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-Team Isolation ────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Template Search - Cross-Team Isolation', () => {
|
||||
test('should not reveal templates from other teams', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
|
||||
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
visibility: 'EVERYONE',
|
||||
title: 'Unique Team A Template',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(ownerB, teamB.id, {
|
||||
createTemplateOptions: {
|
||||
visibility: 'EVERYONE',
|
||||
title: 'Unique Team B Template',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberA.email });
|
||||
|
||||
const { data } = await trpcTemplateSearch(page, 'Unique');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
const titles = data!.map((d) => d.title);
|
||||
|
||||
expect(titles).toContain('Unique Team A Template');
|
||||
expect(titles).not.toContain('Unique Team B Template');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not cross team boundaries when searching SQL wildcard "%"', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
|
||||
await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: { title: 'Wildcard Tpl A' },
|
||||
});
|
||||
|
||||
await seedBlankTemplate(ownerB, teamB.id, {
|
||||
createTemplateOptions: { title: 'Wildcard Tpl B' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { data } = await trpcTemplateSearch(page, '%');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.map((d) => d.title)).not.toContain('Wildcard Tpl B');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Recipient Email Search ──────────────────────────────────────────────────
|
||||
|
||||
test.describe('Template Search - Recipient Email', () => {
|
||||
test('should find templates by recipient email within team but not cross-team', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const adminUserA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
|
||||
const { user: uniqueRecipient } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: { title: 'Template with Unique Recipient' },
|
||||
});
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: uniqueRecipient.email,
|
||||
name: uniqueRecipient.name ?? '',
|
||||
token: Math.random().toString().slice(2, 7),
|
||||
envelopeId: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Team admin can find the template by recipient email.
|
||||
await apiSignin({ page, email: adminUserA.email });
|
||||
|
||||
const { data: adminData } = await trpcTemplateSearch(page, uniqueRecipient.email);
|
||||
|
||||
expect(adminData).not.toBeNull();
|
||||
expect(adminData).toHaveLength(1);
|
||||
expect(adminData![0].title).toBe('Template with Unique Recipient');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Owner of a different team cannot find it.
|
||||
await apiSignin({ page, email: ownerB.email });
|
||||
|
||||
const { data: otherTeamData } = await trpcTemplateSearch(page, uniqueRecipient.email);
|
||||
|
||||
expect(otherTeamData).not.toBeNull();
|
||||
expect(otherTeamData).toHaveLength(0);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Filtering ───────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Template Search - Filtering', () => {
|
||||
test('should exclude soft-deleted templates', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedBlankTemplate(owner, team.id, {
|
||||
createTemplateOptions: { title: 'Active Findable Template' },
|
||||
});
|
||||
|
||||
const deletedTemplate = await seedBlankTemplate(owner, team.id, {
|
||||
createTemplateOptions: { title: 'Deleted Findable Template' },
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: { id: deletedTemplate.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const { data } = await trpcTemplateSearch(page, 'Findable Template');
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data![0].title).toBe('Active Findable Template');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Template Search - Authentication', () => {
|
||||
test('should reject unauthenticated requests', async ({ page }) => {
|
||||
const { res } = await trpcTemplateSearch(page, 'anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Document API', () => {
|
||||
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
|
||||
@@ -193,9 +189,7 @@ test.describe('Document API', () => {
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('sendDocument: should fail when signer has only non-signature fields', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('sendDocument: should fail when signer has only non-signature fields', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
@@ -12,16 +10,14 @@ import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe('Template Field Prefill API v1', () => {
|
||||
test('should create a document from template with prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test('should create a document from template with prefilled fields', async ({ page, request }) => {
|
||||
// 1. Create a user
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
@@ -196,9 +192,7 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
|
||||
|
||||
// 8. Create a document from the template with prefilled fields
|
||||
const response = await request.post(
|
||||
@@ -381,10 +375,7 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
await expect(page.getByText('Select B')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a document from template without prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test('should create a document from template without prefilled fields', async ({ page, request }) => {
|
||||
// 1. Create a user
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
@@ -487,9 +478,7 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
|
||||
|
||||
// 8. Create a document from the template without prefilled fields
|
||||
const response = await request.post(
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
|
||||
import {
|
||||
@@ -16,6 +11,7 @@ import {
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
@@ -25,9 +21,7 @@ test.describe.configure({
|
||||
|
||||
test.describe('Document Access API V1', () => {
|
||||
test.describe('Document GET endpoint', () => {
|
||||
test('should block unauthorized access to documents not owned by the user', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to documents not owned by the user', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
@@ -319,9 +313,7 @@ test.describe('Document Access API V1', () => {
|
||||
});
|
||||
|
||||
test.describe('Document recipients POST endpoint', () => {
|
||||
test('should block unauthorized access to document recipients endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to document recipients endpoint', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
@@ -371,9 +363,7 @@ test.describe('Document Access API V1', () => {
|
||||
});
|
||||
|
||||
test.describe('Document recipients PATCH endpoint', () => {
|
||||
test('should block unauthorized access to PATCH on recipients endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to PATCH on recipients endpoint', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
@@ -459,9 +449,7 @@ test.describe('Document Access API V1', () => {
|
||||
});
|
||||
|
||||
test.describe('Document recipients DELETE endpoint', () => {
|
||||
test('should block unauthorized access to DELETE on recipients endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to DELETE on recipients endpoint', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
@@ -972,9 +960,7 @@ test.describe('Document Access API V1', () => {
|
||||
|
||||
expect(resB.ok()).toBeTruthy();
|
||||
expect(resB.status()).toBe(200);
|
||||
expect(reqData.documents.every((doc: { userId: number }) => doc.userId !== userA.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reqData.documents.every((doc: { userId: number }) => doc.userId !== userA.id)).toBe(true);
|
||||
expect(reqData.documents.length).toBe(0);
|
||||
expect(reqData.totalPages).toBe(0);
|
||||
});
|
||||
@@ -999,9 +985,7 @@ test.describe('Document Access API V1', () => {
|
||||
expect(resA.ok()).toBeTruthy();
|
||||
expect(resA.status()).toBe(200);
|
||||
expect(reqData.documents.length).toBeGreaterThan(0);
|
||||
expect(reqData.documents.every((doc: { userId: number }) => doc.userId === userA.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reqData.documents.every((doc: { userId: number }) => doc.userId === userA.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1027,9 +1011,7 @@ test.describe('Document Access API V1', () => {
|
||||
|
||||
expect(resB.ok()).toBeTruthy();
|
||||
expect(resB.status()).toBe(200);
|
||||
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId !== userA.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId !== userA.id)).toBe(true);
|
||||
expect(reqData.templates.length).toBe(0);
|
||||
expect(reqData.totalPages).toBe(0);
|
||||
});
|
||||
@@ -1054,16 +1036,12 @@ test.describe('Document Access API V1', () => {
|
||||
expect(resA.ok()).toBeTruthy();
|
||||
expect(resA.status()).toBe(200);
|
||||
expect(reqData.templates.length).toBeGreaterThan(0);
|
||||
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId === userA.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId === userA.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create document from template endpoint', () => {
|
||||
test('should block unauthorized access to create-document-from-template endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to create-document-from-template endpoint', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
@@ -1104,9 +1082,7 @@ test.describe('Document Access API V1', () => {
|
||||
expect(resB.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should allow authorized access to create-document-from-template endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should allow authorized access to create-document-from-template endpoint', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
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 { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
@@ -13,6 +10,8 @@ import type {
|
||||
} 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 { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
@@ -138,9 +137,7 @@ test.describe('Envelope distribute validation', () => {
|
||||
expect(errorResponse.message).toContain('Signers must have at least one signature field');
|
||||
});
|
||||
|
||||
test('should fail to distribute when signer has non-signature fields only', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should fail to distribute when signer has non-signature fields only', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
|
||||
@@ -321,9 +318,7 @@ test.describe('Envelope distribute validation', () => {
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('should fail when one of multiple signers is missing signature field', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should fail when one of multiple signers is missing signature field', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from 'playwright-core';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from 'playwright-core';
|
||||
|
||||
test.describe('Embedding Presign API', () => {
|
||||
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('createEmbeddingPresignToken: should create a token with default expiration', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
@@ -34,9 +31,7 @@ test.describe('Embedding Presign API', () => {
|
||||
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
|
||||
});
|
||||
|
||||
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
@@ -187,9 +182,7 @@ test.describe('Embedding Presign API', () => {
|
||||
expect(verifyResponseData.success).toBe(true);
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
@@ -233,19 +226,16 @@ const createPresignToken = async (
|
||||
apiToken: string,
|
||||
data?: Partial<CreateEmbeddingPresignTokenOptions>,
|
||||
) => {
|
||||
return await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken,
|
||||
...data,
|
||||
},
|
||||
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
data: {
|
||||
apiToken,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const verifyPresignToken = async (
|
||||
@@ -253,14 +243,11 @@ const verifyPresignToken = async (
|
||||
apiToken: string,
|
||||
data: VerifyEmbeddingPresignTokenOptions,
|
||||
) => {
|
||||
return await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data,
|
||||
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pick } from 'remeda';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -19,17 +15,20 @@ import {
|
||||
RecipientRole,
|
||||
} from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
|
||||
import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import { pick } from 'remeda';
|
||||
|
||||
import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf';
|
||||
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
|
||||
@@ -94,9 +93,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
const files = [
|
||||
{
|
||||
name: 'field-font-alignment.pdf',
|
||||
data: fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
),
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -171,6 +168,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
@@ -180,6 +178,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -205,7 +204,9 @@ test.describe('API V2 Envelopes', () => {
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerDocumentCreated: true,
|
||||
},
|
||||
},
|
||||
attachments: [
|
||||
@@ -228,9 +229,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
},
|
||||
{
|
||||
name: 'field-font-alignment.pdf',
|
||||
data: fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
),
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -290,15 +289,11 @@ test.describe('API V2 Envelopes', () => {
|
||||
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
|
||||
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
|
||||
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
|
||||
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
|
||||
payload.meta.allowDictateNextSigner,
|
||||
);
|
||||
expect(envelope.documentMeta.allowDictateNextSigner).toBe(payload.meta.allowDictateNextSigner);
|
||||
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
|
||||
expect(envelope.documentMeta.language).toBe(payload.meta.language);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
|
||||
payload.meta.uploadSignatureEnabled,
|
||||
);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
||||
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
||||
@@ -320,9 +315,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
accessAuth: recipient.authOptions?.accessAuth,
|
||||
}).toEqual(
|
||||
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
|
||||
);
|
||||
}).toEqual(pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']));
|
||||
|
||||
expect({
|
||||
type: field.type,
|
||||
@@ -331,16 +324,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
}).toEqual(
|
||||
pick(payload.recipients[0].fields[0], [
|
||||
'type',
|
||||
'page',
|
||||
'positionX',
|
||||
'positionY',
|
||||
'width',
|
||||
'height',
|
||||
]),
|
||||
);
|
||||
}).toEqual(pick(payload.recipients[0].fields[0], ['type', 'page', 'positionX', 'positionY', 'width', 'height']));
|
||||
|
||||
// Expect string based ID to work.
|
||||
expect(field.envelopeItemId).toBe(
|
||||
@@ -359,13 +343,9 @@ test.describe('API V2 Envelopes', () => {
|
||||
*/
|
||||
test('Envelope full test', async ({ request }) => {
|
||||
// Step 1: Create initial envelope with Prisma (with first envelope item)
|
||||
const alignmentPdf = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
);
|
||||
const alignmentPdf = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
|
||||
|
||||
const fieldMetaPdf = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
|
||||
);
|
||||
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf'));
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -378,10 +358,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
);
|
||||
|
||||
// Only add one file for now.
|
||||
formData.append(
|
||||
'files',
|
||||
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
@@ -414,10 +391,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
|
||||
const createEnvelopeItemFormData = new FormData();
|
||||
createEnvelopeItemFormData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
|
||||
createEnvelopeItemFormData.append(
|
||||
'files',
|
||||
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
createEnvelopeItemFormData.append('files', new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
@@ -477,12 +451,8 @@ test.describe('API V2 Envelopes', () => {
|
||||
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
|
||||
|
||||
const recipientId = envelopeResponse.recipients[0].id;
|
||||
const alignmentItem = envelopeResponse.envelopeItems.find(
|
||||
(item: { order: number }) => item.order === 1,
|
||||
);
|
||||
const fieldMetaItem = envelopeResponse.envelopeItems.find(
|
||||
(item: { order: number }) => item.order === 2,
|
||||
);
|
||||
const alignmentItem = envelopeResponse.envelopeItems.find((item: { order: number }) => item.order === 1);
|
||||
const fieldMetaItem = envelopeResponse.envelopeItems.find((item: { order: number }) => item.order === 2);
|
||||
|
||||
expect(recipientId).toBeDefined();
|
||||
expect(alignmentItem).toBeDefined();
|
||||
@@ -551,9 +521,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
// Verify structure
|
||||
expect(finalEnvelope.envelopeItems.length).toBe(2);
|
||||
expect(finalEnvelope.recipients.length).toBe(1);
|
||||
expect(finalEnvelope.fields.length).toBe(
|
||||
ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length,
|
||||
);
|
||||
expect(finalEnvelope.fields.length).toBe(ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length);
|
||||
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
|
||||
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
|
||||
|
||||
@@ -564,17 +532,11 @@ test.describe('API V2 Envelopes', () => {
|
||||
});
|
||||
|
||||
test.describe('Envelope find endpoint', () => {
|
||||
const createEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
payload: TCreateEnvelopePayload,
|
||||
) => {
|
||||
const createEnvelope = async (request: APIRequestContext, token: string, payload: TCreateEnvelopePayload) => {
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const pdfData = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
);
|
||||
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
|
||||
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
@@ -1195,9 +1157,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('Distribute envelope with empty email recipient and auth requirements fails', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document with Auth Requirements',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { apiSeedDraftDocument, apiSeedPendingDocument } from '../../fixtures/api-seeds';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
|
||||
expect(res.ok(), `${procedure} failed: ${await res.text()}`).toBeTruthy();
|
||||
};
|
||||
|
||||
const getPdfBytes = async (response: Awaited<ReturnType<APIRequestContext['get']>>) => {
|
||||
const body = await response.body();
|
||||
|
||||
expect(body.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
|
||||
return new Uint8Array(body);
|
||||
};
|
||||
|
||||
const signAndCompleteRecipient = async ({
|
||||
request,
|
||||
token,
|
||||
documentId,
|
||||
fieldId,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
documentId: number;
|
||||
fieldId: number;
|
||||
}) => {
|
||||
await trpcMutation(request, 'envelope.field.sign', {
|
||||
token,
|
||||
fieldId,
|
||||
fieldValue: {
|
||||
type: FieldType.SIGNATURE,
|
||||
value: 'Signature',
|
||||
},
|
||||
});
|
||||
|
||||
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('API V2 partial signed PDF downloads', () => {
|
||||
test('returns a PDF with inserted fields, supports ETag, and rejects after completion', async ({ request }) => {
|
||||
const { envelope, token, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
recipients: [
|
||||
{ email: 'partial-signer-1@test.documenso.com', name: 'Partial Signer 1' },
|
||||
{ email: 'partial-signer-2@test.documenso.com', name: 'Partial Signer 2' },
|
||||
],
|
||||
fieldsPerRecipient: [
|
||||
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
|
||||
[
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 15,
|
||||
width: 15,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const [recipientOne, recipientTwo] = distributeResult.recipients;
|
||||
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
const recipientOneField = envelope.fields.find(
|
||||
(field) => field.recipientId === recipientOne.id && field.type === FieldType.SIGNATURE,
|
||||
);
|
||||
const recipientTwoField = envelope.fields.find(
|
||||
(field) => field.recipientId === recipientTwo.id && field.type === FieldType.SIGNATURE,
|
||||
);
|
||||
|
||||
if (!recipientOneField || !recipientTwoField) {
|
||||
throw new Error('Expected signature fields not found');
|
||||
}
|
||||
|
||||
await signAndCompleteRecipient({
|
||||
request,
|
||||
token: recipientOne.token,
|
||||
documentId,
|
||||
fieldId: recipientOneField.id,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbEnvelope.status).toBe(DocumentStatus.PENDING);
|
||||
expect(dbEnvelope.recipients.find((recipient) => recipient.id === recipientOne.id)?.signingStatus).toBe(
|
||||
SigningStatus.SIGNED,
|
||||
);
|
||||
}).toPass();
|
||||
|
||||
const downloadUrl = `${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=pending`;
|
||||
const pendingResponse = await request.get(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(pendingResponse.status()).toBe(200);
|
||||
expect(pendingResponse.headers()['content-type']).toContain('application/pdf');
|
||||
expect(pendingResponse.headers()['cache-control']).toBe('no-store, private');
|
||||
expect(pendingResponse.headers()['content-disposition']).toContain('_pending.pdf');
|
||||
|
||||
const etag = pendingResponse.headers().etag;
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const pendingPdfBytes = await getPdfBytes(pendingResponse);
|
||||
const pendingPdf = await PDF.load(pendingPdfBytes);
|
||||
|
||||
const originalEnvelopeItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: {
|
||||
id: envelopeItem.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
const originalPdfBytes = await getFileServerSide({
|
||||
type: originalEnvelopeItem.documentData.type,
|
||||
data: originalEnvelopeItem.documentData.initialData,
|
||||
});
|
||||
const originalPdf = await PDF.load(new Uint8Array(originalPdfBytes));
|
||||
|
||||
// Pending PDF should have the same page count as the original (no cert/audit pages).
|
||||
expect(pendingPdf.getPageCount()).toBe(originalPdf.getPageCount());
|
||||
|
||||
const cachedResponse = await request.get(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'If-None-Match': etag,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cachedResponse.status()).toBe(304);
|
||||
|
||||
await signAndCompleteRecipient({
|
||||
request,
|
||||
token: recipientTwo.token,
|
||||
documentId,
|
||||
fieldId: recipientTwoField.id,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
const completedResponse = await request.get(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const completedError = await completedResponse.json();
|
||||
|
||||
expect(completedResponse.status()).toBe(400);
|
||||
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
|
||||
|
||||
const signedResponse = await request.get(
|
||||
`${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=signed`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(signedResponse.status()).toBe(200);
|
||||
await getPdfBytes(signedResponse);
|
||||
});
|
||||
|
||||
test('rejects draft and legacy pending envelopes', async ({ request }) => {
|
||||
const draft = await apiSeedDraftDocument(request, {
|
||||
recipients: [{ email: 'partial-draft@test.documenso.com', name: 'Draft Signer' }],
|
||||
});
|
||||
const draftResponse = await request.get(
|
||||
`${API_BASE_URL}/envelope/item/${draft.envelope.envelopeItems[0].id}/download?version=pending`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${draft.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const draftError = await draftResponse.json();
|
||||
|
||||
expect(draftResponse.status()).toBe(400);
|
||||
expect(draftError.code).toBe('ENVELOPE_DRAFT');
|
||||
|
||||
const legacy = await apiSeedPendingDocument(request);
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: legacy.envelope.id,
|
||||
},
|
||||
data: {
|
||||
internalVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const legacyResponse = await request.get(
|
||||
`${API_BASE_URL}/envelope/item/${legacy.envelope.envelopeItems[0].id}/download?version=pending`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${legacy.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const legacyError = await legacyResponse.json();
|
||||
|
||||
expect(legacyResponse.status()).toBe(400);
|
||||
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,5 @@
|
||||
import { PDF, StandardFonts } from '@libpdf/core';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
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';
|
||||
@@ -16,6 +11,10 @@ import type {
|
||||
} 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 { PDF, StandardFonts } from '@libpdf/core';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
@@ -66,11 +65,7 @@ test.describe('Placeholder-based field creation', () => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const createEnvelopeItemsWithPdf = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
pdfFilename: string,
|
||||
) => {
|
||||
const createEnvelopeItemsWithPdf = async (request: APIRequestContext, envelopeId: string, pdfFilename: string) => {
|
||||
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
|
||||
const pdfData = fs.readFileSync(pdfPath);
|
||||
|
||||
@@ -129,10 +124,7 @@ test.describe('Placeholder-based field creation', () => {
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
const getEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
): Promise<TGetEnvelopeResponse> => {
|
||||
const getEnvelope = async (request: APIRequestContext, envelopeId: string): Promise<TGetEnvelopeResponse> => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
@@ -281,9 +273,7 @@ test.describe('Placeholder-based field creation', () => {
|
||||
expect(createFieldsRes.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should create fields using a mix of coordinate and placeholder positioning', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should create fields using a mix of coordinate and placeholder positioning', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
@@ -409,9 +399,7 @@ test.describe('Placeholder-based field creation', () => {
|
||||
expect(uniqueYPositions.size).toBe(3);
|
||||
});
|
||||
|
||||
test('should map placeholder recipients by signing order when adding items', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should map placeholder recipients by signing order when adding items', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
|
||||
await addRecipients(request, envelope.id, [
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
@@ -12,16 +10,14 @@ import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe('Template Field Prefill API v2', () => {
|
||||
test('should create a document from template with prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test('should create a document from template with prefilled fields', async ({ page, request }) => {
|
||||
// 1. Create a user
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
@@ -196,9 +192,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
|
||||
|
||||
// 8. Create a document from the template with prefilled fields using v2 API
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
@@ -378,10 +372,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
await expect(page.getByText('Select B')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a document from template without prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test('should create a document from template without prefilled fields', async ({ page, request }) => {
|
||||
// 1. Create a user
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
@@ -484,9 +475,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
|
||||
|
||||
// 8. Create a document from the template without prefilled fields using v2 API
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+16
-39
@@ -1,11 +1,10 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
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 { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
@@ -35,33 +34,23 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment find endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to envelope attachment find endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should allow authorized access to envelope attachment find endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
@@ -69,9 +58,7 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment create endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to envelope attachment create endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
|
||||
@@ -89,9 +76,7 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should allow authorized access to envelope attachment create endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
|
||||
@@ -111,9 +96,7 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment update endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to envelope attachment update endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
@@ -140,9 +123,7 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should allow authorized access to envelope attachment update endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
@@ -171,9 +152,7 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should block unauthorized access to envelope attachment delete endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
@@ -194,9 +173,7 @@ test.describe('Envelope Attachments API V2', () => {
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should allow authorized access to envelope attachment delete endpoint', async ({ request }) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
|
||||
+337
-347
@@ -1,13 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType } from '@prisma/client';
|
||||
|
||||
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 { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
@@ -16,375 +15,366 @@ test.describe.configure({
|
||||
});
|
||||
|
||||
// Todo: Remove skip once the API endpoints are released.
|
||||
test.describe.skip('Envelope Bulk API V2', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
test.describe
|
||||
.skip('Envelope Bulk API V2', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test.describe('Envelope bulk move endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserB tries to move userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
|
||||
// Create a document owned by userB
|
||||
const doc = await seedBlankDocument(userB, teamB.id);
|
||||
test.describe('Envelope bulk move endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
// UserB tries to move userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
// UserB tries to move their document to userA's folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
|
||||
// Create a document owned by userB
|
||||
const doc = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserB tries to move their document to userA's folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA moves their own document to their own folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(1);
|
||||
|
||||
// Verify in database that the document was moved to the folder
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBe(folderA.id);
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({ request }) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be moved
|
||||
expect(body.movedCount).toBe(2);
|
||||
|
||||
// Verify userA's documents were moved
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).not.toBeNull();
|
||||
expect(docA1InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).not.toBeNull();
|
||||
expect(docA2InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
// Verify userB's document was NOT moved
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({ request }) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify userB's documents were NOT moved
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.folderId).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.folderId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
test.describe('Envelope bulk delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk delete endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
// UserB tries to delete userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// Unauthorized envelope ID should be in failedIds
|
||||
expect(body.failedIds).toEqual([doc.id]);
|
||||
|
||||
// Verify in database that the document still exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.id).toBe(doc.id);
|
||||
expect(docInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
// UserA moves their own document to their own folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserA deletes their own document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(1);
|
||||
expect(body.failedIds).toEqual([]);
|
||||
|
||||
// Verify in database that the document no longer exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).toBeNull();
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({ request }) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(1);
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Verify in database that the document was moved to the folder
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
// UserA tries to delete a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be deleted
|
||||
expect(body.deletedCount).toBe(2);
|
||||
// Unauthorized envelope ID (docB) should be in failedIds
|
||||
expect(body.failedIds).toEqual([docB.id]);
|
||||
|
||||
// Verify userA's documents were deleted
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).toBeNull();
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).toBeNull();
|
||||
|
||||
// Verify userB's document was NOT deleted
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.id).toBe(docB.id);
|
||||
expect(docBInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBe(folderA.id);
|
||||
});
|
||||
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({ request }) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
// UserA tries to delete userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
},
|
||||
});
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// All unauthorized envelope IDs should be in failedIds
|
||||
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
|
||||
expect(body.failedIds).toHaveLength(2);
|
||||
|
||||
// Verify userB's documents were NOT deleted
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.id).toBe(docB1.id);
|
||||
expect(docB1InDb?.deletedAt).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.id).toBe(docB2.id);
|
||||
expect(docB2InDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
// UserA tries to move a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be moved
|
||||
expect(body.movedCount).toBe(2);
|
||||
|
||||
// Verify userA's documents were moved
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).not.toBeNull();
|
||||
expect(docA1InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).not.toBeNull();
|
||||
expect(docA2InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
// Verify userB's document was NOT moved
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify userB's documents were NOT moved
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.folderId).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.folderId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope bulk delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserB tries to delete userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// Unauthorized envelope ID should be in failedIds
|
||||
expect(body.failedIds).toEqual([doc.id]);
|
||||
|
||||
// Verify in database that the document still exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.id).toBe(doc.id);
|
||||
expect(docInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserA deletes their own document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(1);
|
||||
expect(body.failedIds).toEqual([]);
|
||||
|
||||
// Verify in database that the document no longer exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).toBeNull();
|
||||
});
|
||||
|
||||
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// UserA tries to delete a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be deleted
|
||||
expect(body.deletedCount).toBe(2);
|
||||
// Unauthorized envelope ID (docB) should be in failedIds
|
||||
expect(body.failedIds).toEqual([docB.id]);
|
||||
|
||||
// Verify userA's documents were deleted
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).toBeNull();
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).toBeNull();
|
||||
|
||||
// Verify userB's document was NOT deleted
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.id).toBe(docB.id);
|
||||
expect(docBInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// UserA tries to delete userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// All unauthorized envelope IDs should be in failedIds
|
||||
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
|
||||
expect(body.failedIds).toHaveLength(2);
|
||||
|
||||
// Verify userB's documents were NOT deleted
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.id).toBe(docB1.id);
|
||||
expect(docB1InDb?.deletedAt).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.id).toBe(docB2.id);
|
||||
expect(docB2InDb?.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
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 { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
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 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 type { TUpdateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/update-envelope-items.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Update Items Test',
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TGetEnvelopeResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transition an envelope from DRAFT to PENDING by adding a recipient with a
|
||||
* signature field and distributing.
|
||||
*/
|
||||
const distributeEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||
const recipientEmail = `signer-${Date.now()}@test.documenso.com`;
|
||||
|
||||
// Create a SIGNER recipient.
|
||||
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
data: [
|
||||
{
|
||||
email: recipientEmail,
|
||||
name: 'Test Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
} satisfies TCreateEnvelopeRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(recipientsRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = (await recipientsRes.json()).data;
|
||||
|
||||
// Resolve the envelope item ID.
|
||||
const envelope = await getEnvelope(request, authToken, envelopeId);
|
||||
const envelopeItemId = envelope.envelopeItems[0].id;
|
||||
|
||||
// Create a SIGNATURE field.
|
||||
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipients[0].id,
|
||||
envelopeItemId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(fieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Distribute.
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
const updateEnvelopeItems = async (
|
||||
request: APIRequestContext,
|
||||
authToken: string,
|
||||
payload: TUpdateEnvelopeItemsRequest,
|
||||
) => {
|
||||
return request.post(`${baseUrl}/envelope/item/update-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: payload,
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Update envelope items', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-update-items',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DRAFT envelope — full edit allowed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow updating item title on a DRAFT envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: 'New Draft Title' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.title).toBe('New Draft Title');
|
||||
});
|
||||
|
||||
test('should allow updating item order on a DRAFT envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, order: 5 }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.order).toBe(5);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PENDING envelope — title-only edit allowed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow updating item title on a PENDING envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: 'Updated Pending Title' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.title).toBe('Updated Pending Title');
|
||||
});
|
||||
|
||||
test('should allow title-only update when order matches existing on a PENDING envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
// Send the same order value that already exists — this should be treated as title-only.
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: 'Title With Same Order',
|
||||
order: envelopeItem.order,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.title).toBe('Title With Same Order');
|
||||
});
|
||||
|
||||
test('should create an ENVELOPE_ITEM_UPDATED audit log when updating item title on PENDING envelope', async ({
|
||||
request,
|
||||
}) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
const originalTitle = envelopeItem.title;
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: 'Audited Title' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'ENVELOPE_ITEM_UPDATED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
|
||||
const auditData = auditLog!.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.envelopeItemId).toBe(envelopeItem.id);
|
||||
|
||||
const changes = auditData.changes as Array<{ field: string; from: string; to: string }>;
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].field).toBe('title');
|
||||
expect(changes[0].from).toBe(originalTitle);
|
||||
expect(changes[0].to).toBe('Audited Title');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PENDING envelope — order change blocked
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should reject order change on a PENDING envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('should reject combined title and order change on a PENDING envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: 'Should Not Save',
|
||||
order: 99,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
// Verify title was NOT changed.
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.title).not.toBe('Should Not Save');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// COMPLETED envelope — all edits blocked
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should reject title update on a COMPLETED envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
// Transition to COMPLETED directly via database.
|
||||
await prisma.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: { status: DocumentStatus.COMPLETED, completedAt: new Date() },
|
||||
});
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
// Verify title was NOT changed.
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.title).not.toBe('Should Not Save');
|
||||
});
|
||||
|
||||
test('should reject order update on a COMPLETED envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: { status: DocumentStatus.COMPLETED, completedAt: new Date() },
|
||||
});
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// REJECTED envelope — all edits blocked
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should reject title update on a REJECTED envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
// Transition to REJECTED directly via database.
|
||||
await prisma.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: { status: DocumentStatus.REJECTED },
|
||||
});
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
// Verify title was NOT changed.
|
||||
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
|
||||
where: { id: envelopeItem.id },
|
||||
});
|
||||
|
||||
expect(dbItem.title).not.toBe('Should Not Save');
|
||||
});
|
||||
|
||||
test('should reject order update on a REJECTED envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: { status: DocumentStatus.REJECTED },
|
||||
});
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Deleted envelope — all edits blocked
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should reject title update on a deleted envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
await distributeEnvelope(request, token, envelope.id);
|
||||
|
||||
// Soft-delete the envelope.
|
||||
await prisma.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should reject an empty title on any envelope', async ({ request }) => {
|
||||
const envelope = await createEnvelope(request, token);
|
||||
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||
const envelopeItem = envelopeData.envelopeItems[0];
|
||||
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelope.id,
|
||||
data: [{ envelopeItemId: envelopeItem.id, title: '' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should reject update for an envelope item that does not belong to the envelope', async ({ request }) => {
|
||||
const envelopeA = await createEnvelope(request, token);
|
||||
const envelopeB = await createEnvelope(request, token);
|
||||
|
||||
const envelopeBData = await getEnvelope(request, token, envelopeB.id);
|
||||
const envelopeBItem = envelopeBData.envelopeItems[0];
|
||||
|
||||
// Try to update envelopeB's item via envelopeA's ID.
|
||||
const res = await updateEnvelopeItems(request, token, {
|
||||
envelopeId: envelopeA.id,
|
||||
data: [{ envelopeItemId: envelopeBItem.id, title: 'Cross Envelope' }],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,16 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement');
|
||||
|
||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
FIXTURES_DIR,
|
||||
'project-proposal-single-recipient.pdf',
|
||||
);
|
||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(FIXTURES_DIR, 'project-proposal-single-recipient.pdf');
|
||||
|
||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
FIXTURES_DIR,
|
||||
'project-proposal-multiple-fields-and-recipients.pdf',
|
||||
);
|
||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(FIXTURES_DIR, 'project-proposal-multiple-fields-and-recipients.pdf');
|
||||
|
||||
const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf');
|
||||
|
||||
@@ -89,9 +82,7 @@ const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) =>
|
||||
};
|
||||
|
||||
test.describe('PDF Placeholders with single recipient', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await setTeamDefaultRecipients(team.id, [
|
||||
@@ -115,9 +106,7 @@ test.describe('PDF Placeholders with single recipient', () => {
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const placeholderRecipient = recipients.find(
|
||||
(recipient) => recipient.email === 'recipient.1@documenso.com',
|
||||
);
|
||||
const placeholderRecipient = recipients.find((recipient) => recipient.email === 'recipient.1@documenso.com');
|
||||
|
||||
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@@ -133,23 +122,17 @@ test.describe('PDF Placeholders with single recipient', () => {
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor shows recipients on the upload page under "Recipients" heading.
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue('recipient.1@documenso.com');
|
||||
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
@@ -164,9 +147,7 @@ test.describe('PDF Placeholders with single recipient', () => {
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
@@ -187,26 +168,18 @@ test.describe('PDF Placeholders with single recipient', () => {
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with multiple recipients', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor shows recipients on the upload page.
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue('recipient.1@documenso.com');
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
||||
'recipient.2@documenso.com',
|
||||
);
|
||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue('recipient.2@documenso.com');
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
||||
'recipient.3@documenso.com',
|
||||
);
|
||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue('recipient.3@documenso.com');
|
||||
|
||||
// Verify recipients via the database for name validation since the v2 editor
|
||||
// only shows the "Name" label on the first recipient row.
|
||||
@@ -223,9 +196,7 @@ test.describe('PDF Placeholders with multiple recipients', () => {
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
@@ -244,9 +215,7 @@ test.describe('PDF Placeholders with multiple recipients', () => {
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders without recipient identifier', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH);
|
||||
|
||||
@@ -277,9 +246,7 @@ test.describe('PDF Placeholders without recipient identifier', () => {
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with invalid field types', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -44,7 +43,7 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.email,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { seedPendingDocumentNoFields, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
createDocumentAuthOptions,
|
||||
createRecipientAuthOptions,
|
||||
} from '@documenso/lib/utils/document-auth';
|
||||
import {
|
||||
seedPendingDocumentNoFields,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
@@ -106,9 +99,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
|
||||
});
|
||||
|
||||
// Currently document auth for signing/approving/viewing is not required.
|
||||
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { user: recipientWithAccount } = await seedUser();
|
||||
@@ -133,14 +124,10 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
'Reauthentication is required to sign the document',
|
||||
);
|
||||
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign the document');
|
||||
});
|
||||
|
||||
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { user: recipientWithAccount } = await seedUser();
|
||||
@@ -170,17 +157,13 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
|
||||
}
|
||||
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
'Reauthentication is required to sign this field',
|
||||
);
|
||||
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign this field');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { user: recipientWithInheritAuth } = await seedUser();
|
||||
@@ -190,11 +173,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [
|
||||
recipientWithInheritAuth,
|
||||
recipientWithExplicitNoneAuth,
|
||||
recipientWithExplicitAccountAuth,
|
||||
],
|
||||
recipients: [recipientWithInheritAuth, recipientWithExplicitNoneAuth, recipientWithExplicitAccountAuth],
|
||||
recipientsCreateOptions: [
|
||||
{
|
||||
authOptions: createRecipientAuthOptions({
|
||||
@@ -237,9 +216,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
}
|
||||
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
'Reauthentication is required to sign this field',
|
||||
);
|
||||
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign this field');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
}
|
||||
|
||||
@@ -278,9 +255,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { user: recipientWithInheritAuth } = await seedUser();
|
||||
@@ -290,11 +265,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [
|
||||
recipientWithInheritAuth,
|
||||
recipientWithExplicitNoneAuth,
|
||||
recipientWithExplicitAccountAuth,
|
||||
],
|
||||
recipients: [recipientWithInheritAuth, recipientWithExplicitNoneAuth, recipientWithExplicitAccountAuth],
|
||||
recipientsCreateOptions: [
|
||||
{
|
||||
authOptions: createRecipientAuthOptions({
|
||||
@@ -343,9 +314,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
||||
}
|
||||
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
'Reauthentication is required to sign this field',
|
||||
);
|
||||
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign this field');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
type SeededEnvelopes = {
|
||||
assistantToken: string;
|
||||
otherEnvelopeFieldId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds two unrelated pending envelopes:
|
||||
* - Envelope A has an ASSISTANT (with a token) plus a SIGNER.
|
||||
* - Envelope B is owned by a different user and has a SIGNER with a TEXT field.
|
||||
*
|
||||
* Returns the assistant's token from envelope A and the TEXT field id from
|
||||
* envelope B so callers can exercise signing routes across envelopes.
|
||||
*/
|
||||
const seedTwoPendingEnvelopes = async (request: APIRequestContext): Promise<SeededEnvelopes> => {
|
||||
const envelopeA = await apiSeedPendingDocument(request, {
|
||||
title: '[TEST] Envelope A',
|
||||
recipients: [
|
||||
{
|
||||
email: `assistant-${Date.now()}@documenso.com`,
|
||||
name: 'Assistant',
|
||||
role: 'ASSISTANT',
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: `signer-a-${Date.now()}@documenso.com`,
|
||||
name: 'Signer A',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
fieldsPerRecipient: [
|
||||
[],
|
||||
// SIGNER needs a SIGNATURE field so distribution succeeds.
|
||||
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
|
||||
],
|
||||
});
|
||||
|
||||
const assistant = envelopeA.distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
|
||||
|
||||
if (!assistant) {
|
||||
throw new Error('Assistant recipient not found in envelope A');
|
||||
}
|
||||
|
||||
const envelopeB = await apiSeedPendingDocument(request, {
|
||||
title: '[TEST] Envelope B',
|
||||
recipients: [
|
||||
{
|
||||
email: `signer-b-${Date.now()}@documenso.com`,
|
||||
name: 'Signer B',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
// A TEXT field is used as the cross-envelope target. The V2 route has a
|
||||
// separate guard that blocks assistants from signing SIGNATURE fields,
|
||||
// which would mask whether the recipient lookup itself was scoped.
|
||||
fieldsPerRecipient: [
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const otherEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeB.envelope.id },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
const textField = otherEnvelope.fields.find((f) => f.type === FieldType.TEXT);
|
||||
|
||||
if (!textField) {
|
||||
throw new Error('TEXT field not found in envelope B');
|
||||
}
|
||||
|
||||
return {
|
||||
assistantToken: assistant.token,
|
||||
otherEnvelopeFieldId: textField.id,
|
||||
};
|
||||
};
|
||||
|
||||
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
|
||||
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('[ASSISTANT_SIGNING_AUTH]: cross-envelope field access', () => {
|
||||
test('envelope.field.sign (V2) rejects fieldId from another envelope', async ({ request }) => {
|
||||
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
|
||||
|
||||
const res = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: assistantToken,
|
||||
fieldId: otherEnvelopeFieldId,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: otherEnvelopeFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe('');
|
||||
});
|
||||
|
||||
test('field.signFieldWithToken (V1) rejects fieldId from another envelope', async ({ request }) => {
|
||||
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: otherEnvelopeFieldId,
|
||||
value: 'TEXT',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: otherEnvelopeFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe('');
|
||||
});
|
||||
|
||||
test('field.removeSignedFieldWithToken (V1) rejects fieldId from another envelope', async ({ request }) => {
|
||||
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
|
||||
|
||||
// Pre-insert the field so a successful (incorrect) uninsert is detectable.
|
||||
await prisma.field.update({
|
||||
where: { id: otherEnvelopeFieldId },
|
||||
data: { inserted: true, customText: 'pre-existing-value' },
|
||||
});
|
||||
|
||||
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: otherEnvelopeFieldId,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: otherEnvelopeFieldId },
|
||||
include: { recipient: true },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('pre-existing-value');
|
||||
expect(fieldAfter.recipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
@@ -157,9 +148,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', a
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
// Verify next recipient UI is not shown
|
||||
await expect(
|
||||
page.getByText('The next recipient to sign this document will be'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText('The next recipient to sign this document will be')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||
|
||||
// Submit and verify completion
|
||||
@@ -243,9 +232,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
// Verify next recipient UI is not shown in parallel flow
|
||||
await expect(
|
||||
page.getByText('The next recipient to sign this document will be'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText('The next recipient to sign this document will be')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||
|
||||
// Submit and verify completion
|
||||
@@ -274,9 +261,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: assistant } = await seedUser();
|
||||
const { user: signer } = await seedUser();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -54,7 +54,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -63,10 +63,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -74,7 +71,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -100,7 +97,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -108,7 +105,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -117,10 +114,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -128,7 +122,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -162,7 +156,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -170,7 +164,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -179,10 +173,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -190,7 +181,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -224,7 +215,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -232,7 +223,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -243,10 +234,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
|
||||
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -268,11 +256,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
textField.fieldMeta &&
|
||||
typeof textField.fieldMeta === 'object' &&
|
||||
'type' in textField.fieldMeta
|
||||
) {
|
||||
if (textField.fieldMeta && typeof textField.fieldMeta === 'object' && 'type' in textField.fieldMeta) {
|
||||
expect(textField.fieldMeta.type).toBe('text');
|
||||
expect(textField.fieldMeta.label).toBe('Test Field');
|
||||
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -167,15 +166,9 @@ test.describe('AutoSave Signers Step', () => {
|
||||
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
|
||||
expect(retrievedRecipients.length).toBe(3);
|
||||
|
||||
const firstRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient1@documenso.com',
|
||||
);
|
||||
const secondRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient2@documenso.com',
|
||||
);
|
||||
const thirdRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient3@documenso.com',
|
||||
);
|
||||
const firstRecipient = retrievedRecipients.find((r) => r.email === 'recipient1@documenso.com');
|
||||
const secondRecipient = retrievedRecipients.find((r) => r.email === 'recipient2@documenso.com');
|
||||
const thirdRecipient = retrievedRecipients.find((r) => r.email === 'recipient3@documenso.com');
|
||||
|
||||
expect(firstRecipient?.signingOrder).toBe(2);
|
||||
expect(secondRecipient?.signingOrder).toBe(3);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -28,7 +28,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -129,14 +129,10 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Email recipients when they're removed from a pending document"),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText("Email recipients when they're removed from a pending document")).toBeChecked({
|
||||
checked: emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Email recipients when the document is completed', { exact: true }),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Email recipients when the document is completed', { exact: true })).toBeChecked({
|
||||
checked: emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
|
||||
@@ -197,14 +193,10 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Email recipients when they're removed from a pending document"),
|
||||
).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('Email recipients when the document is completed', { 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('Email recipients when a pending document is deleted')).toBeChecked({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -33,14 +33,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
||||
|
||||
// Step 3: Add fields
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
// Switch to second duplicate and add field
|
||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Continue to send
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Document, Team } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import type { Document, Team } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
@@ -12,11 +11,7 @@ import { signSignaturePad } from '../fixtures/signature';
|
||||
/**
|
||||
* Test helper to complete the document creation flow with duplicate recipients
|
||||
*/
|
||||
const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
document: Document;
|
||||
}) => {
|
||||
const completeDocumentFlowWithDuplicateRecipients = async (options: { page: Page; team: Team; document: Document }) => {
|
||||
const { page, team, document } = options;
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
@@ -44,21 +39,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
// Step 3: Add fields for each recipient
|
||||
// Add signature field for first duplicate recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to second duplicate recipient and add their field
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to unique recipient and add their field
|
||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Continue to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -97,9 +92,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
||||
});
|
||||
|
||||
test('should allow adding duplicate recipient after saving document initially', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should allow adding duplicate recipient after saving document initially', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
@@ -122,7 +115,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Save the document by going to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -149,7 +142,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Complete the flow
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -162,10 +155,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should isolate fields per recipient token even with duplicate emails', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test('should isolate fields per recipient token even with duplicate emails', async ({ page, context }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
@@ -208,9 +198,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Verify only one signature field is visible for this recipient
|
||||
expect(
|
||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
||||
).toHaveLength(1);
|
||||
expect(await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all()).toHaveLength(1);
|
||||
|
||||
// Verify recipient name is correct
|
||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
||||
@@ -218,10 +206,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
// Sign the document
|
||||
await signSignaturePad(page);
|
||||
|
||||
await page
|
||||
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
@@ -232,9 +217,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle duplicate recipient workflow with different field types', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should handle duplicate recipient workflow with different field types', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
@@ -270,24 +253,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add signature for first recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Add name field for second recipient
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Add date field for second recipient
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
|
||||
|
||||
// If second recipient is still a SIGNER (role change wasn't available),
|
||||
// add a signature field for them to pass validation
|
||||
if (!secondRecipientIsApprover) {
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
|
||||
}
|
||||
|
||||
// Complete the document
|
||||
@@ -300,9 +283,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should preserve field assignments when editing document with duplicates', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should preserve field assignments when editing document with duplicates', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
@@ -333,23 +314,19 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Verify their field is visible and can be selected
|
||||
const firstRecipientFields = await page
|
||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
||||
.all();
|
||||
const firstRecipientFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
||||
expect(firstRecipientFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Switch to second duplicate recipient
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Verify they have their own field
|
||||
const secondRecipientFields = await page
|
||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
||||
.all();
|
||||
const secondRecipientFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
||||
expect(secondRecipientFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Add another field to the second duplicate
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
@@ -92,7 +83,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -100,7 +91,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -120,9 +111,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
@@ -158,7 +147,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -166,7 +155,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -177,7 +166,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
@@ -185,7 +174,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
@@ -256,7 +245,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -264,7 +253,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -275,7 +264,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
@@ -283,7 +272,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
@@ -303,9 +292,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
@@ -336,9 +323,7 @@ test('[DOCUMENT_FLOW]: should not be able to create a document without signature
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByText('No signature field found').first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('dialog').getByText('No signature field found').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
|
||||
@@ -381,12 +366,8 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' }).click();
|
||||
await page.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
}
|
||||
});
|
||||
@@ -459,10 +440,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('dialog')
|
||||
.getByText('You are about to complete approving the following document')
|
||||
.first(),
|
||||
page.getByRole('dialog').getByText('You are about to complete approving the following document').first(),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
@@ -576,7 +554,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100 * i,
|
||||
@@ -605,9 +583,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
expect(createdDocument?.recipients.length).toBe(3);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const recipient = createdDocument?.recipients.find(
|
||||
(r) => r.email === `user${i + 1}@example.com`,
|
||||
);
|
||||
const recipient = createdDocument?.recipients.find((r) => r.email === `user${i + 1}@example.com`);
|
||||
expect(recipient).not.toBeNull();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
@@ -652,9 +628,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
seedCompletedDocument,
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
@@ -72,9 +67,7 @@ test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({ page }) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
@@ -116,9 +109,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({ page }) => {
|
||||
const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
@@ -223,9 +214,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
@@ -273,9 +262,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
const recipientA = recipients[0];
|
||||
const recipientB = recipients[1];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankDocument, seedCompletedDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
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 { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
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 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 { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getRecipientEmailInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
} 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 PENDING envelope via the V2 API with a single SIGNER recipient
|
||||
* and a SIGNATURE field, then distribute it.
|
||||
*
|
||||
* Returns the envelope ID, recipient email, team URL, and user info for navigation.
|
||||
*/
|
||||
const createPendingEnvelopeViaApi = async () => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'e2e-resend-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const recipientEmail = `resend-${Date.now()}@test.documenso.com`;
|
||||
|
||||
// 1. Create envelope with a PDF.
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: `E2E Resend Test ${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: 'Resend 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();
|
||||
|
||||
// 5. Distribute the envelope.
|
||||
const distributeRes = await fetch(`${V2_API_BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: createResponse.id,
|
||||
} satisfies TDistributeEnvelopeRequest),
|
||||
});
|
||||
|
||||
expect(distributeRes.ok).toBeTruthy();
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
envelopeId: createResponse.id,
|
||||
recipientEmail,
|
||||
};
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('send document via email', 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.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
|
||||
await expect(surface.root.getByText('1 Field')).toBeVisible();
|
||||
|
||||
// Navigate back to the recipients step so the sidebar actions are available.
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
|
||||
// Click the "Send Document" sidebar action.
|
||||
await page.locator('button[title="Send Envelope"]').click();
|
||||
|
||||
// The distribute dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
|
||||
|
||||
// Click Send.
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Envelope distributed');
|
||||
|
||||
// Assert the document status was changed in the database.
|
||||
const updatedEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: surface.envelopeId },
|
||||
});
|
||||
|
||||
expect(updatedEnvelope.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('send document shows validation when signers lack signature fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Add the current user as a SIGNER recipient via the UI — but do NOT place any fields.
|
||||
await clickAddMyselfButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||
|
||||
// Click the "Send Document" sidebar action without placing signature fields.
|
||||
await page.locator('button[title="Send Envelope"]').click();
|
||||
|
||||
// The distribute dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
|
||||
|
||||
// The validation warning should be shown instead of the send form.
|
||||
await expect(page.getByText('The following signers are missing signature fields')).toBeVisible();
|
||||
|
||||
// The "Send" button should not be visible since we're in validation mode.
|
||||
await expect(page.getByRole('button', { name: 'Send', exact: true })).not.toBeVisible();
|
||||
|
||||
// Close the dialog.
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// The dialog should be closed.
|
||||
await expect(page.getByText('The following signers are missing signature fields')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('resend document sends reminder', async ({ page }) => {
|
||||
const { user, team, envelopeId, recipientEmail } = await createPendingEnvelopeViaApi();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Click the "Resend Document" sidebar action.
|
||||
await page.locator('button[title="Resend Envelope"]').click();
|
||||
|
||||
// The redistribute dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Resend Document' })).toBeVisible();
|
||||
|
||||
// The unsigned recipient should be listed.
|
||||
await expect(page.getByText(recipientEmail)).toBeVisible();
|
||||
|
||||
// Select the recipient checkbox.
|
||||
await page.getByRole('checkbox').first().click();
|
||||
|
||||
// Click "Send reminder".
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Envelope resent');
|
||||
|
||||
// Verify a resend audit log entry was created in the database.
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId,
|
||||
type: 'EMAIL_SENT',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
expect((auditLog!.data as Record<string, unknown>).isResending).toBe(true);
|
||||
expect((auditLog!.data as Record<string, unknown>).recipientEmail).toBe(recipientEmail);
|
||||
});
|
||||
|
||||
test('duplicate document', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Click the "Duplicate Document" sidebar action.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
|
||||
// The duplicate dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Click "Duplicate".
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
|
||||
// The page should have navigated to the new document's edit page.
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// Verify a new envelope was created in the database.
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
// Should have original + duplicate.
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('download PDF dialog shows envelope items', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Click the "Download PDF" sidebar action.
|
||||
await page.locator('button[title="Download PDF"]').click();
|
||||
|
||||
// The download dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Download Files' })).toBeVisible();
|
||||
await expect(page.getByText('Select the files you would like to download.')).toBeVisible();
|
||||
|
||||
// At least one envelope item with an "Original" download button should be listed.
|
||||
await expect(page.getByRole('button', { name: 'Original' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('delete draft document', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const documentId = surface.envelopeId;
|
||||
|
||||
// Click the "Delete Document" sidebar action.
|
||||
await page.locator('button[title="Delete Envelope"]').click();
|
||||
|
||||
// The delete dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
// For DRAFT documents no confirmation input is needed, just click "Delete".
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Document deleted');
|
||||
|
||||
// The page should navigate back to the documents list.
|
||||
await expect(page).toHaveURL(/\/documents$/);
|
||||
|
||||
// Verify the document was deleted from the database.
|
||||
const deletedDocument = await prisma.envelope.findUnique({
|
||||
where: { id: documentId },
|
||||
});
|
||||
|
||||
expect(deletedDocument).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('create and manage direct link', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// seedTemplate creates a template with a SIGNER recipient.
|
||||
const template = await seedTemplate({
|
||||
title: `E2E Direct Link Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Click the "Direct Link" sidebar action.
|
||||
await page.locator('button[title="Direct Link"]').click();
|
||||
|
||||
// The onboarding dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Create Direct Signing Link' })).toBeVisible();
|
||||
|
||||
// Click "Enable direct link signing" to proceed.
|
||||
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
|
||||
|
||||
// Select recipient step: click "Create one automatically".
|
||||
await page.getByRole('button', { name: 'Create one automatically' }).click();
|
||||
|
||||
// Manage step should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
|
||||
|
||||
// The shareable link input should be present.
|
||||
await expect(page.locator('#copy-direct-link')).toBeVisible();
|
||||
|
||||
// Click "Save" to persist the toggle state.
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Assert success toast.
|
||||
await expectToastTextToBeVisible(page, 'Success');
|
||||
|
||||
// Verify a TemplateDirectLink was created and enabled in the database.
|
||||
const directLink = await prisma.templateDirectLink.findFirst({
|
||||
where: { envelopeId: template.id },
|
||||
});
|
||||
|
||||
expect(directLink).not.toBeNull();
|
||||
expect(directLink!.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('duplicate template', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
// Click the "Duplicate Template" sidebar action.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
|
||||
// The duplicate dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Template' })).toBeVisible();
|
||||
|
||||
// Click "Duplicate".
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Template Duplicated');
|
||||
|
||||
// The page should have navigated to the new template's edit page.
|
||||
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
|
||||
|
||||
// Verify a new envelope was created in the database.
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: 'TEMPLATE',
|
||||
},
|
||||
});
|
||||
|
||||
// Should have original + duplicate.
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
addEnvelopeItemPdf,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const TEST_ATTACHMENTS = {
|
||||
first: {
|
||||
label: 'E2E First Attachment',
|
||||
url: 'https://example.com/first-attachment',
|
||||
},
|
||||
second: {
|
||||
label: 'E2E Second Attachment',
|
||||
url: 'https://example.com/second-attachment',
|
||||
},
|
||||
third: {
|
||||
label: 'E2E Third Attachment',
|
||||
url: 'https://example.com/third-attachment',
|
||||
},
|
||||
};
|
||||
|
||||
type AttachmentFlowResult = {
|
||||
externalId: string;
|
||||
expectedAttachments: Array<{ label: string; url: string }>;
|
||||
deletedAttachment: { label: string; url: string };
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachmentsPopover = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Attachments' }).click();
|
||||
|
||||
await expect(root.getByRole('heading', { name: 'Attachments', level: 4 })).toBeVisible();
|
||||
};
|
||||
|
||||
const addAttachment = async (root: Page, { label, url }: { label: string; url: string }) => {
|
||||
await root.getByRole('button', { name: 'Add Attachment' }).click();
|
||||
await root.getByPlaceholder('Label').fill(label);
|
||||
await root.getByPlaceholder('URL').fill(url);
|
||||
await root.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
};
|
||||
|
||||
const getAttachmentItems = (root: Page) => root.locator('.rounded-md.border.border-border.p-2');
|
||||
|
||||
const getAttachmentDeleteButtons = (root: Page) => getAttachmentItems(root).locator('button');
|
||||
|
||||
const assertAttachmentVisibleInPopover = async (root: Page, { label, url }: { label: string; url: string }) => {
|
||||
const items = getAttachmentItems(root);
|
||||
const matchingItem = items.filter({ hasText: label });
|
||||
|
||||
await expect(matchingItem).toBeVisible();
|
||||
await expect(matchingItem.locator(`a[href="${url}"]`)).toBeVisible();
|
||||
};
|
||||
|
||||
const assertAttachmentNotVisibleInPopover = async (root: Page, label: string) => {
|
||||
await expect(getAttachmentItems(root).filter({ hasText: label })).toHaveCount(0);
|
||||
};
|
||||
|
||||
const assertAttachmentCount = async (root: Page, count: number) => {
|
||||
const button = root.getByRole('button', { name: 'Attachments' });
|
||||
|
||||
if (count > 0) {
|
||||
await expect(button).toContainText(`(${count})`);
|
||||
} else {
|
||||
await expect(button).not.toContainText('(');
|
||||
}
|
||||
};
|
||||
|
||||
const assertAttachmentsInDatabase = async ({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments,
|
||||
deletedLabel,
|
||||
}: {
|
||||
externalId: string;
|
||||
surface: TEnvelopeEditorSurface;
|
||||
expectedAttachments: Array<{ label: string; url: string }>;
|
||||
deletedLabel?: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeAttachments: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.envelopeAttachments).toHaveLength(expectedAttachments.length);
|
||||
|
||||
for (const expected of expectedAttachments) {
|
||||
const found = envelope.envelopeAttachments.find((a) => a.label === expected.label);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.data).toBe(expected.url);
|
||||
expect(found!.type).toBe('link');
|
||||
}
|
||||
|
||||
if (deletedLabel) {
|
||||
expect(envelope.envelopeAttachments.some((a) => a.label === deletedLabel)).toBe(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runAttachmentFlow = async (surface: TEnvelopeEditorSurface): Promise<AttachmentFlowResult> => {
|
||||
const externalId = `e2e-attachments-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await openAttachmentsPopover(surface.root);
|
||||
|
||||
// Create first attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
|
||||
await assertAttachmentCount(surface.root, 1);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.first],
|
||||
});
|
||||
|
||||
// Create second attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second],
|
||||
});
|
||||
|
||||
// Create third attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 3);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
});
|
||||
|
||||
// Delete first attachment.
|
||||
await getAttachmentDeleteButtons(surface.root).first().click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
|
||||
|
||||
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
|
||||
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
deletedLabel: TEST_ATTACHMENTS.first.label,
|
||||
});
|
||||
|
||||
return {
|
||||
externalId,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
deletedAttachment: TEST_ATTACHMENTS.first,
|
||||
};
|
||||
};
|
||||
|
||||
const runEmbeddedAttachmentFlow = async (surface: TEnvelopeEditorSurface): Promise<AttachmentFlowResult> => {
|
||||
const externalId = `e2e-attachments-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await openAttachmentsPopover(surface.root);
|
||||
|
||||
// Create first attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
|
||||
await assertAttachmentCount(surface.root, 1);
|
||||
|
||||
// Create second attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
// Create third attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 3);
|
||||
|
||||
// Delete first attachment.
|
||||
await getAttachmentDeleteButtons(surface.root).first().click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
|
||||
|
||||
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
|
||||
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
deletedAttachment: TEST_ATTACHMENTS.first,
|
||||
};
|
||||
};
|
||||
|
||||
const assertAttachmentsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
expectedAttachments,
|
||||
deletedAttachment,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
expectedAttachments: AttachmentFlowResult['expectedAttachments'];
|
||||
deletedAttachment: AttachmentFlowResult['deletedAttachment'];
|
||||
}) => {
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments,
|
||||
deletedLabel: deletedAttachment.label,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAttachmentFlow(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAttachmentFlow(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-attachments',
|
||||
});
|
||||
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-document-attachments.pdf');
|
||||
|
||||
const result = await runEmbeddedAttachmentFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-attachments',
|
||||
});
|
||||
|
||||
const result = await runEmbeddedAttachmentFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
addEnvelopeItemPdf,
|
||||
createEmbeddedEnvelopeCreateHash,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const TEST_CSS_VARS = {
|
||||
background: '#ff0000',
|
||||
primary: '#00ff00',
|
||||
radius: '1rem',
|
||||
};
|
||||
|
||||
/**
|
||||
* A unique CSS selector used for asserting raw CSS injection.
|
||||
*/
|
||||
const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
|
||||
|
||||
/**
|
||||
* Expected HSL values after conversion by `toNativeCssVars`:
|
||||
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
|
||||
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
|
||||
*
|
||||
* The `%` on saturation and lightness is required: theme.css consumes these
|
||||
* via `hsl(var(--token))`, and CSS Color 4 space-separated `hsl()` rejects
|
||||
* bare numbers there. See `apps/remix/app/utils/css-vars.ts`.
|
||||
*/
|
||||
const EXPECTED_CSS_VARS = {
|
||||
'--background': '0 100% 50%',
|
||||
'--primary': '120 100% 50%',
|
||||
'--radius': '1rem',
|
||||
};
|
||||
|
||||
const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: userId },
|
||||
include: { organisationClaim: true },
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
flags: {
|
||||
allowLegacyEnvelopes: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The default background color from the theme before any CSS injection.
|
||||
*
|
||||
* The theme default `--background: 0 0% 100%` resolves to hsl(0, 0%, 100%) which is white.
|
||||
*/
|
||||
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
|
||||
|
||||
/**
|
||||
* When `--background` is set to `0 100% 50%` (hsl(0, 100%, 50%)) the body background
|
||||
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
|
||||
*/
|
||||
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
|
||||
|
||||
const assertCssNotInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should not be set on the inline style.
|
||||
expect(cssState.background).toBe('');
|
||||
expect(cssState.primary).toBe('');
|
||||
expect(cssState.radius).toBe('');
|
||||
|
||||
// No raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(false);
|
||||
|
||||
// The body should still use the default theme background color.
|
||||
expect(cssState.bodyBgColor).toBe(DEFAULT_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertCssInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should be set to the expected HSL values.
|
||||
expect(cssState.background).toBe(EXPECTED_CSS_VARS['--background']);
|
||||
expect(cssState.primary).toBe(EXPECTED_CSS_VARS['--primary']);
|
||||
expect(cssState.radius).toBe(EXPECTED_CSS_VARS['--radius']);
|
||||
|
||||
// Raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(true);
|
||||
|
||||
// The body background should reflect the injected --background value (red).
|
||||
expect(cssState.bodyBgColor).toBe(INJECTED_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertDarkModeDisabled = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const hasDarkModeDisabled = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark-mode-disabled'),
|
||||
);
|
||||
|
||||
expect(hasDarkModeDisabled).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open an embedded create editor with a pre-seeded user. This is needed for folderId
|
||||
* tests where the folder must exist before the hash is built.
|
||||
*/
|
||||
const openEmbeddedCreateWithUser = async (
|
||||
page: Page,
|
||||
user: { id: number; email: string; name: string | null },
|
||||
team: { id: number },
|
||||
options: { folderId?: string; tokenNamePrefix?: string },
|
||||
): Promise<TEnvelopeEditorSurface> => {
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: `${options.tokenNamePrefix ?? 'e2e-embed-folder'}-document`,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// Exchange API token for presign token.
|
||||
const response = await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
if (typeof data !== 'object' || data === null || !('token' in data)) {
|
||||
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const presignToken = data.token;
|
||||
|
||||
if (typeof presignToken !== 'string' || presignToken.length === 0) {
|
||||
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const hash = createEmbeddedEnvelopeCreateHash({
|
||||
envelopeType: 'DOCUMENT',
|
||||
folderId: options.folderId,
|
||||
});
|
||||
|
||||
await page.goto(`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(presignToken)}#${hash}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: true,
|
||||
envelopeType: 'DOCUMENT',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to set an external ID on the envelope via the settings dialog so we can
|
||||
* look it up in the database after creation.
|
||||
*/
|
||||
const setExternalIdViaSettings = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await getEnvelopeEditorSettingsTrigger(surface.root).click();
|
||||
await expect(surface.root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal setup for an embedded create flow: upload a PDF and add a recipient
|
||||
* so the "Create Document" button works.
|
||||
*/
|
||||
const setupMinimalEnvelope = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await addEnvelopeItemPdf(surface.root);
|
||||
await setRecipientEmail(surface.root, 0, `${nanoid()}@test.documenso.com`);
|
||||
await setExternalIdViaSettings(surface, externalId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Click "Create Document" and expect a failure toast instead of the success heading.
|
||||
*/
|
||||
const expectCreateToFail = async (surface: TEnvelopeEditorSurface) => {
|
||||
const actionButtonName = surface.envelopeType === 'DOCUMENT' ? 'Create Document' : 'Create Template';
|
||||
|
||||
await surface.root.getByRole('button', { name: actionButtonName }).click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Failed to create document');
|
||||
};
|
||||
|
||||
test.describe('embedded create - folderId', () => {
|
||||
test('creates envelope in the specified folder when folderId is provided', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const folder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'E2E Document Folder',
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
const surface = await openEmbeddedCreateWithUser(page, user, team, {
|
||||
folderId: folder.id,
|
||||
tokenNamePrefix: 'e2e-embed-folder',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-create-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.folderId).toBe(folder.id);
|
||||
});
|
||||
|
||||
test('creates envelope in root folder when no folderId is provided', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-folder-none',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-root-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects creation when folderId has wrong folder type', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a TEMPLATE folder but attempt to create a DOCUMENT envelope in it.
|
||||
const templateFolder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'E2E Template Folder',
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
type: 'TEMPLATE',
|
||||
},
|
||||
});
|
||||
|
||||
const surface = await openEmbeddedCreateWithUser(page, user, team, {
|
||||
folderId: templateFolder.id,
|
||||
tokenNamePrefix: 'e2e-embed-folder-wrong-type',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-wrong-type-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await expectCreateToFail(surface);
|
||||
|
||||
// Verify no envelope was created with this externalId.
|
||||
const count = await prisma.envelope.count({ where: { externalId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects creation when folderId belongs to another team', async ({ page }) => {
|
||||
// Create the user who will use the embedded editor.
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a second user/team that owns the folder.
|
||||
const { user: otherUser, team: otherTeam } = await seedUser();
|
||||
|
||||
const otherTeamFolder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'E2E Other Team Folder',
|
||||
teamId: otherTeam.id,
|
||||
userId: otherUser.id,
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
// Open the embedded editor for the first user but pass the folder from the other team.
|
||||
const surface = await openEmbeddedCreateWithUser(page, user, team, {
|
||||
folderId: otherTeamFolder.id,
|
||||
tokenNamePrefix: 'e2e-embed-folder-no-perm',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-no-perm-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await expectCreateToFail(surface);
|
||||
|
||||
// Verify no envelope was created with this externalId.
|
||||
const count = await prisma.envelope.count({ where: { externalId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects creation when folderId does not exist', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-folder-nonexistent',
|
||||
folderId: 'nonexistent-folder-id',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-nonexistent-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await expectCreateToFail(surface);
|
||||
|
||||
// Verify no envelope was created with this externalId.
|
||||
const count = await prisma.envelope.count({ where: { externalId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,836 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import {
|
||||
addEnvelopeItemPdf,
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRemoveButtons,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
};
|
||||
|
||||
const TEST_FIELD_VALUES = {
|
||||
embeddedRecipient: {
|
||||
email: 'embedded-field-recipient@documenso.com',
|
||||
name: 'Embedded Field Recipient',
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (surface.isEmbedded) {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||
|
||||
return TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||
}
|
||||
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||
await clickAddMyselfButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||
|
||||
return surface.userEmail;
|
||||
};
|
||||
|
||||
type FieldButtonName =
|
||||
| 'Signature'
|
||||
| 'Email'
|
||||
| 'Name'
|
||||
| 'Initials'
|
||||
| 'Date'
|
||||
| 'Text'
|
||||
| 'Number'
|
||||
| 'Radio'
|
||||
| 'Checkbox'
|
||||
| 'Dropdown';
|
||||
|
||||
const placeFieldOnPdf = async (root: Page, fieldName: FieldButtonName, 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 });
|
||||
};
|
||||
|
||||
const selectRecipientInFieldsStep = async (root: Page, recipientIdentifier: string) => {
|
||||
await root.locator('button[role="combobox"]').click();
|
||||
await root.getByText(recipientIdentifier).click();
|
||||
};
|
||||
|
||||
const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||
await canvas.click({ position, force: true });
|
||||
};
|
||||
|
||||
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
|
||||
let fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
|
||||
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await surface.root.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
recipientEmail,
|
||||
};
|
||||
};
|
||||
|
||||
const getFieldMetaType = (fieldMeta: unknown) => {
|
||||
if (!isRecord(fieldMeta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const assertFieldsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
recipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = envelope.recipients.find((currentRecipient) => currentRecipient.email === recipientEmail);
|
||||
|
||||
expect(recipient).toBeDefined();
|
||||
|
||||
const fieldTypes = envelope.fields.map((field) => field.type).sort();
|
||||
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
|
||||
|
||||
expect(envelope.fields).toHaveLength(2);
|
||||
expect(fieldTypes).toEqual(expectedFieldTypes);
|
||||
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
|
||||
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
|
||||
|
||||
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
|
||||
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
|
||||
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
|
||||
};
|
||||
|
||||
// --- Multi-recipient field flow ---
|
||||
|
||||
type TMultiRecipientFlowResult = {
|
||||
externalId: string;
|
||||
firstRecipientEmail: string;
|
||||
secondRecipientEmail: string;
|
||||
};
|
||||
|
||||
const MULTI_RECIPIENT_VALUES = {
|
||||
secondSigner: {
|
||||
email: 'second-signer@test.documenso.com',
|
||||
name: 'Second Signer',
|
||||
},
|
||||
};
|
||||
|
||||
const runMultiRecipientFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TMultiRecipientFlowResult> => {
|
||||
const externalId = `e2e-multi-recip-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
// Add two recipients.
|
||||
let firstRecipientEmail: string;
|
||||
|
||||
if (surface.isEmbedded) {
|
||||
await setRecipientEmail(root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||
await setRecipientName(root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||
firstRecipientEmail = TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||
} else {
|
||||
await clickAddMyselfButton(root);
|
||||
firstRecipientEmail = surface.userEmail;
|
||||
}
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.email);
|
||||
await setRecipientName(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.name);
|
||||
|
||||
// Navigate to fields step.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(0);
|
||||
|
||||
// Place Signature for recipient #1 (auto-selected).
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
// Switch recipient and place text field for recipient #2.
|
||||
await selectRecipientInFieldsStep(root, MULTI_RECIPIENT_VALUES.secondSigner.email);
|
||||
await placeFieldOnPdf(root, 'Text', { x: 220, y: 240 });
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to ensure fields are persisted in the UI.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Phase 2: cascade deletion — go back to recipients and remove the second one.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(2);
|
||||
|
||||
await getRecipientRemoveButtons(root).nth(1).click();
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(1);
|
||||
|
||||
// Go back to fields and verify cascade removal.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
firstRecipientEmail,
|
||||
secondRecipientEmail: MULTI_RECIPIENT_VALUES.secondSigner.email,
|
||||
};
|
||||
};
|
||||
|
||||
const assertMultiRecipientCascadePersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
firstRecipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
firstRecipientEmail: string;
|
||||
secondRecipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true, recipients: true },
|
||||
});
|
||||
|
||||
// After cascade deletion, only one recipient and one field should remain.
|
||||
expect(envelope.recipients).toHaveLength(1);
|
||||
expect(envelope.recipients[0].email).toBe(firstRecipientEmail);
|
||||
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
expect(envelope.fields[0].recipientId).toBe(envelope.recipients[0].id);
|
||||
};
|
||||
|
||||
// --- All 10 field types flow ---
|
||||
|
||||
type TAllFieldTypesFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const runAllFieldTypesFlow = async (surface: TEnvelopeEditorSurface): Promise<TAllFieldTypesFlowResult> => {
|
||||
const externalId = `e2e-all-fields-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place and configure each field type immediately after placement.
|
||||
// After placeFieldOnPdf, the sidebar shows the field's config form (field is selected in React state).
|
||||
|
||||
// 1. Signature: place and set fontSize to 24.
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 50 });
|
||||
await root.locator('[data-testid="field-form-fontSize"]').fill('24');
|
||||
|
||||
// 2. Email: place and set textAlign to center.
|
||||
await placeFieldOnPdf(root, 'Email', { x: 120, y: 100 });
|
||||
await root.locator('[data-testid="field-form-textAlign"]').click();
|
||||
await root.getByRole('option', { name: 'Center' }).click();
|
||||
|
||||
// 3. Name: place and set textAlign to right.
|
||||
await placeFieldOnPdf(root, 'Name', { x: 120, y: 150 });
|
||||
await root.locator('[data-testid="field-form-textAlign"]').click();
|
||||
await root.getByRole('option', { name: 'Right' }).click();
|
||||
|
||||
// 4. Initials: place and set fontSize to 16.
|
||||
await placeFieldOnPdf(root, 'Initials', { x: 120, y: 200 });
|
||||
await root.locator('[data-testid="field-form-fontSize"]').fill('16');
|
||||
|
||||
// 5. Date: place and set textAlign to center.
|
||||
await placeFieldOnPdf(root, 'Date', { x: 120, y: 250 });
|
||||
await root.locator('[data-testid="field-form-textAlign"]').click();
|
||||
await root.getByRole('option', { name: 'Center' }).click();
|
||||
|
||||
// 6. Text: place and configure label, placeholder, text, characterLimit, required.
|
||||
await placeFieldOnPdf(root, 'Text', { x: 120, y: 300 });
|
||||
await root.locator('[data-testid="field-form-label"]').fill('Test Label');
|
||||
await root.locator('[data-testid="field-form-placeholder"]').fill('Enter text here');
|
||||
await root.locator('[data-testid="field-form-text"]').fill('Default text value');
|
||||
await root.locator('[data-testid="field-form-characterLimit"]').fill('100');
|
||||
await root.locator('[data-testid="field-form-required"]').click();
|
||||
|
||||
// 7. Number: place and configure label, placeholder, numberFormat, minValue, maxValue, required.
|
||||
await placeFieldOnPdf(root, 'Number', { x: 120, y: 350 });
|
||||
await root.locator('[data-testid="field-form-label"]').fill('Amount');
|
||||
await root.locator('[data-testid="field-form-placeholder"]').fill('0.00');
|
||||
await root.locator('[data-testid="field-form-numberFormat"]').click();
|
||||
await root.getByRole('option', { name: '123,456,789.00' }).click();
|
||||
await root.locator('[data-testid="field-form-minValue"]').fill('0');
|
||||
await root.locator('[data-testid="field-form-maxValue"]').fill('1000');
|
||||
await root.locator('[data-testid="field-form-required"]').click();
|
||||
|
||||
// 8. Radio: place and configure two options, pre-select first, set direction to horizontal.
|
||||
await placeFieldOnPdf(root, 'Radio', { x: 120, y: 400 });
|
||||
|
||||
// The first option already exists with default value "Default value". Fill it.
|
||||
await root.locator('[data-testid="field-form-values-0-value"]').fill('Option A');
|
||||
|
||||
// Add a second option.
|
||||
await root.locator('[data-testid="field-form-values-add"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').fill('Option B');
|
||||
|
||||
// Pre-select the first option (click its checkbox).
|
||||
await root.locator('[data-testid="field-form-values-0-checked"]').click();
|
||||
|
||||
// Set direction to horizontal.
|
||||
await root.locator('[data-testid="field-form-direction"]').click();
|
||||
await root.getByRole('option', { name: 'Horizontal' }).click();
|
||||
|
||||
// 9. Checkbox: place and configure two options, check both, set validation rule.
|
||||
await placeFieldOnPdf(root, 'Checkbox', { x: 120, y: 450 });
|
||||
|
||||
// Fill first option value.
|
||||
await root.locator('[data-testid="field-form-values-0-value"]').fill('Check A');
|
||||
|
||||
// Add a second option.
|
||||
await root.locator('[data-testid="field-form-values-add"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').fill('Check B');
|
||||
|
||||
// Check both options (click their checkboxes).
|
||||
await root.locator('[data-testid="field-form-values-0-checked"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-checked"]').click();
|
||||
|
||||
// Set validation: "Select at least" 1.
|
||||
await root.locator('[data-testid="field-form-validationRule"]').click();
|
||||
await root.getByRole('option', { name: 'Select at least' }).click();
|
||||
|
||||
// Set validation length to 1.
|
||||
await root.locator('[data-testid="field-form-validationLength"]').click();
|
||||
await root.getByRole('option', { name: '1', exact: true }).click();
|
||||
|
||||
// 10. Dropdown: place and configure two options, set default value.
|
||||
await placeFieldOnPdf(root, 'Dropdown', { x: 120, y: 500 });
|
||||
|
||||
// First option already has "Option 1". Change it to "Red".
|
||||
await root.locator('[data-testid="field-form-values-0-value"]').fill('Red');
|
||||
|
||||
// Add a second option.
|
||||
await root.locator('[data-testid="field-form-values-add"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').clear();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').fill('Blue');
|
||||
|
||||
// Set default value to "Red".
|
||||
await root.locator('[data-testid="field-form-defaultValue"]').click();
|
||||
await root.getByRole('option', { name: 'Red' }).click();
|
||||
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(10);
|
||||
|
||||
// Wait briefly for auto-save to fire on the last configured field.
|
||||
await root.waitForTimeout(500);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(10);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertAllFieldTypesPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(envelope.fields).toHaveLength(10);
|
||||
|
||||
const fieldsByType = new Map(envelope.fields.map((f) => [f.type, f]));
|
||||
|
||||
// Helper to safely access fieldMeta as a record.
|
||||
const meta = (type: FieldType): Record<string, unknown> => {
|
||||
const field = fieldsByType.get(type);
|
||||
expect(field).toBeDefined();
|
||||
const fieldMeta = field!.fieldMeta;
|
||||
expect(typeof fieldMeta).toBe('object');
|
||||
expect(fieldMeta).not.toBeNull();
|
||||
return fieldMeta as Record<string, unknown>;
|
||||
};
|
||||
|
||||
// SIGNATURE
|
||||
expect(meta(FieldType.SIGNATURE).type).toBe('signature');
|
||||
expect(meta(FieldType.SIGNATURE).fontSize).toBe(24);
|
||||
|
||||
// EMAIL
|
||||
expect(meta(FieldType.EMAIL).type).toBe('email');
|
||||
expect(meta(FieldType.EMAIL).textAlign).toBe('center');
|
||||
|
||||
// NAME
|
||||
expect(meta(FieldType.NAME).type).toBe('name');
|
||||
expect(meta(FieldType.NAME).textAlign).toBe('right');
|
||||
|
||||
// INITIALS
|
||||
expect(meta(FieldType.INITIALS).type).toBe('initials');
|
||||
expect(meta(FieldType.INITIALS).fontSize).toBe(16);
|
||||
|
||||
// DATE
|
||||
expect(meta(FieldType.DATE).type).toBe('date');
|
||||
expect(meta(FieldType.DATE).textAlign).toBe('center');
|
||||
|
||||
// TEXT
|
||||
expect(meta(FieldType.TEXT).type).toBe('text');
|
||||
expect(meta(FieldType.TEXT).label).toBe('Test Label');
|
||||
expect(meta(FieldType.TEXT).placeholder).toBe('Enter text here');
|
||||
expect(meta(FieldType.TEXT).text).toBe('Default text value');
|
||||
expect(meta(FieldType.TEXT).characterLimit).toBe(100);
|
||||
expect(meta(FieldType.TEXT).required).toBe(true);
|
||||
|
||||
// NUMBER
|
||||
expect(meta(FieldType.NUMBER).type).toBe('number');
|
||||
expect(meta(FieldType.NUMBER).label).toBe('Amount');
|
||||
expect(meta(FieldType.NUMBER).placeholder).toBe('0.00');
|
||||
expect(meta(FieldType.NUMBER).numberFormat).toBe('123,456,789.00');
|
||||
expect(meta(FieldType.NUMBER).minValue).toBe(0);
|
||||
expect(meta(FieldType.NUMBER).maxValue).toBe(1000);
|
||||
expect(meta(FieldType.NUMBER).required).toBe(true);
|
||||
|
||||
// RADIO
|
||||
expect(meta(FieldType.RADIO).type).toBe('radio');
|
||||
expect(meta(FieldType.RADIO).direction).toBe('horizontal');
|
||||
const radioValues = meta(FieldType.RADIO).values as Array<{
|
||||
value: string;
|
||||
checked: boolean;
|
||||
}>;
|
||||
expect(radioValues).toHaveLength(2);
|
||||
expect(radioValues[0].value).toBe('Option A');
|
||||
expect(radioValues[0].checked).toBe(true);
|
||||
expect(radioValues[1].value).toBe('Option B');
|
||||
expect(radioValues[1].checked).toBe(false);
|
||||
|
||||
// CHECKBOX
|
||||
expect(meta(FieldType.CHECKBOX).type).toBe('checkbox');
|
||||
expect(meta(FieldType.CHECKBOX).validationRule).toBe('Select at least');
|
||||
expect(meta(FieldType.CHECKBOX).validationLength).toBe(1);
|
||||
const checkboxValues = meta(FieldType.CHECKBOX).values as Array<{
|
||||
value: string;
|
||||
checked: boolean;
|
||||
}>;
|
||||
expect(checkboxValues).toHaveLength(2);
|
||||
expect(checkboxValues[0].value).toBe('Check A');
|
||||
expect(checkboxValues[0].checked).toBe(true);
|
||||
expect(checkboxValues[1].value).toBe('Check B');
|
||||
expect(checkboxValues[1].checked).toBe(true);
|
||||
|
||||
// DROPDOWN
|
||||
expect(meta(FieldType.DROPDOWN).type).toBe('dropdown');
|
||||
expect(meta(FieldType.DROPDOWN).defaultValue).toBe('Red');
|
||||
const dropdownValues = meta(FieldType.DROPDOWN).values as Array<{ value: string }>;
|
||||
expect(dropdownValues).toHaveLength(2);
|
||||
expect(dropdownValues[0].value).toBe('Red');
|
||||
expect(dropdownValues[1].value).toBe('Blue');
|
||||
};
|
||||
|
||||
// --- Duplicate and delete fields flow ---
|
||||
|
||||
type TDuplicateDeleteFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const runDuplicateDeleteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TDuplicateDeleteFlowResult> => {
|
||||
const externalId = `e2e-dup-del-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place a Signature field.
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
// Select the field on canvas to show the action toolbar.
|
||||
await selectFieldOnCanvas(root, { x: 150, y: 150 });
|
||||
await expect(root.locator('button[title="Duplicate"]')).toBeVisible();
|
||||
|
||||
// Duplicate the field.
|
||||
await root.locator('button[title="Duplicate"]').click();
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to persist changes.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Select a field and delete it via the Remove button.
|
||||
await selectFieldOnCanvas(root, { x: 150, y: 150 });
|
||||
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||
await root.locator('button[title="Remove"]').click();
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// After duplicating (2 fields) then deleting one, exactly 1 SIGNATURE field should remain.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-fields',
|
||||
});
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-multi-recip',
|
||||
});
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-dup-del',
|
||||
});
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-all-fields',
|
||||
});
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-fields',
|
||||
});
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-multi-recip',
|
||||
});
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-dup-del',
|
||||
});
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-all-fields',
|
||||
});
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
addEnvelopeItemPdf,
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.use({
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
|
||||
|
||||
// --- Shared helpers ---
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const getEditButton = (root: Page, index: number) =>
|
||||
root.locator('[data-testid^="envelope-item-edit-button-"]').nth(index);
|
||||
|
||||
const getEditDialog = (root: Page) => root.getByRole('dialog');
|
||||
|
||||
const getEditDialogTitleInput = (root: Page) => root.locator('[data-testid="envelope-item-edit-title-input"]');
|
||||
|
||||
const getEditDialogDropzone = (root: Page) => root.locator('[data-testid="envelope-item-edit-dropzone"]');
|
||||
|
||||
const getEditDialogSelectedFile = (root: Page) => root.locator('[data-testid="envelope-item-edit-selected-file"]');
|
||||
|
||||
const getEditDialogClearFileButton = (root: Page) => root.locator('[data-testid="envelope-item-edit-clear-file"]');
|
||||
|
||||
const getEditDialogUpdateButton = (root: Page) => root.locator('[data-testid="envelope-item-edit-update-button"]');
|
||||
|
||||
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
|
||||
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute('data-page-count', String(expectedCount), {
|
||||
timeout: 15000,
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToFieldsPage = async (surface: TEnvelopeEditorSurface) => {
|
||||
// Set up a recipient first so the fields page is functional.
|
||||
if (surface.isEmbedded) {
|
||||
await setRecipientEmail(surface.root, 0, `test-${nanoid(4)}@example.com`);
|
||||
await setRecipientName(surface.root, 0, 'Test User');
|
||||
} else {
|
||||
await clickAddMyselfButton(surface.root);
|
||||
}
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
|
||||
// Wait for the file selector to be visible on the fields page.
|
||||
await expect(getEditButton(surface.root, 0)).toBeAttached({ timeout: 10000 });
|
||||
};
|
||||
|
||||
const openEditDialogOnFieldsPage = async (root: Page, index = 0) => {
|
||||
// Hover to reveal the edit button, then click it.
|
||||
const editButton = getEditButton(root, index);
|
||||
await editButton.click({ force: true });
|
||||
await expect(getEditDialog(root)).toBeVisible();
|
||||
};
|
||||
|
||||
// --- Flows ---
|
||||
|
||||
const runRenameFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-edit-rename-${nanoid()}`;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(surface.root, 'rename-test.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await navigateToFieldsPage(surface);
|
||||
|
||||
// Open edit dialog and change the title.
|
||||
await openEditDialogOnFieldsPage(surface.root);
|
||||
|
||||
const titleInput = getEditDialogTitleInput(surface.root);
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
await titleInput.clear();
|
||||
await titleInput.fill('Renamed Document');
|
||||
|
||||
// Update button should be disabled without a replacement file.
|
||||
await expect(getEditDialogUpdateButton(surface.root)).toBeDisabled();
|
||||
|
||||
// A replacement file is required for the Update button to be enabled.
|
||||
const dropzone = getEditDialogDropzone(surface.root);
|
||||
await expect(dropzone).toBeVisible();
|
||||
|
||||
const fileInput = dropzone.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'example.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
|
||||
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
|
||||
|
||||
await getEditDialogUpdateButton(surface.root).click();
|
||||
|
||||
// Dialog should close.
|
||||
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const runReplacePdfFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-edit-replace-${nanoid()}`;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(surface.root, 'replace-test.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await navigateToFieldsPage(surface);
|
||||
|
||||
// First, assert the page count is 1 (example.pdf has 1 page).
|
||||
await assertPdfPageCount(surface.root, 1);
|
||||
|
||||
// Open edit dialog.
|
||||
await openEditDialogOnFieldsPage(surface.root);
|
||||
|
||||
// Select a multi-page PDF via the dropzone.
|
||||
const dropzone = getEditDialogDropzone(surface.root);
|
||||
await expect(dropzone).toBeVisible();
|
||||
|
||||
const fileInput = dropzone.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'field-font-alignment.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: multiPagePdfBuffer,
|
||||
});
|
||||
|
||||
// Verify file is shown in the selected file display.
|
||||
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
|
||||
|
||||
// Click Update.
|
||||
await getEditDialogUpdateButton(surface.root).click();
|
||||
|
||||
// Dialog should close.
|
||||
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
// After replacement, the page count should be 3 (field-font-alignment.pdf has 3 pages).
|
||||
await assertPdfPageCount(surface.root, 3);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const runClearFileSelectionFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(surface.root, 'clear-file-test.pdf');
|
||||
}
|
||||
|
||||
await navigateToFieldsPage(surface);
|
||||
|
||||
// Open edit dialog.
|
||||
await openEditDialogOnFieldsPage(surface.root);
|
||||
|
||||
// Select a file.
|
||||
const dropzone = getEditDialogDropzone(surface.root);
|
||||
await expect(dropzone).toBeVisible();
|
||||
|
||||
const fileInput = dropzone.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'to-be-cleared.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
|
||||
// Verify file appears.
|
||||
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
|
||||
await expect(getEditDialogDropzone(surface.root)).not.toBeVisible();
|
||||
|
||||
// Click X to clear.
|
||||
await getEditDialogClearFileButton(surface.root).click();
|
||||
|
||||
// Dropzone should reappear.
|
||||
await expect(getEditDialogDropzone(surface.root)).toBeVisible();
|
||||
await expect(getEditDialogSelectedFile(surface.root)).not.toBeVisible();
|
||||
};
|
||||
|
||||
// --- DB assertion ---
|
||||
|
||||
const assertRenamePersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(envelope.envelopeItems.length).toBeGreaterThanOrEqual(1);
|
||||
expect(envelope.envelopeItems[0].title).toBe('Renamed Document');
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('should rename an envelope item from the fields page', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runRenameFlow(surface);
|
||||
|
||||
await assertRenamePersistedInDatabase({ surface, ...result });
|
||||
});
|
||||
|
||||
test('should replace a PDF from the fields page', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
await runReplacePdfFlow(surface);
|
||||
});
|
||||
|
||||
test('should clear a selected file before submitting', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
await runClearFileSelectionFlow(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('should rename an envelope item from the fields page', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runRenameFlow(surface);
|
||||
|
||||
await assertRenamePersistedInDatabase({ surface, ...result });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('should rename an envelope item from the fields page', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-edit-dialog-create',
|
||||
});
|
||||
|
||||
const result = await runRenameFlow(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRenamePersistedInDatabase({ surface, ...result });
|
||||
});
|
||||
|
||||
test('should replace a PDF from the fields page', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-edit-dialog-replace',
|
||||
});
|
||||
|
||||
await runReplacePdfFlow(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('should rename an envelope item from the fields page', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-edit-dialog-edit',
|
||||
});
|
||||
|
||||
const result = await runRenameFlow(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRenamePersistedInDatabase({ surface, ...result });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getEnvelopeItemDragHandles,
|
||||
getEnvelopeItemDropzoneInput,
|
||||
getEnvelopeItemRemoveButtons,
|
||||
getEnvelopeItemTitleInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.use({
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
|
||||
type TestFilePayload = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
const createPdfPayload = (name: string): TestFilePayload => ({
|
||||
name,
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
|
||||
const getCurrentTitles = async (root: Page) => {
|
||||
const titleInputs = getEnvelopeItemTitleInputs(root);
|
||||
const count = await titleInputs.count();
|
||||
|
||||
return await Promise.all(
|
||||
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
|
||||
);
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAddFieldsAndBack = async (root: Page) => {
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
};
|
||||
|
||||
const getEnvelopeItemsFromDatabase = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return envelope.envelopeItems;
|
||||
};
|
||||
|
||||
type ItemFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
|
||||
const input = getEnvelopeItemDropzoneInput(root);
|
||||
|
||||
await input.setInputFiles(files);
|
||||
};
|
||||
|
||||
const dragEnvelopeItemByHandle = async ({
|
||||
root,
|
||||
sourceIndex,
|
||||
targetIndex,
|
||||
}: {
|
||||
root: Page;
|
||||
sourceIndex: number;
|
||||
targetIndex: number;
|
||||
}) => {
|
||||
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
|
||||
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
|
||||
|
||||
await expect(sourceHandle).toBeVisible();
|
||||
await expect(targetHandle).toBeVisible();
|
||||
|
||||
const sourceBox = await sourceHandle.boundingBox();
|
||||
const targetBox = await targetHandle.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error('Could not resolve drag handle bounding boxes');
|
||||
}
|
||||
|
||||
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||
const targetX = targetBox.x + targetBox.width / 2;
|
||||
const targetY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
await root.mouse.move(sourceX, sourceY);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(targetX, targetY, { steps: 20 });
|
||||
await root.mouse.up();
|
||||
};
|
||||
|
||||
const runEnvelopeItemCrudFlow = async ({
|
||||
surface,
|
||||
initialCount,
|
||||
filesToUpload,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
initialCount: number;
|
||||
filesToUpload: TestFilePayload[];
|
||||
}): Promise<ItemFlowResult> => {
|
||||
const { root, isEmbedded } = surface;
|
||||
const externalId = `e2e-items-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
|
||||
|
||||
// Upload files.
|
||||
await uploadFiles(root, filesToUpload);
|
||||
|
||||
const expectedCountAfterUpload = initialCount + filesToUpload.length;
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterUpload = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterUpload).toHaveLength(expectedCountAfterUpload);
|
||||
}
|
||||
|
||||
// Rename items.
|
||||
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
|
||||
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
|
||||
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterRename = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterRename[0].title).toBe('Envelope Item A');
|
||||
expect(itemsAfterRename[1].title).toBe('Envelope Item B');
|
||||
}
|
||||
|
||||
// Reorder items.
|
||||
await dragEnvelopeItemByHandle({
|
||||
root,
|
||||
sourceIndex: 0,
|
||||
targetIndex: 1,
|
||||
});
|
||||
|
||||
await expect.poll(async () => await getCurrentTitles(root)).toEqual(['Envelope Item B', 'Envelope Item A']);
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterReorder = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterReorder[0].title).toBe('Envelope Item B');
|
||||
expect(itemsAfterReorder[1].title).toBe('Envelope Item A');
|
||||
}
|
||||
|
||||
// Remove first item.
|
||||
await getEnvelopeItemRemoveButtons(root).first().click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await root.getByRole('button', { name: 'Delete' }).click();
|
||||
}
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterRemove = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterRemove).toHaveLength(expectedCountAfterUpload - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
};
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('document-item-added.pdf')],
|
||||
});
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('template-item-added.pdf')],
|
||||
});
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-items',
|
||||
});
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 0,
|
||||
filesToUpload: [
|
||||
createPdfPayload('embedded-document-item-a.pdf'),
|
||||
createPdfPayload('embedded-document-item-b.pdf'),
|
||||
],
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(1); // Expect order 1 because this is a one shot create via embedding. There are no incremental updates.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-items',
|
||||
});
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pending envelope title editing', () => {
|
||||
test('edit envelope and item titles on a pending envelope', async ({ page }) => {
|
||||
const recipientEmail = `recipient-${nanoid()}@test.documenso.com`;
|
||||
const { user, team } = await seedUser();
|
||||
const pendingDocument = await seedPendingDocument(user, team.id, [recipientEmail], {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${pendingDocument.id}/edit`,
|
||||
});
|
||||
|
||||
// Verify the envelope editor loaded with the upload step visible.
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Edit the envelope title in the header.
|
||||
const envelopeTitleInput = page.locator('[data-testid="envelope-title-input"]');
|
||||
await expect(envelopeTitleInput).toBeEnabled();
|
||||
await envelopeTitleInput.fill('Updated Pending Title');
|
||||
|
||||
// Edit the envelope item title.
|
||||
const itemTitleInput = getEnvelopeItemTitleInputs(page).first();
|
||||
await expect(itemTitleInput).toBeEnabled();
|
||||
await itemTitleInput.fill('Updated Item Title');
|
||||
|
||||
// Wait for debounced auto-save to persist by navigating away and back.
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await expect(page.getByText('Selected Recipient')).toBeVisible();
|
||||
await clickEnvelopeEditorStep(page, 'upload');
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Verify the titles persisted in the database.
|
||||
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pendingDocument.id },
|
||||
include: {
|
||||
envelopeItems: true,
|
||||
auditLogs: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedEnvelope.title).toBe('Updated Pending Title');
|
||||
expect(updatedEnvelope.envelopeItems[0].title).toBe('Updated Item Title');
|
||||
|
||||
// Verify audit logs were created for both title changes.
|
||||
const titleAuditLog = updatedEnvelope.auditLogs.find((log) => log.type === 'DOCUMENT_TITLE_UPDATED');
|
||||
expect(titleAuditLog).toBeDefined();
|
||||
|
||||
const itemAuditLog = updatedEnvelope.auditLogs.find((log) => log.type === 'ENVELOPE_ITEM_UPDATED');
|
||||
expect(itemAuditLog).toBeDefined();
|
||||
|
||||
// Verify the upload dropzone is still disabled for pending envelopes.
|
||||
const dropzoneMessage = page.getByText('Cannot upload items after the document has been sent');
|
||||
await expect(dropzoneMessage).toBeVisible();
|
||||
|
||||
// Drag handles are still rendered but dragging is disabled via isDragDisabled
|
||||
// when canItemsBeModified is false. Verify at least that the handle is present
|
||||
// but we cannot programmatically assert isDragDisabled from the DOM.
|
||||
await expect(getEnvelopeItemDragHandles(page)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
|
||||
import {
|
||||
addEnvelopeItemPdf,
|
||||
assertRecipientRole,
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientNameInputs,
|
||||
getRecipientRemoveButtons,
|
||||
getSigningOrderInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
setSigningOrderValue,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleAllowDictateSigners,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type RecipientFlowResult = {
|
||||
externalId: string;
|
||||
expectedRecipientsBySigningOrder: Array<{
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder: number;
|
||||
}>;
|
||||
removedRecipientEmail: string;
|
||||
};
|
||||
|
||||
const TEST_RECIPIENT_VALUES = {
|
||||
secondRecipient: {
|
||||
email: 'recipient-two@example.com',
|
||||
name: 'Recipient Two',
|
||||
},
|
||||
thirdRecipient: {
|
||||
email: 'recipient-three@example.com',
|
||||
name: 'Recipient Three',
|
||||
},
|
||||
embeddedPrimaryRecipient: {
|
||||
email: 'embedded-primary@example.com',
|
||||
name: 'Embedded Primary',
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAddFieldsAndBack = async (root: Page) => {
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
};
|
||||
|
||||
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
|
||||
const externalId = `e2e-recipients-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
|
||||
|
||||
if (surface.isEmbedded) {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
|
||||
await setRecipientName(surface.root, 0, primaryRecipient.name);
|
||||
} else {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||
await clickAddMyselfButton(surface.root);
|
||||
|
||||
primaryRecipient = {
|
||||
email: surface.userEmail,
|
||||
name: surface.userName,
|
||||
};
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
|
||||
}
|
||||
|
||||
await clickAddSignerButton(surface.root);
|
||||
await clickAddSignerButton(surface.root);
|
||||
|
||||
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||
|
||||
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
|
||||
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
|
||||
|
||||
await setRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await setRecipientRole(surface.root, 2, 'Receives copy');
|
||||
|
||||
await getRecipientRemoveButtons(surface.root).nth(2).click();
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
|
||||
await toggleSigningOrder(surface.root, true);
|
||||
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
|
||||
await setSigningOrderValue(surface.root, 0, 2);
|
||||
|
||||
await toggleAllowDictateSigners(surface.root, true);
|
||||
|
||||
await navigateToAddFieldsAndBack(surface.root);
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
|
||||
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
|
||||
|
||||
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||
|
||||
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
|
||||
|
||||
return {
|
||||
externalId,
|
||||
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||
expectedRecipientsBySigningOrder: [
|
||||
{
|
||||
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
role: RecipientRole.APPROVER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: primaryRecipient.email,
|
||||
name: primaryRecipient.name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const assertRecipientsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
expectedRecipientsBySigningOrder,
|
||||
removedRecipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
|
||||
removedRecipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
|
||||
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
|
||||
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
|
||||
|
||||
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
|
||||
const recipient = envelope.recipients[index];
|
||||
|
||||
expect(recipient.email).toBe(expectedRecipient.email);
|
||||
expect(recipient.name).toBe(expectedRecipient.name);
|
||||
expect(recipient.role).toBe(expectedRecipient.role);
|
||||
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
|
||||
});
|
||||
|
||||
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(false);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runRecipientFlow(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runRecipientFlow(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-recipients',
|
||||
});
|
||||
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
|
||||
|
||||
const result = await runRecipientFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-recipients',
|
||||
});
|
||||
|
||||
const result = await runRecipientFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import {
|
||||
addEnvelopeItemPdf,
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getEnvelopeItemDropzoneInput,
|
||||
getEnvelopeItemReplaceButtons,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
|
||||
test.use({
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
|
||||
type TestFilePayload = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
|
||||
|
||||
const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({
|
||||
name,
|
||||
mimeType: 'application/pdf',
|
||||
buffer,
|
||||
});
|
||||
|
||||
// --- Shared helpers ---
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const replaceEnvelopeItemPdf = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
file: TestFilePayload,
|
||||
options?: { isEmbedded?: boolean },
|
||||
) => {
|
||||
const replaceButton = getEnvelopeItemReplaceButtons(root).nth(index);
|
||||
await expect(replaceButton).toBeVisible();
|
||||
|
||||
// Listen for the file chooser event before clicking so the native dialog
|
||||
// is intercepted and never actually shown to the user.
|
||||
const [fileChooser] = await Promise.all([root.waitForEvent('filechooser'), replaceButton.click()]);
|
||||
|
||||
await fileChooser.setFiles(file);
|
||||
|
||||
// The button stays in the DOM but becomes disabled while the replace
|
||||
// mutation is in flight, then re-enables once the mutation completes.
|
||||
// Wait for both transitions to guarantee the replace has fully finished.
|
||||
//
|
||||
// For embedded surfaces the replacement is purely local state with no
|
||||
// network round-trip, so it can complete before Playwright observes the
|
||||
// disabled state. Skip the disabled assertion for embedded surfaces.
|
||||
if (!options?.isEmbedded) {
|
||||
await expect(replaceButton).toBeDisabled({ timeout: 15000 });
|
||||
}
|
||||
|
||||
await expect(replaceButton).toBeEnabled({ timeout: 15000 });
|
||||
};
|
||||
|
||||
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
|
||||
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute('data-page-count', String(expectedCount), {
|
||||
timeout: 15000,
|
||||
});
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const scrollToPage = async (root: Page, pageNumber: number) => {
|
||||
const pageImage = root.locator(`img[data-page-number="${pageNumber}"]`);
|
||||
await pageImage.scrollIntoViewIfNeeded();
|
||||
await expect(pageImage).toBeVisible({ timeout: 10000 });
|
||||
// Wait for the Konva stage to initialize after virtualized rendering.
|
||||
await root.waitForTimeout(1000);
|
||||
};
|
||||
|
||||
const placeFieldOnPage = async (
|
||||
root: Page,
|
||||
pageNumber: number,
|
||||
fieldName: 'Signature' | 'Text',
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
if (pageNumber > 1) {
|
||||
await scrollToPage(root, pageNumber);
|
||||
}
|
||||
|
||||
// Find the canvas corresponding to this page number via Konva stages.
|
||||
// Since the virtualized list may have multiple canvases, we target the one
|
||||
// that belongs to the correct page by using the evaluate approach.
|
||||
const canvas = root.locator('.konva-container canvas');
|
||||
|
||||
if (pageNumber === 1) {
|
||||
await expect(canvas.first()).toBeVisible();
|
||||
await canvas.first().click({ position });
|
||||
} else {
|
||||
// For multi-page, find the canvas at the page position.
|
||||
// The canvases are rendered in page order within the viewport.
|
||||
// After scrolling to page N, it should be visible. We use nth based on
|
||||
// the page index among currently rendered canvases.
|
||||
// A more reliable approach: click on the page's img and offset into canvas.
|
||||
const pageImg = root.locator(`img[data-page-number="${pageNumber}"]`);
|
||||
const pageBox = await pageImg.boundingBox();
|
||||
|
||||
if (!pageBox) {
|
||||
throw new Error(`Could not find bounding box for page ${pageNumber}`);
|
||||
}
|
||||
|
||||
// Click at the desired position relative to the page image.
|
||||
// The Konva canvas overlays the image, so clicking at the same coordinates works.
|
||||
await root.mouse.click(pageBox.x + position.x, pageBox.y + position.y);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Test 1: Basic replace flow ---
|
||||
|
||||
type BasicReplaceFlowResult = {
|
||||
externalId: string;
|
||||
originalDocumentDataId: string | null;
|
||||
};
|
||||
|
||||
const runBasicReplaceFlow = async (surface: TEnvelopeEditorSurface): Promise<BasicReplaceFlowResult> => {
|
||||
const { root, isEmbedded } = surface;
|
||||
const externalId = `e2e-replace-${nanoid()}`;
|
||||
|
||||
// For embedded create, upload a PDF first.
|
||||
if (isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'replace-test-original.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
// Record the original documentDataId so we can assert it changed after replace.
|
||||
// For embedded flows the externalId only lives in client state until persist,
|
||||
// so query by envelope ID when available, and skip entirely for embedded create.
|
||||
let originalDocumentDataId: string | null = null;
|
||||
|
||||
if (!isEmbedded) {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
|
||||
} else if (surface.envelopeId) {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: surface.envelopeId,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
|
||||
}
|
||||
|
||||
// Navigate to addFields step to verify initial page count.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
await assertPdfPageCount(root, 1);
|
||||
|
||||
// Navigate back to upload step.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Replace the PDF.
|
||||
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('replace-test-new.pdf'), { isEmbedded });
|
||||
|
||||
// Navigate to addFields step to verify the PDF loaded correctly.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
await assertPdfPageCount(root, 1);
|
||||
|
||||
// Navigate back to upload step.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
return { externalId, originalDocumentDataId };
|
||||
};
|
||||
|
||||
const assertBasicReplaceInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
originalDocumentDataId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
originalDocumentDataId: string | null;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(envelope.envelopeItems).toHaveLength(1);
|
||||
|
||||
const item = envelope.envelopeItems[0];
|
||||
|
||||
// The documentDataId should have changed after replace.
|
||||
if (originalDocumentDataId) {
|
||||
expect(item.documentDataId).not.toBe(originalDocumentDataId);
|
||||
}
|
||||
|
||||
// Verify documentDataId is a valid non-empty string.
|
||||
expect(item.documentDataId).toBeTruthy();
|
||||
expect(typeof item.documentDataId).toBe('string');
|
||||
|
||||
// Title and order should be unchanged.
|
||||
expect(item.order).toBeGreaterThanOrEqual(1);
|
||||
};
|
||||
|
||||
// --- Test 2: Field cleanup replace flow ---
|
||||
|
||||
const TEST_FIELD_VALUES = {
|
||||
embeddedRecipient: {
|
||||
email: 'embedded-replace-recipient@documenso.com',
|
||||
name: 'Embedded Replace Recipient',
|
||||
},
|
||||
};
|
||||
|
||||
type FieldCleanupFlowResult = {
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
};
|
||||
|
||||
const setupRecipient = async (surface: TEnvelopeEditorSurface): Promise<string> => {
|
||||
if (surface.isEmbedded) {
|
||||
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||
return TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||
}
|
||||
|
||||
await clickAddMyselfButton(surface.root);
|
||||
return surface.userEmail;
|
||||
};
|
||||
|
||||
const uploadMultiPagePdf = async (root: Page) => {
|
||||
await getEnvelopeItemDropzoneInput(root).setInputFiles({
|
||||
name: 'multi-page.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: multiPagePdfBuffer,
|
||||
});
|
||||
};
|
||||
|
||||
const runFieldCleanupReplaceFlow = async (surface: TEnvelopeEditorSurface): Promise<FieldCleanupFlowResult> => {
|
||||
const { root, isEmbedded } = surface;
|
||||
const externalId = `e2e-replace-fields-${nanoid()}`;
|
||||
|
||||
// Step 1: Get a 3-page PDF loaded.
|
||||
if (isEmbedded && !surface.envelopeId) {
|
||||
// Embedded create: upload the multi-page PDF directly.
|
||||
await uploadMultiPagePdf(root);
|
||||
} else {
|
||||
// All other surfaces: replace the existing 1-page PDF with the 3-page one.
|
||||
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('multi-page.pdf', multiPagePdfBuffer), {
|
||||
isEmbedded,
|
||||
});
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
// Step 2: Add a recipient.
|
||||
const recipientEmail = await setupRecipient(surface);
|
||||
|
||||
// Step 3: Navigate to addFields step and verify 3-page PDF is loaded.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
await assertPdfPageCount(root, 3);
|
||||
|
||||
// Step 4: Place a Signature field on page 1.
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
|
||||
let fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCountPage1).toBe(1);
|
||||
|
||||
// Step 5: Scroll to page 2 and place a Text field.
|
||||
await placeFieldOnPage(root, 2, 'Text', { x: 120, y: 140 });
|
||||
const fieldCountPage2 = await getKonvaElementCountForPage(root, 2, '.field-group');
|
||||
expect(fieldCountPage2).toBe(1);
|
||||
|
||||
// Verify file selector shows "2 Fields".
|
||||
await expect(root.getByText('2 Fields')).toBeVisible();
|
||||
|
||||
// Step 6: Navigate back to upload step.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Step 7: Replace with 1-page PDF.
|
||||
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('single-page.pdf'), { isEmbedded });
|
||||
|
||||
// Step 8: Navigate to addFields step to verify.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
// PDF should now be 1 page.
|
||||
await assertPdfPageCount(root, 1);
|
||||
|
||||
// Page 1 field should survive.
|
||||
fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCountPage1).toBe(1);
|
||||
|
||||
// File selector should show "1 Field".
|
||||
await expect(root.getByText('1 Field')).toBeVisible();
|
||||
|
||||
return { externalId, recipientEmail };
|
||||
};
|
||||
|
||||
const assertFieldCleanupInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
recipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
recipients: true,
|
||||
envelopeItems: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const recipient = envelope.recipients.find((r) => r.email === recipientEmail);
|
||||
expect(recipient).toBeDefined();
|
||||
|
||||
// Only the page-1 field should remain.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].page).toBe(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
expect(envelope.fields[0].recipientId).toBe(recipient?.id);
|
||||
|
||||
// The envelope item should have a 1-page PDF (documentDataId should exist).
|
||||
expect(envelope.envelopeItems).toHaveLength(1);
|
||||
expect(envelope.envelopeItems[0].documentDataId).toBeTruthy();
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('replace PDF on an envelope item', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runBasicReplaceFlow(surface);
|
||||
|
||||
await assertBasicReplaceInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runFieldCleanupReplaceFlow(surface);
|
||||
|
||||
await assertFieldCleanupInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('replace PDF on an envelope item', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runBasicReplaceFlow(surface);
|
||||
|
||||
await assertBasicReplaceInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runFieldCleanupReplaceFlow(surface);
|
||||
|
||||
await assertFieldCleanupInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('replace PDF on an envelope item', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-replace',
|
||||
});
|
||||
|
||||
const result = await runBasicReplaceFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertBasicReplaceInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-replace-fields',
|
||||
});
|
||||
|
||||
const result = await runFieldCleanupReplaceFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldCleanupInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('replace PDF on an envelope item', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-replace',
|
||||
});
|
||||
|
||||
const result = await runBasicReplaceFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertBasicReplaceInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-replace-fields',
|
||||
});
|
||||
|
||||
const result = await runFieldCleanupReplaceFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldCleanupInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
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_/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
|
||||
|
||||
import {
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type SettingsFlowData = {
|
||||
externalId: string;
|
||||
isEmbedded: boolean;
|
||||
};
|
||||
|
||||
const TEST_SETTINGS_VALUES = {
|
||||
replyTo: 'e2e-settings@example.com',
|
||||
redirectUrl: 'https://example.com/e2e-settings-complete',
|
||||
subject: 'E2E settings subject',
|
||||
message: 'E2E settings message',
|
||||
language: 'French',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: 'None',
|
||||
expirationMode: 'Custom duration',
|
||||
expirationAmount: 5,
|
||||
expirationUnit: 'Weeks',
|
||||
reminderMode: 'Enabled',
|
||||
reminderSendAfterAmount: 3,
|
||||
reminderSendAfterUnit: 'Days',
|
||||
reminderRepeatMode: 'Custom interval',
|
||||
reminderRepeatAmount: 7,
|
||||
reminderRepeatUnit: 'Days',
|
||||
accessAuth: 'Require account',
|
||||
actionAuth: 'Require password',
|
||||
visibility: 'Managers and above',
|
||||
};
|
||||
|
||||
const DB_EXPECTED_VALUES = {
|
||||
language: 'fr',
|
||||
dateFormat: 'dd/MM/yyyy',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: DocumentDistributionMethod.NONE,
|
||||
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
|
||||
reminderSettings: {
|
||||
sendAfter: { unit: 'day', amount: 3 },
|
||||
repeatEvery: { unit: 'day', amount: 7 },
|
||||
},
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
globalActionAuth: ['PASSWORD'],
|
||||
emailSettings: {
|
||||
recipientSigned: false,
|
||||
recipientSigningRequest: false,
|
||||
recipientRemoved: false,
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: false,
|
||||
ownerRecipientExpired: false,
|
||||
ownerDocumentCreated: false,
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const clickSettingsDialogHeader = async (root: Page) => {
|
||||
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
|
||||
};
|
||||
|
||||
const getComboboxByLabel = (root: Page, label: string) =>
|
||||
root.locator(`label:has-text("${label}")`).locator('xpath=..').locator('[role="combobox"]').first();
|
||||
|
||||
const selectMultiSelectOption = async (
|
||||
root: Page,
|
||||
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
|
||||
optionLabel: string,
|
||||
) => {
|
||||
const select = root.locator(`[data-testid="${dataTestId}"]`);
|
||||
|
||||
await select.click();
|
||||
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
};
|
||||
|
||||
const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, isEmbedded }: SettingsFlowData) => {
|
||||
await openSettingsDialog(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Language').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
|
||||
|
||||
await signatureTypesCombobox.click();
|
||||
await root.getByRole('option', { name: 'Upload' }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Date Format').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Time Zone').click();
|
||||
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('input[name="externalId"]').fill(externalId);
|
||||
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
|
||||
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Expiration').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationMode }).click();
|
||||
await root.getByRole('spinbutton').clear();
|
||||
await root.getByRole('spinbutton').fill(String(TEST_SETTINGS_VALUES.expirationAmount));
|
||||
const expirationUnitTrigger = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ })
|
||||
.first();
|
||||
await expirationUnitTrigger.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
// Configure reminder settings.
|
||||
await root.getByRole('button', { name: 'Reminders' }).click();
|
||||
|
||||
await root.locator('[data-testid="reminder-mode-select"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderMode }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('[data-testid="reminder-send-after-amount"]').clear();
|
||||
await root
|
||||
.locator('[data-testid="reminder-send-after-amount"]')
|
||||
.fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
|
||||
|
||||
await root.locator('[data-testid="reminder-send-after-unit"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('[data-testid="reminder-repeat-mode-select"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('[data-testid="reminder-repeat-amount"]').clear();
|
||||
await root.locator('[data-testid="reminder-repeat-amount"]').fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
|
||||
|
||||
await root.locator('[data-testid="reminder-repeat-unit"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const spinbuttons = root.getByRole('spinbutton');
|
||||
await spinbuttons.first().clear();
|
||||
await spinbuttons.first().fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
|
||||
|
||||
const sendAfterUnitTrigger = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Days|Weeks|Months/ })
|
||||
.first();
|
||||
await sendAfterUnitTrigger.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const repeatModeSelect = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Custom interval|Don't repeat/ })
|
||||
.first();
|
||||
await repeatModeSelect.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await spinbuttons.last().clear();
|
||||
await spinbuttons.last().fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
|
||||
|
||||
const repeatUnitTrigger = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Days|Weeks|Months/ })
|
||||
.last();
|
||||
await repeatUnitTrigger.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
await root.locator('#ownerDocumentCompleted').click();
|
||||
await root.locator('#ownerRecipientExpired').click();
|
||||
await root.locator('#ownerDocumentCreated').click();
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||
|
||||
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
|
||||
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
await selectMultiSelectOption(root, 'documentActionSelectValue', TEST_SETTINGS_VALUES.actionAuth);
|
||||
}
|
||||
|
||||
if (isEmbedded) {
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
|
||||
} else {
|
||||
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
}
|
||||
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
}
|
||||
|
||||
await openSettingsDialog(root);
|
||||
|
||||
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
|
||||
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
|
||||
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
|
||||
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(TEST_SETTINGS_VALUES.dateFormat);
|
||||
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
|
||||
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.distributionMethod,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Expiration')).toContainText(TEST_SETTINGS_VALUES.expirationMode);
|
||||
await expect(root.getByRole('spinbutton')).toHaveValue(String(TEST_SETTINGS_VALUES.expirationAmount));
|
||||
await expect(
|
||||
root.locator('button[role="combobox"]').filter({ hasText: TEST_SETTINGS_VALUES.expirationUnit }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Verify reminder settings persisted in UI.
|
||||
await root.getByRole('button', { name: 'Reminders' }).click();
|
||||
await expect(root.locator('[data-testid="reminder-mode-select"]')).toContainText(TEST_SETTINGS_VALUES.reminderMode);
|
||||
await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
|
||||
String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-send-after-unit"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderSendAfterUnit,
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-repeat-mode-select"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderRepeatMode,
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-repeat-amount"]')).toHaveValue(
|
||||
String(TEST_SETTINGS_VALUES.reminderRepeatAmount),
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-repeat-unit"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderRepeatUnit,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCreated')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(TEST_SETTINGS_VALUES.replyTo);
|
||||
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(TEST_SETTINGS_VALUES.subject);
|
||||
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.accessAuth,
|
||||
);
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.actionAuth,
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmbedded) {
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
|
||||
} else {
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.visibility,
|
||||
);
|
||||
}
|
||||
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
}
|
||||
|
||||
return {
|
||||
hasActionAuthSelect,
|
||||
};
|
||||
};
|
||||
|
||||
const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility,
|
||||
}: {
|
||||
externalId: string;
|
||||
surface: TEnvelopeEditorSurface;
|
||||
hasActionAuthSelect: boolean;
|
||||
shouldAssertVisibility: boolean;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.externalId).toBe(externalId);
|
||||
if (shouldAssertVisibility) {
|
||||
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
|
||||
}
|
||||
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
|
||||
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
|
||||
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
|
||||
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
|
||||
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(DB_EXPECTED_VALUES.envelopeExpirationPeriod);
|
||||
expect(envelope.documentMeta.reminderSettings).toEqual(DB_EXPECTED_VALUES.reminderSettings);
|
||||
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
|
||||
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
|
||||
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||
|
||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||
|
||||
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
|
||||
}
|
||||
};
|
||||
|
||||
const parseAuthOptions = (authOptions: unknown): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
|
||||
if (!isRecord(authOptions)) {
|
||||
return {
|
||||
globalAccessAuth: [],
|
||||
globalActionAuth: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
|
||||
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
|
||||
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-settings',
|
||||
});
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: true,
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-settings',
|
||||
});
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: true,
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
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 { type APIRequestContext, expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
@@ -202,10 +201,7 @@ test('cert and audit log pages match A4 page dimensions', async ({ page, request
|
||||
});
|
||||
});
|
||||
|
||||
test('cert and audit log pages match tabloid landscape page dimensions', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test('cert and audit log pages match tabloid landscape page dimensions', async ({ page, request }) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||
@@ -31,6 +30,9 @@ const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
/**
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('seed alignment test document', async ({ page }) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -69,9 +71,7 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
});
|
||||
|
||||
// Step 1: Create initial envelope with Prisma (with first envelope item)
|
||||
const alignmentPdf = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
|
||||
);
|
||||
const alignmentPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
|
||||
|
||||
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-meta.pdf'));
|
||||
|
||||
@@ -161,7 +161,16 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
const uninsertedFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
inserted: false,
|
||||
OR: [
|
||||
{
|
||||
inserted: false,
|
||||
},
|
||||
{
|
||||
// Include email fields because they are automatically inserted during envelope distribution.
|
||||
// We need to extract it to override their values for accurate comparison in tests.
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
@@ -211,9 +220,7 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
? {
|
||||
create: {
|
||||
recipientId: envelope.recipients[0].id,
|
||||
signatureImageAsBase64: isBase64Image(foundField.signature)
|
||||
? foundField.signature
|
||||
: null,
|
||||
signatureImageAsBase64: isBase64Image(foundField.signature) ? foundField.signature : null,
|
||||
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
|
||||
},
|
||||
}
|
||||
@@ -223,6 +230,44 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
// Email fields are auto-inserted with the signer's email during distribution,
|
||||
// so we override customText directly to test with specific values.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const testFields = field.envelopeItem.title === 'alignment-pdf' ? ALIGNMENT_TEST_FIELDS : FIELD_META_TEST_FIELDS;
|
||||
|
||||
const foundField = testFields.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientToken = envelope.recipients[0].token;
|
||||
const signUrl = `/sign/${recipientToken}`;
|
||||
|
||||
@@ -326,6 +371,42 @@ test.skip('download envelope images', async ({ page, request }) => {
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const testFields = field.envelopeItem.title === 'alignment-pdf' ? ALIGNMENT_TEST_FIELDS : FIELD_META_TEST_FIELDS;
|
||||
|
||||
const foundField = testFields.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const token = envelope.recipients[0].token;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
@@ -436,17 +517,10 @@ type CompareSignedPdfWithImagesOptions = {
|
||||
testInfo: TestInfo;
|
||||
};
|
||||
|
||||
const compareSignedPdfWithImages = async ({
|
||||
id,
|
||||
pdfData,
|
||||
images,
|
||||
testInfo,
|
||||
}: CompareSignedPdfWithImagesOptions) => {
|
||||
const compareSignedPdfWithImages = async ({ id, pdfData, images, testInfo }: CompareSignedPdfWithImagesOptions) => {
|
||||
const renderedImages = await renderPdfToImage(pdfData);
|
||||
|
||||
const blankCertificateFile = fs.readFileSync(
|
||||
path.join(__dirname, '../../visual-regression/blank-certificate.png'),
|
||||
);
|
||||
const blankCertificateFile = fs.readFileSync(path.join(__dirname, '../../visual-regression/blank-certificate.png'));
|
||||
const blankCertificateImage = PNG.sync.read(blankCertificateFile).data;
|
||||
|
||||
for (const [index, { image, width, height }] of renderedImages.entries()) {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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 { expect, test } from '@playwright/test';
|
||||
|
||||
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,138 @@
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
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.getByTestId('envelope-expiration-amount');
|
||||
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.getByTestId('envelope-expiration-unit');
|
||||
|
||||
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.getByTestId('envelope-expiration-mode');
|
||||
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();
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
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 = page.getByTestId('envelope-expiration-amount');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('5');
|
||||
|
||||
const unitTrigger = page.getByTestId('envelope-expiration-unit');
|
||||
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,130 @@
|
||||
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 { expect, test } from '@playwright/test';
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOverflowTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||
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 { OVERFLOW_TEST_FIELDS } from '../../constants/field-overflow-pdf';
|
||||
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 });
|
||||
|
||||
/**
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('seed overflow test document', async ({ page }) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: 'example@documenso.com',
|
||||
},
|
||||
include: {
|
||||
ownedOrganisations: {
|
||||
include: {
|
||||
teams: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = user.id;
|
||||
const teamId = user.ownedOrganisations[0].teams[0].id;
|
||||
|
||||
await seedOverflowTestDocument({
|
||||
userId,
|
||||
teamId,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: false,
|
||||
status: DocumentStatus.DRAFT,
|
||||
});
|
||||
});
|
||||
|
||||
test('overflow visual regression', async ({ page, request }, testInfo) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// Step 1: Create initial envelope with overflow PDF
|
||||
const overflowPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-overflow.pdf'));
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
const overflowFields = OVERFLOW_TEST_FIELDS.map((field) => ({
|
||||
identifier: 'field-overflow',
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
fieldMeta: field.fieldMeta,
|
||||
}));
|
||||
|
||||
const createEnvelopePayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Overflow Test',
|
||||
recipients: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: overflowFields,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
formData.append('payload', JSON.stringify(createEnvelopePayload));
|
||||
formData.append('files', new File([overflowPdf], 'field-overflow', { type: 'application/pdf' }));
|
||||
|
||||
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createEnvelopeRequest.ok()).toBeTruthy();
|
||||
expect(createEnvelopeRequest.status()).toBe(200);
|
||||
|
||||
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: createdEnvelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipientId = envelope.recipients[0].id;
|
||||
const overflowItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
|
||||
|
||||
expect(recipientId).toBeDefined();
|
||||
expect(overflowItem).toBeDefined();
|
||||
|
||||
if (!overflowItem) {
|
||||
throw new Error('Envelope item not found');
|
||||
}
|
||||
|
||||
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
const uninsertedFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
OR: [
|
||||
{
|
||||
inserted: false,
|
||||
},
|
||||
{
|
||||
// Include email fields because they are automatically inserted during envelope distribution.
|
||||
// We need to extract it to override their values for accurate comparison in tests.
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
uninsertedFields.map(async (field) => {
|
||||
const foundField = OVERFLOW_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
|
||||
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
|
||||
Number(field.height).toFixed(2) === f.height.toFixed(2),
|
||||
);
|
||||
|
||||
if (!foundField) {
|
||||
throw new Error('Field not found');
|
||||
}
|
||||
|
||||
await prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
inserted: true,
|
||||
customText: foundField.customText,
|
||||
signature: foundField.signature
|
||||
? {
|
||||
create: {
|
||||
recipientId: envelope.recipients[0].id,
|
||||
signatureImageAsBase64: isBase64Image(foundField.signature) ? foundField.signature : null,
|
||||
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
// Email fields are auto-inserted with the signer's email during distribution,
|
||||
// so we override customText directly to test overflow with specific text lengths.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const foundField = OVERFLOW_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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 completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression'));
|
||||
|
||||
await Promise.all(
|
||||
completedDocument.envelopeItems.map(async (item) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token: recipientToken,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const loadedImages = storedImages
|
||||
.filter((image) => image.startsWith(`field-overflow-`))
|
||||
.sort((leftImage, rightImage) => {
|
||||
return getVisualRegressionImageIndex(leftImage) - getVisualRegressionImageIndex(rightImage);
|
||||
})
|
||||
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
|
||||
|
||||
await compareSignedPdfWithImages({
|
||||
id: 'field-overflow',
|
||||
pdfData: new Uint8Array(pdfData),
|
||||
images: loadedImages,
|
||||
testInfo,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to download the envelope images when updating the visual regression test.
|
||||
*
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('download overflow 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 seedOverflowTestDocument({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: true,
|
||||
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();
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const foundField = OVERFLOW_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const token = envelope.recipients[0].token;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
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 completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
completedDocument.envelopeItems.map(async (item) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
|
||||
|
||||
for (const [index, { image }] of pdfImages.entries()) {
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../../visual-regression', `field-overflow-${index}.png`),
|
||||
new Uint8Array(image),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
async function renderPdfToImage(pdfBytes: Uint8Array) {
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Increase for higher resolution
|
||||
const scale = 4;
|
||||
|
||||
return await Promise.all(
|
||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||
const page = await pdf.getPage(index + 1);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
canvasContext.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvas,
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
image: await canvas.encode('png'),
|
||||
|
||||
// Rounded down because the certificate page somehow gives dimensions with decimals
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type CompareSignedPdfWithImagesOptions = {
|
||||
id: string;
|
||||
pdfData: Uint8Array;
|
||||
images: Buffer[];
|
||||
testInfo: TestInfo;
|
||||
};
|
||||
|
||||
const compareSignedPdfWithImages = async ({ id, pdfData, images, testInfo }: CompareSignedPdfWithImagesOptions) => {
|
||||
const renderedImages = await renderPdfToImage(pdfData);
|
||||
|
||||
expect(images).toHaveLength(renderedImages.length);
|
||||
|
||||
for (const [index, { image, width, height }] of renderedImages.entries()) {
|
||||
const isCertificate = index === renderedImages.length - 1;
|
||||
|
||||
// Skip certificate page comparison.
|
||||
if (isCertificate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diff = new PNG({ width, height });
|
||||
|
||||
const storedImage = PNG.sync.read(images[index]).data;
|
||||
|
||||
const newImage = PNG.sync.read(image).data;
|
||||
|
||||
const comparison = pixelMatch(
|
||||
new Uint8Array(storedImage),
|
||||
new Uint8Array(newImage),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
diff.data as unknown as Uint8Array,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
threshold: 0.25,
|
||||
// includeAA: true, // This allows stricter testing.
|
||||
},
|
||||
);
|
||||
console.log(`${id}-${index}: ${comparison}`);
|
||||
|
||||
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
|
||||
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
|
||||
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
|
||||
|
||||
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
|
||||
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
|
||||
fs.writeFileSync(newFilePath, new Uint8Array(image));
|
||||
|
||||
expect.soft(comparison).toBeLessThan(2);
|
||||
}
|
||||
};
|
||||
|
||||
const getVisualRegressionImageIndex = (image: string) => {
|
||||
const match = image.match(/-(\d+)\.png$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected visual regression image name: ${image}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
|
||||
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
test.describe('V2 envelope field insertion during signing', () => {
|
||||
test('date fields are auto-inserted when completing a V2 envelope', async ({ page, request }) => {
|
||||
const now = DateTime.now().setZone(DEFAULT_DOCUMENT_TIME_ZONE);
|
||||
|
||||
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
recipients: [{ email: 'signer-date@test.documenso.com', name: 'Date Signer' }],
|
||||
fieldsPerRecipient: [
|
||||
[
|
||||
{ type: FieldType.DATE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const { token } = distributeResult.recipients[0];
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
|
||||
// wait for PDF to be visible
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Wait for the Konva canvas to be ready.
|
||||
const canvas = page.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// DATE is auto-filled, but SIGNATURE still needs manual interaction.
|
||||
await expect(page.getByText('1 Field Remaining').first()).toBeVisible();
|
||||
|
||||
// Set up a signature via the sidebar form.
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
await page.getByRole('tab', { name: 'Type' }).click();
|
||||
await page.getByTestId('signature-pad-type-input').fill('Signature');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
// Click the signature field on the canvas.
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas bounding box not found');
|
||||
}
|
||||
|
||||
const sigField = envelope.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!sigField) {
|
||||
throw new Error('Signature field not found');
|
||||
}
|
||||
|
||||
const x =
|
||||
(Number(sigField.positionX) / 100) * canvasBox.width + ((Number(sigField.width) / 100) * canvasBox.width) / 2;
|
||||
const y =
|
||||
(Number(sigField.positionY) / 100) * canvasBox.height + ((Number(sigField.height) / 100) * canvasBox.height) / 2;
|
||||
|
||||
await canvas.click({ position: { x, y } });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
// Verify the date field was inserted in the database with the correct format.
|
||||
const dateField = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.DATE,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dateField.inserted).toBe(true);
|
||||
expect(dateField.customText).toBeTruthy();
|
||||
|
||||
// Verify the inserted date is close to now (within 2 minutes).
|
||||
const insertedDate = DateTime.fromFormat(dateField.customText, DEFAULT_DOCUMENT_DATE_FORMAT, {
|
||||
zone: DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
});
|
||||
|
||||
expect(insertedDate.isValid).toBe(true);
|
||||
expect(Math.abs(insertedDate.diff(now, 'minutes').minutes)).toBeLessThanOrEqual(2);
|
||||
|
||||
// Verify the document reached COMPLETED status.
|
||||
await expect(async () => {
|
||||
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
});
|
||||
|
||||
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('date and email fields are inserted when completing a V2 envelope with multiple field types', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const now = DateTime.now().setZone(DEFAULT_DOCUMENT_TIME_ZONE);
|
||||
|
||||
const recipientEmail = 'signer-multi@test.documenso.com';
|
||||
|
||||
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
recipients: [{ email: recipientEmail, name: 'Multi Signer' }],
|
||||
fieldsPerRecipient: [
|
||||
[
|
||||
{ type: FieldType.DATE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
|
||||
{ type: FieldType.EMAIL, page: 1, positionX: 5, positionY: 10, width: 5, height: 5 },
|
||||
{ type: FieldType.NAME, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 20,
|
||||
width: 5,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const { token } = distributeResult.recipients[0];
|
||||
|
||||
// Resolve the fields from the envelope for position calculations.
|
||||
const fields = envelope.fields;
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
|
||||
// wait for PDF to be visible
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Wait for the Konva canvas to be ready.
|
||||
const canvas = page.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// DATE and EMAIL fields are auto-filled, so only NAME and SIGNATURE remain.
|
||||
await expect(page.getByText('2 Fields Remaining').first()).toBeVisible();
|
||||
|
||||
// Set up a signature via the sidebar form.
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
await page.getByRole('tab', { name: 'Type' }).click();
|
||||
await page.getByTestId('signature-pad-type-input').fill('Signature');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
// Click each non-date field on the Konva canvas to insert it.
|
||||
// Fields are seeded with positions as percentages of the page.
|
||||
// We need to convert these percentages to pixel positions on the canvas.
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas bounding box not found');
|
||||
}
|
||||
|
||||
// Only NAME and SIGNATURE fields need manual interaction (DATE and EMAIL are auto-filled).
|
||||
const manualFields = fields.filter((f) => f.type !== FieldType.DATE && f.type !== FieldType.EMAIL);
|
||||
|
||||
for (const field of manualFields) {
|
||||
const x = (Number(field.positionX) / 100) * canvasBox.width + ((Number(field.width) / 100) * canvasBox.width) / 2;
|
||||
const y =
|
||||
(Number(field.positionY) / 100) * canvasBox.height + ((Number(field.height) / 100) * canvasBox.height) / 2;
|
||||
|
||||
await canvas.click({ position: { x, y } });
|
||||
|
||||
if (field.type === FieldType.NAME) {
|
||||
const nameDialog = page.getByRole('dialog');
|
||||
const isDialogVisible = await nameDialog.isVisible().catch(() => false);
|
||||
|
||||
if (isDialogVisible) {
|
||||
const nameInput = nameDialog.locator('input[type="text"], input[name="name"]').first();
|
||||
const isInputVisible = await nameInput.isVisible().catch(() => false);
|
||||
|
||||
if (isInputVisible) {
|
||||
await nameInput.fill('Test Signer');
|
||||
}
|
||||
|
||||
const saveButton = nameDialog.getByRole('button', { name: 'Save' });
|
||||
const isButtonVisible = await saveButton.isVisible().catch(() => false);
|
||||
|
||||
if (isButtonVisible) {
|
||||
await saveButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to allow the field signing to complete.
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// All fields should now be complete.
|
||||
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
// Verify the date field was auto-inserted with the correct format.
|
||||
const dateField = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.DATE,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dateField.inserted).toBe(true);
|
||||
expect(dateField.customText).toBeTruthy();
|
||||
|
||||
const insertedDate = DateTime.fromFormat(dateField.customText, DEFAULT_DOCUMENT_DATE_FORMAT, {
|
||||
zone: DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
});
|
||||
|
||||
expect(insertedDate.isValid).toBe(true);
|
||||
expect(Math.abs(insertedDate.diff(now, 'minutes').minutes)).toBeLessThanOrEqual(2);
|
||||
|
||||
// Verify the email field was inserted with the recipient's email.
|
||||
const emailField = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(emailField.inserted).toBe(true);
|
||||
expect(emailField.customText).toBe(recipientEmail);
|
||||
|
||||
// Verify all fields are inserted.
|
||||
const allFields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
for (const field of allFields) {
|
||||
expect(field.inserted).toBe(true);
|
||||
}
|
||||
|
||||
// Verify the document reached COMPLETED status.
|
||||
await expect(async () => {
|
||||
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
});
|
||||
|
||||
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
@@ -106,9 +105,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
|
||||
});
|
||||
|
||||
test('team document with signing certificate enabled should include certificate', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('team document with signing certificate enabled should include certificate', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
@@ -211,9 +208,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
|
||||
});
|
||||
|
||||
test('team document with signing certificate disabled should not include certificate', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('team document with signing certificate disabled should not include certificate', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
@@ -304,9 +299,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const completedDocumentData = await fetch(documentUrl).then(
|
||||
async (res) => await res.arrayBuffer(),
|
||||
);
|
||||
const completedDocumentData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
placeholder?: string;
|
||||
matchAll?: boolean;
|
||||
};
|
||||
|
||||
export type ApiSeedContext = {
|
||||
user: Awaited<ReturnType<typeof seedUser>>['user'];
|
||||
team: Awaited<ReturnType<typeof seedUser>>['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<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<ApiSeedContext> => {
|
||||
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<TCreateEnvelopeResponse> => {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<TGetEnvelopeResponse> => {
|
||||
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<TCreateEnvelopeRecipientsResponse> => {
|
||||
const data = {
|
||||
envelopeId,
|
||||
data: recipients.map((r) => {
|
||||
const recipientPayload: Record<string, unknown> = {
|
||||
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<void> => {
|
||||
// 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<string, unknown> = {
|
||||
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<TDistributeEnvelopeResponse> => {
|
||||
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<string, unknown>;
|
||||
}>
|
||||
>;
|
||||
/** 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<ApiSeedResult> => {
|
||||
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<ApiSeedDocumentOptions, 'folderId'> & {
|
||||
/** 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<ApiSeedResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
type LoginOptions = {
|
||||
page: Page;
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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 { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from './authentication';
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
export type TEnvelopeEditorSurface = {
|
||||
root: Page;
|
||||
isEmbedded: boolean;
|
||||
envelopeId?: string;
|
||||
envelopeType: TEnvelopeEditorType;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
|
||||
|
||||
type TEmbeddedHashCommonOptions = {
|
||||
externalId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
darkModeDisabled?: boolean;
|
||||
};
|
||||
|
||||
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
|
||||
const encodedPayload = encodeURIComponent(JSON.stringify(options));
|
||||
|
||||
if (typeof btoa === 'function') {
|
||||
return btoa(encodedPayload);
|
||||
}
|
||||
|
||||
return Buffer.from(encodedPayload, 'utf8').toString('base64');
|
||||
};
|
||||
|
||||
export const createEmbeddedEnvelopeCreateHash = ({
|
||||
envelopeType,
|
||||
externalId,
|
||||
folderId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: { envelopeType: TEnvelopeEditorType; folderId?: string } & TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
type: envelopeType,
|
||||
folderId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmbeddedEnvelopeEditHash = ({
|
||||
externalId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: false,
|
||||
envelopeId: document.id,
|
||||
envelopeType: 'DOCUMENT',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: {
|
||||
title: `E2E Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: false,
|
||||
envelopeId: template.id,
|
||||
envelopeType: 'TEMPLATE',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
type OpenEmbeddedEnvelopeEditorOptions = {
|
||||
envelopeType: TEnvelopeEditorType;
|
||||
mode?: 'create' | 'edit';
|
||||
tokenNamePrefix?: string;
|
||||
externalId?: string;
|
||||
folderId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
darkModeDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const openEmbeddedEnvelopeEditor = async (
|
||||
page: Page,
|
||||
{
|
||||
envelopeType,
|
||||
mode = 'create',
|
||||
tokenNamePrefix = 'e2e-embed',
|
||||
externalId,
|
||||
folderId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: OpenEmbeddedEnvelopeEditorOptions,
|
||||
): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelopeToEdit =
|
||||
mode === 'edit'
|
||||
? envelopeType === 'DOCUMENT'
|
||||
? await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
})
|
||||
: await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: {
|
||||
title: `E2E Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const embeddedToken = await resolveEmbeddingToken(
|
||||
page,
|
||||
token,
|
||||
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
|
||||
);
|
||||
|
||||
if (envelopeToEdit) {
|
||||
const hash = createEmbeddedEnvelopeEditHash({
|
||||
externalId,
|
||||
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||
);
|
||||
} else {
|
||||
const hash = createEmbeddedEnvelopeCreateHash({
|
||||
envelopeType,
|
||||
externalId,
|
||||
folderId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
|
||||
await page.goto(`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`);
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: true,
|
||||
envelopeId: envelopeToEdit?.id,
|
||||
envelopeType,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const getEnvelopeEditorSettingsTrigger = (root: Page) => root.locator('button[title="Settings"]');
|
||||
|
||||
export const getEnvelopeItemTitleInputs = (root: Page) => root.locator('[data-testid^="envelope-item-title-input-"]');
|
||||
|
||||
export const getEnvelopeItemDragHandles = (root: Page) => root.locator('[data-testid^="envelope-item-drag-handle-"]');
|
||||
|
||||
export const getEnvelopeItemRemoveButtons = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-remove-button-"]');
|
||||
|
||||
export const getEnvelopeItemReplaceButtons = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-replace-button-"]');
|
||||
|
||||
export const getEnvelopeItemDropzoneInput = (root: Page) =>
|
||||
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
|
||||
|
||||
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
|
||||
await getEnvelopeItemDropzoneInput(root).setInputFiles({
|
||||
name: fileName,
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
};
|
||||
|
||||
export const getRecipientEmailInputs = (root: Page) => root.locator('[data-testid="signer-email-input"]');
|
||||
|
||||
export const getRecipientNameInputs = (root: Page) => root.locator('input[placeholder^="Recipient "]');
|
||||
|
||||
export const getRecipientRows = (root: Page) =>
|
||||
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
|
||||
|
||||
export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]');
|
||||
|
||||
export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]');
|
||||
|
||||
export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => {
|
||||
await root.waitForTimeout(200);
|
||||
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
|
||||
};
|
||||
|
||||
export const clickAddMyselfButton = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Add Myself' }).click();
|
||||
};
|
||||
|
||||
export const clickAddSignerButton = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
};
|
||||
|
||||
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
|
||||
await getRecipientEmailInputs(root).nth(index).fill(email);
|
||||
};
|
||||
|
||||
export const setRecipientName = async (root: Page, index: number, name: string) => {
|
||||
await getRecipientNameInputs(root).nth(index).fill(name);
|
||||
};
|
||||
|
||||
export const setRecipientRole = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
roleLabel: 'Needs to sign' | 'Needs to approve' | 'Needs to view' | 'Receives copy' | 'Can prepare',
|
||||
) => {
|
||||
const row = getRecipientRows(root).nth(index);
|
||||
|
||||
await row.locator('button[role="combobox"]').first().click();
|
||||
await root.getByRole('option', { name: roleLabel }).click();
|
||||
};
|
||||
|
||||
export const assertRecipientRole = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
roleLabel: 'Needs to sign' | 'Needs to approve' | 'Needs to view' | 'Receives copy' | 'Can prepare',
|
||||
) => {
|
||||
const row = getRecipientRows(root).nth(index);
|
||||
const roleValueByLabel: Record<typeof roleLabel, string> = {
|
||||
'Needs to sign': 'SIGNER',
|
||||
'Needs to approve': 'APPROVER',
|
||||
'Needs to view': 'VIEWER',
|
||||
'Receives copy': 'CC',
|
||||
'Can prepare': 'ASSISTANT',
|
||||
};
|
||||
|
||||
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute('title', roleValueByLabel[roleLabel]);
|
||||
};
|
||||
|
||||
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
|
||||
const checkbox = root.locator('#signingOrder');
|
||||
const currentState = await checkbox.getAttribute('aria-checked');
|
||||
const isEnabled = currentState === 'true';
|
||||
|
||||
if (isEnabled !== enabled) {
|
||||
await checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
|
||||
const checkbox = root.locator('#allowDictateNextSigner');
|
||||
const currentState = await checkbox.getAttribute('aria-checked');
|
||||
const isEnabled = currentState === 'true';
|
||||
|
||||
if (isEnabled !== enabled) {
|
||||
await checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||
const input = getSigningOrderInputs(root).nth(index);
|
||||
await input.fill(value.toString());
|
||||
await input.blur();
|
||||
};
|
||||
|
||||
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (!surface.isEmbedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUpdateFlow =
|
||||
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
|
||||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
|
||||
|
||||
const actionButtonName = isUpdateFlow
|
||||
? surface.envelopeType === 'DOCUMENT'
|
||||
? 'Update Document'
|
||||
: 'Update Template'
|
||||
: surface.envelopeType === 'DOCUMENT'
|
||||
? 'Create Document'
|
||||
: 'Create Template';
|
||||
|
||||
await surface.root.getByRole('button', { name: actionButtonName }).click();
|
||||
|
||||
const completionHeading = isUpdateFlow
|
||||
? surface.envelopeType === 'DOCUMENT'
|
||||
? 'Document Updated'
|
||||
: 'Template Updated'
|
||||
: surface.envelopeType === 'DOCUMENT'
|
||||
? 'Document Created'
|
||||
: 'Template Created';
|
||||
|
||||
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
|
||||
};
|
||||
|
||||
const resolveEmbeddingToken = async (page: Page, inputToken: string, scope?: string): Promise<string> => {
|
||||
if (!inputToken.startsWith('api_')) {
|
||||
return inputToken;
|
||||
}
|
||||
|
||||
const response = await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: scope ? { scope } : {},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
if (typeof data !== 'object' || data === null || !('token' in data)) {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const token = data.token;
|
||||
|
||||
if (typeof token !== 'string' || token.length === 0) {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export const expectTextToBeVisible = async (page: Page, text: string) => {
|
||||
await expect(page.getByText(text).first()).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type Konva from 'konva';
|
||||
|
||||
export const getKonvaElementCountForPage = async (page: Page, pageNumber: number, elementSelector: string) => {
|
||||
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
|
||||
return await page.evaluate(
|
||||
({ pageNumber, elementSelector }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const pageOne = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
return pageOne?.find(elementSelector).length || 0;
|
||||
},
|
||||
{ pageNumber, elementSelector },
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, FolderType, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument, seedTeamDocuments } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -88,9 +87,7 @@ test('[TEAMS]: can create a document inside a document folder', async ({ page })
|
||||
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
@@ -405,9 +402,7 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
page.getByRole('button', { name: 'Template (Legacy)' }).click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
@@ -677,9 +672,7 @@ test('[TEAMS]: can navigate between template folders', async ({ page }) => {
|
||||
await expect(page.getByText('Team Contract Template 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: folder visibility is properly applied based on team member roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: folder visibility is properly applied based on team member roles', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamMember1 = await seedTeamMember({
|
||||
@@ -893,9 +886,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
||||
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
@@ -2119,9 +2110,7 @@ test('[TEAMS]: team member cannot see admin folder in folder list', async ({ pag
|
||||
await expect(page.getByText('Admin Only Folder')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team member can access admin folder via URL and see everyone documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: team member can access admin folder via URL and see everyone documents', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamMember = await seedTeamMember({
|
||||
@@ -2203,9 +2192,7 @@ test('[TEAMS]: team member cannot see manager folder in folder list', async ({ p
|
||||
await expect(page.getByText('Manager Folder')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team member can access manager folder via URL and see everyone documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: team member can access manager folder via URL and see everyone documents', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamMember = await seedTeamMember({
|
||||
@@ -2305,9 +2292,7 @@ test('[TEAMS]: team member can see everyone folders', async ({ page }) => {
|
||||
await expect(page.getByText('Everyone Folder')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team member can only see everyone documents in everyone folder', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: team member can only see everyone documents in everyone folder', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamMember = await seedTeamMember({
|
||||
@@ -2362,9 +2347,7 @@ test('[TEAMS]: team member can only see everyone documents in everyone folder',
|
||||
await expect(page.getByText('Admin Document')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team manager can see manager and everyone folders in folder list', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: team manager can see manager and everyone folders in folder list', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamManager = await seedTeamMember({
|
||||
@@ -2408,9 +2391,7 @@ test('[TEAMS]: team manager can see manager and everyone folders in folder list'
|
||||
await expect(page.getByText('Everyone Folder')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team manager can see manager and everyone documents in manager folder', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: team manager can see manager and everyone documents in manager folder', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamManager = await seedTeamMember({
|
||||
@@ -2460,18 +2441,14 @@ test('[TEAMS]: team manager can see manager and everyone documents in manager fo
|
||||
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' })).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Admin Document')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team manager can see manager and everyone documents in everyone folder', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: team manager can see manager and everyone documents in everyone folder', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const teamManager = await seedTeamMember({
|
||||
@@ -2723,9 +2700,7 @@ test('[TEAMS]: team owner can see all documents in manager folder', async ({ pag
|
||||
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' })).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
|
||||
@@ -2936,9 +2911,7 @@ test('[TEAMS]: team admin can see all documents in manager folder', async ({ pag
|
||||
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' })).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
||||
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -101,226 +100,215 @@ const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
|
||||
test.describe.skip('Enterprise Feature Restrictions', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Backup any existing license file before running tests
|
||||
await backupLicenseFile();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore the backup license file after all tests complete
|
||||
await restoreLicenseFile();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean up license file before each test to ensure clean state
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up license file after each test
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Ensure no license file exists
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
test.describe
|
||||
.skip('Enterprise Feature Restrictions', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Backup any existing license file before running tests
|
||||
await backupLicenseFile();
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
test.afterAll(async () => {
|
||||
// Restore the backup license file after all tests complete
|
||||
await restoreLicenseFile();
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
test.beforeEach(async () => {
|
||||
// Clean up license file before each test to ensure clean state
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
test.afterEach(async () => {
|
||||
// Clean up license file after each test
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
// Check that enterprise features have asterisks (are restricted)
|
||||
// These are the enterprise features that should be marked with *
|
||||
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
|
||||
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({ page }) => {
|
||||
// Ensure no license file exists
|
||||
await writeLicenseFile(null);
|
||||
|
||||
// Check that the alert is visible
|
||||
await expect(
|
||||
page.getByText('Your current license does not include these features.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Check that enterprise feature checkboxes are disabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeDisabled();
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeDisabled();
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeDisabled();
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Check that enterprise features have asterisks (are restricted)
|
||||
// These are the enterprise features that should be marked with *
|
||||
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
|
||||
|
||||
// Check that the alert is visible
|
||||
await expect(page.getByText('Your current license does not include these features.')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
|
||||
|
||||
// Check that enterprise feature checkboxes are disabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeDisabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeDisabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({ page }) => {
|
||||
// Create a license with ALL enterprise features enabled
|
||||
await writeLicenseFile(
|
||||
createMockLicenseWithFlags({
|
||||
emailDomains: true,
|
||||
embedAuthoring: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
billing: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Check that enterprise features do NOT have asterisks
|
||||
// They should show without the * since the license covers them
|
||||
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
|
||||
|
||||
// The plain labels should be visible (without asterisks)
|
||||
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
|
||||
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
|
||||
|
||||
// The alert should NOT be visible
|
||||
await expect(page.getByText('Your current license does not include these features.')).not.toBeVisible();
|
||||
|
||||
// Check that enterprise feature checkboxes are enabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeEnabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeEnabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeEnabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({ page }) => {
|
||||
// Create a license with SOME enterprise features (emailDomains and cfr21)
|
||||
await writeLicenseFile(
|
||||
createMockLicenseWithFlags({
|
||||
emailDomains: true,
|
||||
cfr21: true,
|
||||
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
|
||||
}),
|
||||
);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Features NOT in license should have asterisks
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
|
||||
|
||||
// Features IN license should NOT have asterisks
|
||||
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
|
||||
|
||||
// The plain labels for licensed features should be visible
|
||||
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
|
||||
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
|
||||
|
||||
// Alert should be visible since some features are restricted
|
||||
await expect(page.getByText('Your current license does not include these features.')).toBeVisible();
|
||||
|
||||
// Licensed features should be enabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeEnabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeEnabled();
|
||||
|
||||
// Unlicensed features should be disabled
|
||||
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
|
||||
await expect(embedAuthoringCheckbox).toBeDisabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
|
||||
// Ensure no license file exists
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Non-enterprise features should NOT have asterisks
|
||||
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
|
||||
|
||||
// Non-enterprise features should always be enabled
|
||||
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
|
||||
await expect(unlimitedDocsCheckbox).toBeEnabled();
|
||||
|
||||
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
|
||||
await expect(brandingCheckbox).toBeEnabled();
|
||||
|
||||
const embedSigningCheckbox = page.locator('#flag-embedSigning');
|
||||
await expect(embedSigningCheckbox).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a license with ALL enterprise features enabled
|
||||
await writeLicenseFile(
|
||||
createMockLicenseWithFlags({
|
||||
emailDomains: true,
|
||||
embedAuthoring: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
billing: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Check that enterprise features do NOT have asterisks
|
||||
// They should show without the * since the license covers them
|
||||
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
|
||||
|
||||
// The plain labels should be visible (without asterisks)
|
||||
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
|
||||
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
|
||||
|
||||
// The alert should NOT be visible
|
||||
await expect(
|
||||
page.getByText('Your current license does not include these features.'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Check that enterprise feature checkboxes are enabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeEnabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeEnabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeEnabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a license with SOME enterprise features (emailDomains and cfr21)
|
||||
await writeLicenseFile(
|
||||
createMockLicenseWithFlags({
|
||||
emailDomains: true,
|
||||
cfr21: true,
|
||||
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
|
||||
}),
|
||||
);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Features NOT in license should have asterisks
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
|
||||
|
||||
// Features IN license should NOT have asterisks
|
||||
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
|
||||
|
||||
// The plain labels for licensed features should be visible
|
||||
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
|
||||
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
|
||||
|
||||
// Alert should be visible since some features are restricted
|
||||
await expect(
|
||||
page.getByText('Your current license does not include these features.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Licensed features should be enabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeEnabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeEnabled();
|
||||
|
||||
// Unlicensed features should be disabled
|
||||
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
|
||||
await expect(embedAuthoringCheckbox).toBeDisabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
|
||||
// Ensure no license file exists
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Non-enterprise features should NOT have asterisks
|
||||
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
|
||||
|
||||
// Non-enterprise features should always be enabled
|
||||
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
|
||||
await expect(unlimitedDocsCheckbox).toBeEnabled();
|
||||
|
||||
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
|
||||
await expect(brandingCheckbox).toBeEnabled();
|
||||
|
||||
const embedSigningCheckbox = page.locator('#flag-embedSigning');
|
||||
await expect(embedSigningCheckbox).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -116,277 +115,250 @@ const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
|
||||
test.describe.skip('License Status Banner', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Backup any existing license file before running tests
|
||||
await backupLicenseFile();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore the backup license file after all tests complete
|
||||
await restoreLicenseFile();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean up license file before each test to ensure clean state
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up license file after each test
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
|
||||
// Ensure no license file exists BEFORE any page loads
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
test.describe
|
||||
.skip('License Status Banner', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Backup any existing license file before running tests
|
||||
await backupLicenseFile();
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
test.afterAll(async () => {
|
||||
// Restore the backup license file after all tests complete
|
||||
await restoreLicenseFile();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean up license file before each test to ensure clean state
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up license file after each test
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
|
||||
// Ensure no license file exists BEFORE any page loads
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should not be visible (no license file)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).not.toBeVisible();
|
||||
|
||||
// Admin banner messages should not be visible (no license file means no banner)
|
||||
await expect(page.getByText('License payment overdue')).not.toBeVisible();
|
||||
await expect(page.getByText('License expired')).not.toBeVisible();
|
||||
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
|
||||
await expect(page.getByText('Missing License')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
|
||||
// Create an ACTIVE license BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('ACTIVE', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should not be visible (license is ACTIVE)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).not.toBeVisible();
|
||||
|
||||
// Admin banner messages should not be visible (license is ACTIVE)
|
||||
await expect(page.getByText('License payment overdue')).not.toBeVisible();
|
||||
await expect(page.getByText('License expired')).not.toBeVisible();
|
||||
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
|
||||
// Create a PAST_DUE license BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('PAST_DUE', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).not.toBeVisible();
|
||||
|
||||
// Admin banner should show PAST_DUE message
|
||||
await expect(page.getByText('License payment overdue')).toBeVisible();
|
||||
await expect(page.getByText('Please update your payment to avoid service disruptions.')).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
|
||||
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).not.toBeVisible();
|
||||
|
||||
// Admin banner should show EXPIRED message
|
||||
await expect(page.getByText('License expired')).toBeVisible();
|
||||
await expect(page.getByText('Please renew your license to continue using enterprise features.')).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({ page }) => {
|
||||
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', true));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner SHOULD be visible (EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
|
||||
|
||||
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Your Documenso instance is using features that are not part of your license.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({ page }) => {
|
||||
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('ACTIVE', true));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (requires EXPIRED status)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).not.toBeVisible();
|
||||
|
||||
// Admin banner should show UNAUTHORIZED message
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Your Documenso instance is using features that are not part of your license.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
}) => {
|
||||
// Create a license file with unauthorized flag but no license data BEFORE any page loads
|
||||
// Note: Even without license data, the banner shows "Invalid License Type" because the
|
||||
// license file exists (just with license: null). The "Missing License" message would only
|
||||
// show if the entire license prop was null, which doesn't happen with a valid file.
|
||||
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).not.toBeVisible();
|
||||
|
||||
// Admin banner should show Invalid License Type message (unauthorized flag is set)
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Your Documenso instance is using features that are not part of your license.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({ page }) => {
|
||||
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', true));
|
||||
|
||||
// Global banner should not be visible (no license file)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
const { user } = await seedUser();
|
||||
|
||||
// Admin banner messages should not be visible (no license file means no banner)
|
||||
await expect(page.getByText('License payment overdue')).not.toBeVisible();
|
||||
await expect(page.getByText('License expired')).not.toBeVisible();
|
||||
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
|
||||
await expect(page.getByText('Missing License')).not.toBeVisible();
|
||||
// Navigate to documents page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
|
||||
// Create an ACTIVE license BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('ACTIVE', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should not be visible (license is ACTIVE)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner messages should not be visible (license is ACTIVE)
|
||||
await expect(page.getByText('License payment overdue')).not.toBeVisible();
|
||||
await expect(page.getByText('License expired')).not.toBeVisible();
|
||||
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
|
||||
// Create a PAST_DUE license BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('PAST_DUE', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show PAST_DUE message
|
||||
await expect(page.getByText('License payment overdue')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Please update your payment to avoid service disruptions.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
|
||||
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show EXPIRED message
|
||||
await expect(page.getByText('License expired')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Please renew your license to continue using enterprise features.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', true));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner SHOULD be visible (EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
|
||||
|
||||
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your Documenso instance is using features that are not part of your license.',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('ACTIVE', true));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (requires EXPIRED status)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show UNAUTHORIZED message
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your Documenso instance is using features that are not part of your license.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a license file with unauthorized flag but no license data BEFORE any page loads
|
||||
// Note: Even without license data, the banner shows "Invalid License Type" because the
|
||||
// license file exists (just with license: null). The "Missing License" message would only
|
||||
// show if the entire license prop was null, which doesn't happen with a valid file.
|
||||
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show Invalid License Type message (unauthorized flag is set)
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your Documenso instance is using features that are not part of your license.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', true));
|
||||
|
||||
const { user } = await seedUser();
|
||||
|
||||
// Navigate to documents page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import {
|
||||
expectTextToBeVisible,
|
||||
expectTextToNotBeVisible,
|
||||
openDropdownMenu,
|
||||
} from '../fixtures/generic';
|
||||
import { expectTextToBeVisible, expectTextToNotBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
@@ -29,9 +24,7 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
|
||||
await page.waitForURL(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page
|
||||
.getByLabel(`Confirm by typing delete ${organisation.name}`)
|
||||
.fill(`delete ${organisation.name}`);
|
||||
await page.getByLabel(`Confirm by typing delete ${organisation.name}`).fill(`delete ${organisation.name}`);
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForURL(`/settings/organisations`);
|
||||
@@ -138,15 +131,9 @@ test('[ORGANISATIONS]: inherit members', async ({ page }) => {
|
||||
|
||||
// Check from admin POV that member counts are correct
|
||||
// You should only see the manager/admins from the organisation in this table.
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: memberEmail })).not.toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: memberEmail2 })).not.toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: memberEmail3 })).not.toBeVisible();
|
||||
@@ -157,31 +144,17 @@ test('[ORGANISATIONS]: inherit members', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Member 1' }).first().click();
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Add Members' }).click();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail)).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${teamWithInheritMembers.url}/settings/members`);
|
||||
|
||||
// Check from member POV that member counts are correct for inherit members team.
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail2),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail3),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail3)).toBeVisible();
|
||||
|
||||
// Disable inherit mode.
|
||||
await page.goto(`/t/${teamWithInheritMembers.url}/settings/groups`);
|
||||
@@ -191,15 +164,9 @@ test('[ORGANISATIONS]: inherit members', async ({ page }) => {
|
||||
|
||||
// Expect the inherited members to disappear
|
||||
await page.goto(`/t/${teamWithInheritMembers.url}/settings/members`);
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: memberEmail })).not.toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: memberEmail2 })).not.toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: memberEmail3 })).not.toBeVisible();
|
||||
@@ -312,25 +279,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Member1' }).click();
|
||||
await page.getByRole('option', { name: 'Member2' }).click();
|
||||
await page.getByRole('option', { name: 'Member3' }).click();
|
||||
// Close the multiselect dropdown so it doesn't overlap the submit button.
|
||||
await page.getByRole('heading', { name: 'Create group' }).click();
|
||||
await page.getByTestId('dialog-create-organisation-button').click();
|
||||
await expect(page.getByText('Group has been created.').first()).toBeVisible();
|
||||
|
||||
await page.goto(`/o/${organisation.url}/settings/members`);
|
||||
|
||||
// Confirm org roles have been applied to these members.
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
|
||||
|
||||
// Test updating the group.
|
||||
await page.goto(`/o/${organisation.url}/settings/groups`);
|
||||
@@ -338,8 +301,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Member' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Member1, Member2, Member3' }).click();
|
||||
await page.getByRole('option', { name: 'Member3' }).click();
|
||||
// Remove Member3 by clicking the X on its chip in the multiselect.
|
||||
await page
|
||||
.getByTestId('group-members-picker')
|
||||
.locator('div', { hasText: /^Member3/ })
|
||||
.getByRole('button', { name: 'Remove' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
await expect(page.getByText('Group has been updated successfully').first()).toBeVisible();
|
||||
|
||||
@@ -348,25 +315,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
// Create a custom member group with the 3 admins to check that they still get the ADMIN roles.
|
||||
await page.getByRole('button', { name: 'Create group' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_ADMINS');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Admin1' }).click();
|
||||
await page.getByRole('option', { name: 'Admin2' }).click();
|
||||
await page.getByRole('option', { name: 'Admin3' }).click();
|
||||
// Close the multiselect dropdown so it doesn't overlap the submit button.
|
||||
await page.getByRole('heading', { name: 'Create group' }).click();
|
||||
await page.getByTestId('dialog-create-organisation-button').click();
|
||||
await expect(page.getByText('Group has been created.').first()).toBeVisible();
|
||||
|
||||
await page.goto(`/o/${organisation.url}/settings/members`);
|
||||
|
||||
// Confirm admins still get admin roles.
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
|
||||
|
||||
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
|
||||
await page.goto(`/o/${organisation.url}/settings/groups`);
|
||||
@@ -374,17 +337,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Member4' }).click();
|
||||
await page.getByRole('option', { name: 'Member5' }).click();
|
||||
// Close the multiselect dropdown so it doesn't overlap the submit button.
|
||||
await page.getByRole('heading', { name: 'Create group' }).click();
|
||||
await page.getByTestId('dialog-create-organisation-button').click();
|
||||
await expect(page.getByText('Group has been created.').first()).toBeVisible();
|
||||
|
||||
// Assign CUSTOM_GROUP_A to TeamA
|
||||
await page.goto(`/t/${teamA}/settings/groups`);
|
||||
await page.getByRole('button', { name: 'Add groups' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByTestId('team-groups-picker').click();
|
||||
await page.getByRole('option', { name: 'CUSTOM_GROUP_A', exact: true }).click();
|
||||
// Close the multiselect dropdown so it doesn't overlap the Next button.
|
||||
await page.getByRole('heading', { name: 'Add groups' }).click();
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Manager' }).click();
|
||||
@@ -394,8 +361,10 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
// Assign CUSTOM_GROUP_B to TeamA
|
||||
await page.goto(`/t/${teamA}/settings/groups`);
|
||||
await page.getByRole('button', { name: 'Add groups' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByTestId('team-groups-picker').click();
|
||||
await page.getByRole('option', { name: 'CUSTOM_GROUP_B', exact: true }).click();
|
||||
// Close the multiselect dropdown so it doesn't overlap the Next button.
|
||||
await page.getByRole('heading', { name: 'Add groups' }).click();
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Manager' }).click();
|
||||
@@ -426,12 +395,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
// Navigate to team members and validate members are there.
|
||||
await page.goto(`/t/${teamA}/settings/members`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail1),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail2),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail2)).toBeVisible();
|
||||
|
||||
// Member 1 should see inherit team and teamA
|
||||
await apiSignout({ page });
|
||||
@@ -572,8 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('You have successfully left this organisation').first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
|
||||
await expect(page.getByText('No results found').first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { seedTeamDocumentWithMeta } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -206,12 +205,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
|
||||
// Update email document settings by enabling/disabling some checkboxes
|
||||
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('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();
|
||||
@@ -229,7 +224,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false, // unchecked
|
||||
documentCompleted: true,
|
||||
documentDeleted: false, // unchecked
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerDocumentCreated: true,
|
||||
});
|
||||
|
||||
// Edit the team email settings
|
||||
@@ -244,15 +241,9 @@ 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: 'Email recipients with a signing request' })
|
||||
.uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
|
||||
.uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email the owner when the document is completed' })
|
||||
.uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
@@ -270,7 +261,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
ownerDocumentCreated: true,
|
||||
});
|
||||
|
||||
// Verify that a document can be created successfully with the team email settings
|
||||
@@ -290,7 +283,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
ownerDocumentCreated: true,
|
||||
});
|
||||
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
@@ -315,7 +310,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerDocumentCreated: true,
|
||||
});
|
||||
|
||||
// Verify that a document can be created successfully with the email settings
|
||||
@@ -335,6 +332,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerDocumentCreated: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
async function addSecondEnvelopeItem(envelopeId: string) {
|
||||
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId },
|
||||
orderBy: { order: 'asc' },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const newDocumentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: firstItem.documentData.type,
|
||||
data: firstItem.documentData.data,
|
||||
initialData: firstItem.documentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.envelopeItem.create({
|
||||
data: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: `${firstItem.title} - Page 2`,
|
||||
documentDataId: newDocumentData.id,
|
||||
order: 2,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('PDF Viewer Rendering', () => {
|
||||
test.describe('Authenticated Pages', () => {
|
||||
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const documentV1 = await seedBlankDocument(user, team.id);
|
||||
const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
const templateV1 = await seedBlankTemplate(user, team.id);
|
||||
const templateV2 = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
|
||||
});
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
|
||||
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Recipient Signing', () => {
|
||||
test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['signer-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['signer-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/sign/${recipientsV1[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/sign/${recipientsV2[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Direct Template', () => {
|
||||
test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const templateV1 = await seedDirectTemplate({
|
||||
title: 'PDF Viewer Test Template V1',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const templateV2 = await seedDirectTemplate({
|
||||
title: 'PDF Viewer Test Template V2',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Share Page', () => {
|
||||
test('should render PDF on share page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const qrTokenV1 = prefixedId('qr');
|
||||
const qrTokenV2 = prefixedId('qr');
|
||||
|
||||
const documentV1 = await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
|
||||
createDocumentOptions: { qrToken: qrTokenV1 },
|
||||
});
|
||||
|
||||
const documentV2 = await seedCompletedDocument(user, team.id, ['share-v2@test.documenso.com'], {
|
||||
createDocumentOptions: { qrToken: qrTokenV2 },
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV1}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV2}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should not return 500 for invalid QR share token', async ({ page }) => {
|
||||
const response = await page.request.get('/share/qr_invalid_token_for_regression_check', {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers().location).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Embed Pages', () => {
|
||||
test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-signer-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-signer-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/embed/sign/${recipientsV1[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/sign/${recipientsV2[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Todo: Multisign does not support multiple envelope items.
|
||||
// await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const templateV1 = await seedDirectTemplate({
|
||||
title: 'Embed Direct Template V1',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const templateV2 = await seedDirectTemplate({
|
||||
title: 'Embed Direct Template V2',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed multisign page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['multisign-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['multisign-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/embed/v1/multisign?token=${recipientsV1[0].token}`);
|
||||
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Todo: Multisign does not support multiple envelope items.
|
||||
// await page.getByRole('button', { name: /View/i }).first().click();
|
||||
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`);
|
||||
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: /View/i }).first().click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Todo: Multisign does not support multiple envelope items.
|
||||
// await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed authoring document create page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
});
|
||||
|
||||
const embedParams = { darkModeDisabled: false, features: {} };
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`);
|
||||
|
||||
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const titleInput = page.getByLabel('Title');
|
||||
await titleInput.click();
|
||||
await titleInput.fill('PDF Viewer E2E Test');
|
||||
|
||||
const emailInput = page.getByPlaceholder('Email').first();
|
||||
await emailInput.click();
|
||||
await emailInput.fill('test-signer@documenso.com');
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator('input[type=file]')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed document edit page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
const documentId = mapSecondaryIdToDocumentId(document.secondaryId);
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-doc-edit-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
scope: `documentId:${documentId}`,
|
||||
});
|
||||
|
||||
const embedParams = {
|
||||
darkModeDisabled: false,
|
||||
features: {},
|
||||
onlyEditFields: true,
|
||||
};
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/edit/${documentId}?token=${presignToken}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed template edit page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
const templateId = mapSecondaryIdToTemplateId(template.secondaryId);
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-template-edit-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
scope: `templateId:${templateId}`,
|
||||
});
|
||||
|
||||
const embedParams = {
|
||||
darkModeDisabled: false,
|
||||
features: {},
|
||||
onlyEditFields: true,
|
||||
};
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/template/edit/${templateId}?token=${presignToken}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
@@ -31,9 +30,7 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
await expect(page.getByRole('status').first()).toContainText(
|
||||
'Your public profile has been updated.',
|
||||
);
|
||||
await expect(page.getByRole('status').first()).toContainText('Your public profile has been updated.');
|
||||
|
||||
// Link direct template to public profile.
|
||||
await page.getByRole('button', { name: 'Link template' }).click();
|
||||
@@ -41,9 +38,7 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill('public-direct-template-title');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Description *' })
|
||||
.fill('public-direct-template-description');
|
||||
await page.getByRole('textbox', { name: 'Description *' }).fill('public-direct-template-description');
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for toast
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { PDF } from '@libpdf/core';
|
||||
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 { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
@@ -14,6 +11,8 @@ import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
@@ -68,10 +67,7 @@ async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
|
||||
/**
|
||||
* Helper to get the value of a text field in a PDF.
|
||||
*/
|
||||
async function getPdfTextFieldValue(
|
||||
pdfBuffer: Uint8Array,
|
||||
fieldName: string,
|
||||
): Promise<string | undefined> {
|
||||
async function getPdfTextFieldValue(pdfBuffer: Uint8Array, fieldName: string): Promise<string | undefined> {
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
|
||||
|
||||
const form = await pdfDoc.getForm();
|
||||
@@ -94,14 +90,10 @@ test.describe.configure({
|
||||
});
|
||||
|
||||
test.describe('Form Flattening', () => {
|
||||
const formFieldsPdf = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../assets/form-fields-test.pdf'),
|
||||
);
|
||||
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
|
||||
|
||||
test.describe('Envelope Creation (DOCUMENT type)', () => {
|
||||
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -119,10 +111,7 @@ test.describe('Form Flattening', () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -156,9 +145,7 @@ test.describe('Form Flattening', () => {
|
||||
expect(hasFormFields).toBe(false);
|
||||
});
|
||||
|
||||
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -176,10 +163,7 @@ test.describe('Form Flattening', () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -211,9 +195,7 @@ test.describe('Form Flattening', () => {
|
||||
});
|
||||
|
||||
test.describe('Template Creation (TEMPLATE type)', () => {
|
||||
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -231,10 +213,7 @@ test.describe('Form Flattening', () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -274,9 +253,7 @@ test.describe('Form Flattening', () => {
|
||||
expect(fieldNames).toContain(FORM_FIELDS.DROPDOWN);
|
||||
});
|
||||
|
||||
test('should preserve form fields in template even when formValues are provided', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should preserve form fields in template even when formValues are provided', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -294,10 +271,7 @@ test.describe('Form Flattening', () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -336,9 +310,7 @@ test.describe('Form Flattening', () => {
|
||||
});
|
||||
|
||||
test.describe('Document from Template', () => {
|
||||
test('should flatten form fields when creating document from template with formValues', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should flatten form fields when creating document from template with formValues', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -362,10 +334,7 @@ test.describe('Form Flattening', () => {
|
||||
|
||||
const templateFormData = new FormData();
|
||||
templateFormData.append('payload', JSON.stringify(templatePayload));
|
||||
templateFormData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -431,9 +400,7 @@ test.describe('Form Flattening', () => {
|
||||
expect(hasFormFields).toBe(false);
|
||||
});
|
||||
|
||||
test('should flatten form fields when creating document from template without formValues', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should flatten form fields when creating document from template without formValues', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -457,10 +424,7 @@ test.describe('Form Flattening', () => {
|
||||
|
||||
const templateFormData = new FormData();
|
||||
templateFormData.append('payload', JSON.stringify(templatePayload));
|
||||
templateFormData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -516,9 +480,7 @@ test.describe('Form Flattening', () => {
|
||||
expect(hasFormFields).toBe(false);
|
||||
});
|
||||
|
||||
test('should use template formValues when creating document without override', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('should use template formValues when creating document without override', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -546,10 +508,7 @@ test.describe('Form Flattening', () => {
|
||||
|
||||
const templateFormData = new FormData();
|
||||
templateFormData.append('payload', JSON.stringify(templatePayload));
|
||||
templateFormData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -636,10 +595,7 @@ test.describe('Form Flattening', () => {
|
||||
const templateFormData = new FormData();
|
||||
|
||||
templateFormData.append('payload', JSON.stringify(templatePayload));
|
||||
templateFormData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -773,10 +729,7 @@ test.describe('Form Flattening', () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -822,10 +775,7 @@ test.describe('Form Flattening', () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as 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';
|
||||
@@ -12,6 +10,7 @@ import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -46,9 +45,7 @@ const setTeamDefaultRecipients = async (
|
||||
};
|
||||
|
||||
test.describe('Default Recipients', () => {
|
||||
test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -142,17 +139,13 @@ test.describe('Default Recipients', () => {
|
||||
expect(defaultRecipient).toBeDefined();
|
||||
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
|
||||
|
||||
const regularSigner = envelope.recipients.find(
|
||||
(r) => r.email === 'regular-signer@documenso.com',
|
||||
);
|
||||
const regularSigner = envelope.recipients.find((r) => r.email === 'regular-signer@documenso.com');
|
||||
expect(regularSigner).toBeDefined();
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
// TODO: Are we intending to allow default recipients to be removed from a document?
|
||||
test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -245,9 +238,7 @@ test.describe('Default Recipients', () => {
|
||||
await expect(removeButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({
|
||||
request,
|
||||
}) => {
|
||||
test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -336,9 +327,7 @@ test.describe('Default Recipients', () => {
|
||||
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
|
||||
});
|
||||
|
||||
test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -413,9 +402,7 @@ test.describe('Default Recipients', () => {
|
||||
|
||||
expect(document.recipients.length).toBe(2);
|
||||
|
||||
const templateRecipient = document.recipients.find(
|
||||
(r) => r.email === 'template-recipient@documenso.com',
|
||||
);
|
||||
const templateRecipient = document.recipients.find((r) => r.email === 'template-recipient@documenso.com');
|
||||
expect(templateRecipient).toBeDefined();
|
||||
|
||||
const defaultRecipient = document.recipients.find(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
@@ -9,10 +8,7 @@ import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
test('[TEAMS]: create team', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
test.skip(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
'Test skipped because billing is enabled.',
|
||||
);
|
||||
test.skip(process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', 'Test skipped because billing is enabled.');
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
@@ -110,11 +109,7 @@ test('[TEAMS]: search respects team document visibility', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
|
||||
const {
|
||||
team: teamA,
|
||||
teamOwner: teamAOwner,
|
||||
teamMember2: teamAMember,
|
||||
} = await seedTeamDocuments();
|
||||
const { team: teamA, teamOwner: teamAOwner, teamMember2: teamAMember } = await seedTeamDocuments();
|
||||
const { team: teamB, teamOwner: teamBOwner } = await seedTeamDocuments();
|
||||
|
||||
await seedDocuments([
|
||||
@@ -157,9 +152,7 @@ test('[TEAMS]: search does not reveal documents from other teams', async ({ page
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
@@ -186,9 +179,7 @@ test('[TEAMS]: search respects recipient visibility regardless of team visibilit
|
||||
await page.waitForURL(/query=Admin(%20|\+|\s)Document/);
|
||||
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Document with Member Recipient' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Document with Member Recipient' })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
@@ -228,9 +219,7 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
|
||||
await page.waitForURL(/query=Unique(%20|\+|\s)Recipient/);
|
||||
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Document for Unique Recipient' })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
@@ -245,9 +234,7 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
|
||||
await page.waitForURL(/query=Unique(%20|\+|\s)Recipient/);
|
||||
|
||||
await checkDocumentTabCount(page, 'All', 0);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Document for Unique Recipient' })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { seedBlankDocument, seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedDocuments,
|
||||
seedTeamDocuments,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import {
|
||||
expectTextToBeVisible,
|
||||
expectToastTextToBeVisible,
|
||||
openDropdownMenu,
|
||||
} from '../fixtures/generic';
|
||||
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
|
||||
@@ -68,11 +59,7 @@ test('[TEAMS]: supports filtering documents by multiple statuses', async ({ page
|
||||
|
||||
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||
const {
|
||||
team: team2,
|
||||
teamOwner: team2Owner,
|
||||
teamMember2: team2Member2,
|
||||
} = await seedTeamDocuments();
|
||||
const { team: team2, teamOwner: team2Owner, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
|
||||
const teamEmailMember = teamMember4;
|
||||
|
||||
@@ -514,9 +501,7 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
|
||||
await expectTextToBeVisible(page, 'Document Visible to Admin with Recipient');
|
||||
});
|
||||
|
||||
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// Seed a member user
|
||||
@@ -583,9 +568,7 @@ test('[TEAMS]: ensure recipient can see document regardless of visibility', asyn
|
||||
});
|
||||
|
||||
// Check that the member user can see the document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Document with Member Recipient', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Document with Member Recipient', exact: true })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
@@ -620,16 +603,12 @@ test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({
|
||||
});
|
||||
|
||||
// Check that the member user cannot see the ADMIN-only document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// Seed a member user
|
||||
@@ -659,9 +638,7 @@ test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only document
|
||||
});
|
||||
|
||||
// Check that the member user cannot see the ADMIN-only document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
@@ -696,9 +673,7 @@ test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async (
|
||||
});
|
||||
|
||||
// Check that the manager user cannot see the ADMIN-only document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
@@ -733,9 +708,7 @@ test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async
|
||||
});
|
||||
|
||||
// Check that the admin user can see the MANAGER_AND_ABOVE document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Manager and Above Document', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Manager and Above Document', exact: true })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
@@ -772,9 +745,7 @@ test('[TEAMS]: check that ADMIN role can change document visibility', async ({ p
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Admins only');
|
||||
});
|
||||
|
||||
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const teamMember = await seedTeamMember({
|
||||
@@ -798,9 +769,7 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE docum
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const teamMember = await seedTeamMember({
|
||||
@@ -824,9 +793,7 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_AB
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const teamMember = await seedTeamMember({
|
||||
@@ -850,9 +817,7 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN document
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const teamManager = await seedTeamMember({
|
||||
@@ -878,16 +843,8 @@ test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documen
|
||||
|
||||
test('[TEAMS]: users cannot see documents from other teams', async ({ page }) => {
|
||||
// Seed two teams with documents
|
||||
const {
|
||||
team: teamA,
|
||||
teamOwner: teamAOwner,
|
||||
teamMember2: teamAMember,
|
||||
} = await seedTeamDocuments();
|
||||
const {
|
||||
team: teamB,
|
||||
teamOwner: teamBOwner,
|
||||
teamMember2: teamBMember,
|
||||
} = await seedTeamDocuments();
|
||||
const { team: teamA, teamOwner: teamAOwner, teamMember2: teamAMember } = await seedTeamDocuments();
|
||||
const { team: teamB, teamOwner: teamBOwner, teamMember2: teamBMember } = await seedTeamDocuments();
|
||||
|
||||
// Seed a document in team B
|
||||
await seedDocuments([
|
||||
@@ -943,9 +900,7 @@ test('[TEAMS]: personal documents are not visible in team context', async ({ pag
|
||||
});
|
||||
|
||||
// Verify that the personal document is not visible in the team context
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Personal Document', exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Personal Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -25,10 +24,7 @@ test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('status')
|
||||
.filter({ hasText: 'We have sent a confirmation email for verification.' })
|
||||
.first(),
|
||||
page.getByRole('status').filter({ hasText: 'We have sent a confirmation email for verification.' }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -55,10 +51,7 @@ test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
const settingsBtn = page
|
||||
.locator('section div')
|
||||
.filter({ hasText: 'Team email' })
|
||||
.getByRole('button');
|
||||
const settingsBtn = page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button');
|
||||
await openDropdownMenu(page, settingsBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedTeamDocumentWithMeta,
|
||||
seedTeamTemplateWithMeta,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedTeamDocumentWithMeta, seedTeamTemplateWithMeta } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -25,9 +18,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
// Create a document and check the settings
|
||||
await page.goto(
|
||||
`/t/${team.url}/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/edit`,
|
||||
);
|
||||
await page.goto(`/t/${team.url}/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/edit`);
|
||||
|
||||
// Verify that the settings match
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Envelope, Team } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import type { Envelope, Team } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Test helper to complete template creation with duplicate recipients
|
||||
*/
|
||||
const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
template: Envelope;
|
||||
}) => {
|
||||
const completeTemplateFlowWithDuplicateRecipients = async (options: { page: Page; team: Team; template: Envelope }) => {
|
||||
const { page, team, template } = options;
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -42,21 +37,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
// Step 3: Add fields for each recipient instance
|
||||
// Add signature field for first instance
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Switch to second instance and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Second Instance').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Switch to different recipient and add their fields
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
@@ -85,10 +80,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page).toHaveURL(`/t/${team.url}/templates`);
|
||||
});
|
||||
|
||||
test('should create document from template with duplicate recipients using same email', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test('should create document from template with duplicate recipients using same email', async ({ page, context }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
@@ -105,10 +97,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
// Navigate to template and create document
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: template.title })
|
||||
.getByRole('button', { name: 'Use Template' })
|
||||
.click();
|
||||
await page.getByRole('row', { name: template.title }).getByRole('button', { name: 'Use Template' }).click();
|
||||
|
||||
// Fill recipient information with same email for both instances
|
||||
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
|
||||
@@ -172,9 +161,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
||||
|
||||
// Verify only one signature field is visible for this recipient
|
||||
expect(
|
||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
||||
).toHaveLength(1);
|
||||
expect(await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all()).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,17 +196,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Duplicate Recipient 2').first().click();
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
@@ -272,7 +259,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -47,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -55,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -64,10 +64,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -75,7 +72,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -98,9 +95,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
expect(fields.length).toBe(3);
|
||||
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT', 'SIGNATURE'].toSorted(),
|
||||
);
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(['SIGNATURE', 'TEXT', 'SIGNATURE'].toSorted());
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -110,7 +105,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -118,7 +113,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -127,10 +122,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -138,7 +130,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -179,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -187,7 +179,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -196,10 +188,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -207,7 +196,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -250,7 +239,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -258,7 +247,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -271,10 +260,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click();
|
||||
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
await triggerAutosave(page);
|
||||
@@ -292,9 +278,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedTemplate.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT'].toSorted(),
|
||||
);
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(['SIGNATURE', 'TEXT'].toSorted());
|
||||
|
||||
const textField = fields.find((field) => field.type === 'TEXT');
|
||||
expect(textField).toBeDefined();
|
||||
@@ -305,11 +289,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
textField.fieldMeta &&
|
||||
typeof textField.fieldMeta === 'object' &&
|
||||
'type' in textField.fieldMeta
|
||||
) {
|
||||
if (textField.fieldMeta && typeof textField.fieldMeta === 'object' && 'type' in textField.fieldMeta) {
|
||||
expect(textField.fieldMeta.type).toBe('text');
|
||||
expect(textField.fieldMeta.label).toBe('Test Field');
|
||||
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -50,9 +49,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
|
||||
retrievedTemplate.title,
|
||||
);
|
||||
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrievedTemplate.title);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -136,7 +133,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Type' }).click();
|
||||
|
||||
@@ -163,7 +160,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
@@ -187,7 +184,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('combobox').nth(7).click();
|
||||
await page.getByRole('option', { name: 'Europe/London' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
@@ -247,7 +244,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
const newExternalId = 'MULTI-TEST-123';
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('combobox').nth(7).click();
|
||||
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -121,18 +120,12 @@ test.describe('AutoSave Signers Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
|
||||
|
||||
await page
|
||||
.getByTestId('placeholder-recipient-email-input')
|
||||
.nth(1)
|
||||
.fill('recipient2@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-email-input').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
|
||||
|
||||
await page
|
||||
.getByTestId('placeholder-recipient-email-input')
|
||||
.nth(2)
|
||||
.fill('recipient3@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-email-input').nth(2).fill('recipient3@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
|
||||
|
||||
await triggerAutosave(page);
|
||||
@@ -185,11 +178,7 @@ export interface GetRecipientsForTemplateOptions {
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
const getRecipientsForTemplate = async ({
|
||||
templateId,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetRecipientsForTemplateOptions) => {
|
||||
const getRecipientsForTemplate = async ({ templateId, userId, teamId }: GetRecipientsForTemplateOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -55,9 +54,7 @@ test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
|
||||
// Set document visibility.
|
||||
await page.getByTestId('documentVisibilitySelectValue').click();
|
||||
await page.getByLabel('Managers and above').click();
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
|
||||
'Managers and above',
|
||||
);
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -67,9 +64,7 @@ test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
|
||||
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
|
||||
'Managers and above',
|
||||
);
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
|
||||
@@ -103,9 +98,7 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
|
||||
// Manager should be able to set visibility to managers and above
|
||||
await page.getByTestId('documentVisibilitySelectValue').click();
|
||||
await page.getByLabel('Managers and above').click();
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
|
||||
'Managers and above',
|
||||
);
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
|
||||
await expect(page.getByText('Admins only')).toBeDisabled();
|
||||
|
||||
// Save and verify
|
||||
@@ -133,9 +126,7 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Navigate to the new template
|
||||
await page.goto(
|
||||
`/t/${team.url}/templates/${mapSecondaryIdToTemplateId(everyoneTemplate.secondaryId)}/edit`,
|
||||
);
|
||||
await page.goto(`/t/${team.url}/templates/${mapSecondaryIdToTemplateId(everyoneTemplate.secondaryId)}/edit`);
|
||||
|
||||
// Regular member should be able to see but not modify visibility
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { FolderType } from '@documenso/prisma/client';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
||||
import path from 'path';
|
||||
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
||||
import path from 'path';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
|
||||
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../../assets/field-font-alignment.pdf',
|
||||
);
|
||||
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(__dirname, '../../../../assets/field-font-alignment.pdf');
|
||||
|
||||
/**
|
||||
* 1. Create a template with all settings filled out
|
||||
@@ -230,9 +226,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
* This test verifies that we can create a document from a template using a custom document
|
||||
* instead of the template's default document.
|
||||
*/
|
||||
test('[TEMPLATE]: should create a document from a template with custom document', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEMPLATE]: should create a document from a template with custom document', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
@@ -267,13 +261,11 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`).evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||
@@ -305,9 +297,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
const expectedDocumentDataType =
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
|
||||
? DocumentDataType.S3_PATH
|
||||
: DocumentDataType.BYTES_64;
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3' ? DocumentDataType.S3_PATH : DocumentDataType.BYTES_64;
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
|
||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||
@@ -316,9 +306,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||
// Probably need to do a pixel match
|
||||
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).not.toEqual(
|
||||
template.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(firstDocumentData.initialData).not.toEqual(template.envelopeItems[0].documentData.initialData);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
@@ -330,9 +318,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
* This test verifies that we can create a team document from a template using a custom document
|
||||
* instead of the template's default document.
|
||||
*/
|
||||
test('[TEMPLATE]: should create a team document from a template with custom document', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEMPLATE]: should create a team document from a template with custom document', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -370,13 +356,11 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`).evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||
@@ -406,9 +390,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
});
|
||||
|
||||
const expectedDocumentDataType =
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
|
||||
? DocumentDataType.S3_PATH
|
||||
: DocumentDataType.BYTES_64;
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3' ? DocumentDataType.S3_PATH : DocumentDataType.BYTES_64;
|
||||
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
@@ -420,9 +402,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||
// Probably need to do a pixel match
|
||||
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).not.toEqual(
|
||||
template.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(firstDocumentData.initialData).not.toEqual(template.envelopeItems[0].documentData.initialData);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
@@ -505,18 +485,12 @@ test('[TEMPLATE]: should create a document from a template using template docume
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
|
||||
expect(firstDocumentData.initialData).toEqual(
|
||||
templateWithData.envelopeItems[0].documentData.data,
|
||||
);
|
||||
expect(firstDocumentData.initialData).toEqual(
|
||||
templateWithData.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(firstDocumentData.initialData).toEqual(templateWithData.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).toEqual(templateWithData.envelopeItems[0].documentData.initialData);
|
||||
expect(firstDocumentData.type).toEqual(templateWithData.envelopeItems[0].documentData.type);
|
||||
});
|
||||
|
||||
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEMPLATE]: should persist document visibility when creating from template', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -533,9 +507,7 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
await page.getByLabel('Title').fill('TEMPLATE_WITH_VISIBILITY');
|
||||
await page.getByTestId('documentVisibilitySelectValue').click();
|
||||
await page.getByLabel('Managers and above').click();
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
|
||||
'Managers and above',
|
||||
);
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
@@ -10,6 +6,9 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -139,9 +138,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplatePath = formatDirectTemplatePath(
|
||||
directTemplateWithAuth.directLink?.token || '',
|
||||
);
|
||||
const directTemplatePath = formatDirectTemplatePath(directTemplateWithAuth.directLink?.token || '');
|
||||
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
@@ -181,9 +178,7 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplatePath = formatDirectTemplatePath(
|
||||
directTemplateWithAuth.directLink?.token || '',
|
||||
);
|
||||
const directTemplatePath = formatDirectTemplatePath(directTemplateWithAuth.directLink?.token || '');
|
||||
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
@@ -312,9 +307,7 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find(
|
||||
(recipient) => recipient.signingOrder === 2,
|
||||
);
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
|
||||
|
||||
expect(updatedSecondRecipient?.name).toBe(newName);
|
||||
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
|
||||
@@ -399,9 +392,7 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find(
|
||||
(recipient) => recipient.signingOrder === 2,
|
||||
);
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
|
||||
|
||||
expect(updatedSecondRecipient?.name).toBe(newName);
|
||||
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -168,7 +167,13 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
await expect(page.getByText('Template Duplicated').first()).toBeVisible();
|
||||
|
||||
// The dialog should navigate to the new template's edit page.
|
||||
await page.waitForURL(/\/templates\/.*\/edit/);
|
||||
|
||||
// Navigate back to the templates list and verify the count is now 2.
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to set up the standard two-team-one-org scenario:
|
||||
*
|
||||
* - One organisation with two teams (teamA and teamB).
|
||||
* - ownerA owns both the org and teamA.
|
||||
* - memberB is only a member of teamB (no relation to teamA).
|
||||
* - An ORGANISATION template is created on teamA.
|
||||
*/
|
||||
const seedOrgTemplateScenario = async () => {
|
||||
const { user: ownerA, organisation, team: teamA } = await seedUser();
|
||||
|
||||
const teamBUrl = `team-b-${nanoid()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: ownerA.id,
|
||||
teamName: `Team B ${teamBUrl}`,
|
||||
teamUrl: teamBUrl,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: false,
|
||||
});
|
||||
|
||||
const teamB = await prisma.team.findFirstOrThrow({
|
||||
where: { url: teamBUrl },
|
||||
});
|
||||
|
||||
// memberB is only added to teamB, not teamA.
|
||||
const memberB = await seedTeamMember({
|
||||
teamId: teamB.id,
|
||||
role: 'MEMBER',
|
||||
});
|
||||
|
||||
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Org Template ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
|
||||
return { ownerA, organisation, teamA, teamB, memberB, orgTemplate };
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to make tRPC queries via the authenticated page context.
|
||||
*/
|
||||
const trpcQuery = async (page: Page, procedure: string, input: Record<string, unknown>, teamId?: number) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (teamId) {
|
||||
headers['x-team-id'] = teamId.toString();
|
||||
}
|
||||
|
||||
const res = await page.context().request.get(url, { headers });
|
||||
|
||||
return { res, json: res.ok() ? await res.json() : null };
|
||||
};
|
||||
|
||||
const trpcMutation = async (page: Page, procedure: string, input: Record<string, unknown>, teamId?: number) => {
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
headers['x-team-id'] = teamId.toString();
|
||||
}
|
||||
|
||||
const res = await page.context().request.post(url, {
|
||||
data: JSON.stringify({ json: input }),
|
||||
headers,
|
||||
});
|
||||
|
||||
return { res, json: res.ok() ? await res.json() : null };
|
||||
};
|
||||
|
||||
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - UI Tabs', () => {
|
||||
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
|
||||
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: ownerA.email,
|
||||
redirectPath: `/t/${teamA.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show tabs for personal organisations', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Listing', () => {
|
||||
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates`,
|
||||
});
|
||||
|
||||
// Team tab should show 0 (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
|
||||
// Switch to Organisation tab.
|
||||
await page.getByTestId('template-tab-organisation').click();
|
||||
|
||||
// Should see the org template from teamA.
|
||||
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create a private template on teamA — should NOT appear in org tab.
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private Template ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(privateTemplate.title)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Organisation Template Detail Page ───────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Detail Page', () => {
|
||||
test('should show org template detail but hide edit/delete actions', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
|
||||
});
|
||||
|
||||
// Click into the org template.
|
||||
await page.getByText(orgTemplate.title).click();
|
||||
|
||||
// Should see the template title.
|
||||
await expect(page.getByRole('heading', { name: orgTemplate.title })).toBeVisible();
|
||||
|
||||
// Should see the Use button.
|
||||
await expect(page.getByRole('button', { name: 'Use' })).toBeVisible();
|
||||
|
||||
// Should NOT see the Edit Template button.
|
||||
await expect(page.getByRole('link', { name: 'Edit Template' })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: findOrganisationTemplates ──────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - findOrganisationTemplates API', () => {
|
||||
test('should return org templates for a member of a sibling team', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res, json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).toContain(orgTemplate.title);
|
||||
});
|
||||
|
||||
test('should not return private templates from sibling teams', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private No Show ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { json } = await trpcQuery(page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamB.id);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(privateTemplate.title);
|
||||
});
|
||||
|
||||
test('should not return org templates to a user outside the organisation', async ({ page }) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create a completely separate user with their own org.
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(orgTemplate.title);
|
||||
});
|
||||
|
||||
test('should respect document visibility based on the viewer team role', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Visibility Everyone ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: 'EVERYONE',
|
||||
},
|
||||
});
|
||||
|
||||
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Visibility Admin ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// memberB has role MEMBER on teamB — should only see EVERYONE visibility.
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { json } = await trpcQuery(page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamB.id);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).toContain(everyoneTemplate.title);
|
||||
expect(titles).not.toContain(adminOnlyTemplate.title);
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// ownerA has role ADMIN on teamA — should see both.
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { json: adminJson } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamA.id,
|
||||
);
|
||||
|
||||
const adminTitles = adminJson.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(adminTitles).toContain(everyoneTemplate.title);
|
||||
expect(adminTitles).toContain(adminOnlyTemplate.title);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: getOrganisationTemplateById ────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - getOrganisationTemplateById API', () => {
|
||||
test('should allow a sibling team member to fetch an org template', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res, json } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(json.result.data.json.title).toBe(orgTemplate.title);
|
||||
});
|
||||
|
||||
test('should reject access from a user outside the organisation', async ({ page }) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
// Should fail — outsider is not in the same org.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should reject fetching a private template via the org endpoint', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: privateTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
// Should fail — template is PRIVATE, not ORGANISATION.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: createDocumentFromTemplate with org template ───────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Use from different team', () => {
|
||||
test('should allow creating a document from an org template owned by a sibling team', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
// Add a recipient to the org template so we can use it.
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: 'recipient@test.documenso.com',
|
||||
name: 'Recipient',
|
||||
token: Math.random().toString().slice(2, 7),
|
||||
envelopeId: orgTemplate.id,
|
||||
},
|
||||
});
|
||||
|
||||
const orgTemplateWithRecipients = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: orgTemplate.id },
|
||||
include: { recipients: true },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const templateId = mapSecondaryIdToTemplateId(orgTemplateWithRecipients.secondaryId);
|
||||
|
||||
const { res } = await trpcMutation(
|
||||
page,
|
||||
'template.createDocumentFromTemplate',
|
||||
{
|
||||
templateId,
|
||||
recipients: orgTemplateWithRecipients.recipients.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
name: r.name ?? '',
|
||||
})),
|
||||
},
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adversarial: Cross-organisation access ──────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Adversarial', () => {
|
||||
test('should not allow accessing org template from a different organisation via getEnvelopeById', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
// Try to fetch via the standard envelope.get endpoint.
|
||||
const { res } = await trpcQuery(page, 'envelope.get', { envelopeId: orgTemplate.id }, outsiderTeam.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should not allow a sibling team member to fetch a private template via org endpoint', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Adversarial Private ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
// Attempt 1: Try via org template endpoint.
|
||||
const { res: orgRes } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: privateTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
expect(orgRes.ok()).toBeFalsy();
|
||||
|
||||
// Attempt 2: Try via standard envelope endpoint.
|
||||
const { res: envelopeRes } = await trpcQuery(page, 'envelope.get', { envelopeId: privateTemplate.id }, teamB.id);
|
||||
expect(envelopeRes.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should not list org templates from a completely unrelated organisation', async ({ page }) => {
|
||||
// Create scenario A.
|
||||
const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create scenario B (separate org, separate teams).
|
||||
const { memberB: memberFromOrgB, teamB: teamFromOrgB } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberFromOrgB.email });
|
||||
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamFromOrgB.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(orgTemplateA.title);
|
||||
});
|
||||
|
||||
test('should not allow unauthenticated access to org template endpoints', async ({ page }) => {
|
||||
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// No apiSignin — unauthenticated.
|
||||
|
||||
const { res: findRes } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
expect(findRes.ok()).toBeFalsy();
|
||||
expect(findRes.status()).toBe(401);
|
||||
|
||||
const { res: getRes } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
expect(getRes.ok()).toBeFalsy();
|
||||
expect(getRes.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should not return org template data via findTemplates (team endpoint)', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
// The standard findTemplates endpoint should NOT include org templates from other teams.
|
||||
const { json } = await trpcQuery(page, 'template.findTemplates', { page: 1, perPage: 50 }, teamB.id);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(orgTemplate.title);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: envelope.item.getManyByToken (org template fallback) ───────────────
|
||||
|
||||
test.describe('Organisation Templates - envelope.item.getManyByToken API', () => {
|
||||
test('should allow a sibling team member to fetch envelope items for an org template', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res, json } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const items = json.result.data.json.data;
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items[0].envelopeId).toBe(orgTemplate.id);
|
||||
});
|
||||
|
||||
test('should allow the owning team member to fetch envelope items (own-team path)', async ({ page }) => {
|
||||
const { ownerA, teamA, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { res, json } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
|
||||
teamA.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const items = json.result.data.json.data;
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items[0].envelopeId).toBe(orgTemplate.id);
|
||||
});
|
||||
|
||||
test('should reject a user outside the organisation', async ({ page }) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should reject fetching items for a PRIVATE template from a sibling team', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private Items ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: privateTemplate.id, access: { type: 'user' } },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should respect document visibility for the viewer team role', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Items Admin Only ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// memberB is a MEMBER on teamB — must not be able to read items for an ADMIN-only template.
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res: memberRes } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: adminOnlyTemplate.id, access: { type: 'user' } },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(memberRes.ok()).toBeFalsy();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// ownerA is ADMIN on teamA — should succeed via the own-team path.
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { res: adminRes, json: adminJson } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: adminOnlyTemplate.id, access: { type: 'user' } },
|
||||
teamA.id,
|
||||
);
|
||||
|
||||
expect(adminRes.ok()).toBeTruthy();
|
||||
expect(adminJson.result.data.json.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should reject unauthenticated callers using the user access type', async ({ page }) => {
|
||||
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// No apiSignin — unauthenticated.
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'envelope.item.getManyByToken',
|
||||
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
extractUserVerificationToken,
|
||||
seedTestEmail,
|
||||
seedUser,
|
||||
} from '@documenso/prisma/seed/users';
|
||||
import { extractUserVerificationToken, seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
@@ -59,24 +58,14 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
|
||||
});
|
||||
|
||||
// Find table row which does not have text 'Current' and click the button called Revoke within the row.
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasNotText: 'Current' })
|
||||
.nth(1)
|
||||
.getByRole('button', { name: 'Revoke' })
|
||||
.click();
|
||||
await page.getByRole('row').filter({ hasNotText: 'Current' }).nth(1).getByRole('button', { name: 'Revoke' }).click();
|
||||
await expectTextToBeVisible(page, 'Session revoked');
|
||||
|
||||
// Expect (1 sessions + 1 header) rows length
|
||||
await expect(page.getByRole('row')).toHaveCount(2);
|
||||
|
||||
// Revoke own session.
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasText: 'Current' })
|
||||
.first()
|
||||
.getByRole('button', { name: 'Revoke' })
|
||||
.click();
|
||||
await page.getByRole('row').filter({ hasText: 'Current' }).first().getByRole('button', { name: 'Revoke' }).click();
|
||||
|
||||
await expect(page).toHaveURL('/signin');
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user