Compare commits

...

2 Commits

Author SHA1 Message Date
bbf1dd3c6b fix: add tests 2025-11-03 20:30:35 +11:00
c10c95ca00 fix: add tests 2025-11-03 20:17:52 +11:00
10 changed files with 456 additions and 79 deletions

View File

@ -2,20 +2,27 @@ import { 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 { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentStatus,
DocumentVisibility,
EnvelopeType,
FieldType,
FolderType,
RecipientRole,
} from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsRequest } 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 { 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 { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@ -51,64 +58,354 @@ test.describe('API V2 Envelopes', () => {
}));
});
test.describe('Envelope create endpoint', () => {
test('should fail on invalid form', async ({ request }) => {
const payload = {
type: 'Invalid Type',
title: 'Test Envelope',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('should create envelope with single file', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Test Envelope',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUnique({
where: {
id: response.id,
},
include: {
envelopeItems: true,
},
});
expect(envelope).toBeDefined();
expect(envelope?.title).toBe('Test Envelope');
expect(envelope?.type).toBe(EnvelopeType.TEMPLATE);
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
expect(envelope?.envelopeItems.length).toBe(1);
expect(envelope?.envelopeItems[0].title).toBe('field-font-alignment.pdf');
expect(envelope?.envelopeItems[0].documentDataId).toBeDefined();
});
test('should create envelope with multiple file', async ({ request }) => {
const folder = await prisma.folder.create({
data: {
name: 'Test Folder',
teamId: teamA.id,
userId: userA.id,
type: FolderType.DOCUMENT,
},
});
const payload = {
title: 'Envelope Title',
type: EnvelopeType.DOCUMENT,
externalId: 'externalId',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
formValues: {
hello: 'world',
},
folderId: folder.id,
recipients: [
{
email: userA.email,
name: 'Name',
role: RecipientRole.SIGNER,
accessAuth: ['TWO_FACTOR_AUTH'],
signingOrder: 1,
fields: [
{
type: FieldType.SIGNATURE,
identifier: 'field-font-alignment.pdf',
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
{
type: FieldType.SIGNATURE,
identifier: 0,
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
],
},
],
meta: {
subject: 'Subject',
message: 'Message',
timezone: 'Europe/Berlin',
dateFormat: 'dd.MM.yyyy',
distributionMethod: DocumentDistributionMethod.NONE,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
redirectUrl: 'https://documenso.com',
language: 'de',
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: false,
emailReplyTo: userA.email,
emailSettings: {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: true,
},
},
attachments: [
{
label: 'Test Attachment',
data: 'https://documenso.com',
type: 'link',
},
],
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-meta.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf')),
},
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
// Should error since folder is not owned by the user.
const invalidRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(invalidRes.ok()).toBeFalsy();
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: response.id,
},
include: {
documentMeta: true,
envelopeItems: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
});
console.log(userB.email);
expect(envelope.envelopeItems.length).toBe(2);
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
expect(envelope.title).toBe(payload.title);
expect(envelope.type).toBe(payload.type);
expect(envelope.externalId).toBe(payload.externalId);
expect(envelope.visibility).toBe(payload.visibility);
expect(envelope.authOptions).toEqual({
globalAccessAuth: payload.globalAccessAuth,
globalActionAuth: [],
});
expect(envelope.formValues).toEqual(payload.formValues);
expect(envelope.folderId).toBe(payload.folderId);
expect(envelope.documentMeta.subject).toBe(payload.meta.subject);
expect(envelope.documentMeta.message).toBe(payload.meta.message);
expect(envelope.documentMeta.timezone).toBe(payload.meta.timezone);
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.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.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
expect([
{
label: envelope.envelopeAttachments[0].label,
data: envelope.envelopeAttachments[0].data,
type: envelope.envelopeAttachments[0].type,
},
]).toEqual(payload.attachments);
const field = envelope.fields[0];
const recipient = envelope.recipients[0];
expect({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.authOptions?.accessAuth,
}).toEqual(
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
);
expect({
type: field.type,
page: field.page,
positionX: field.positionX.toNumber(),
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',
]),
);
// Expect string based ID to work.
expect(field.envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-font-alignment.pdf')?.id,
);
// Expect index based ID to work.
expect(envelope.fields[1].envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-meta.pdf')?.id,
);
});
});
/**
* Creates envelopes with the two field test PDFs.
*/
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'))
.toString('base64');
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const fieldMetaPdf = fs
.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf'))
.toString('base64');
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const alignmentDocumentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: alignmentPdf,
initialData: alignmentPdf,
},
});
const formData = new FormData();
const documentId = await incrementDocumentId();
const documentMeta = await prisma.documentMeta.create({
data: {},
});
const createdEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion: 2,
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
title: `Envelope Full Field Test`,
status: 'DRAFT',
userId: userA.id,
teamId: teamA.id,
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: `Alignment Test`,
documentDataId: alignmentDocumentData.id,
order: 1,
},
},
},
include: {
envelopeItems: true,
},
title: 'Envelope Full Field Test',
} satisfies TCreateEnvelopePayload),
);
// Only add one file for now.
formData.append(
'files',
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
const response: TCreateEnvelopeResponse = await res.json();
const createdEnvelope: TGetEnvelopeResponse = await request
.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
})
.then(async (res) => await res.json());
// Might as well testing access control here as well.
const unauthRequest = await request.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(unauthRequest.ok()).toBeFalsy();
expect(unauthRequest.status()).toBe(404);
// Step 2: Create second envelope item via API
// Todo: Envelopes - Use API Route
const fieldMetaDocumentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: fieldMetaPdf,
initialData: fieldMetaPdf,
data: fieldMetaPdf.toString('base64'),
initialData: fieldMetaPdf.toString('base64'),
},
});
@ -133,7 +430,6 @@ test.describe('API V2 Envelopes', () => {
// Step 3: Update envelope via API
const updateEnvelopeRequest: TUpdateEnvelopeRequest = {
envelopeId: createdEnvelope.id,
envelopeType: EnvelopeType.DOCUMENT,
data: {
title: 'Envelope Full Field Test',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,

View File

@ -1,5 +1,7 @@
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';
@ -2858,6 +2860,86 @@ test.describe('Document API V2', () => {
});
});
test.describe('Document create endpoint', () => {
test('should allow authorized access to document create endpoint', async ({ request }) => {
const payload = {
title: 'Test Document',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfPath = path.join(__dirname, '../../../../../assets/example.pdf');
const pdfData = fs.existsSync(pdfPath) ? fs.readFileSync(pdfPath) : Buffer.from('%PDF-1.4\n');
formData.append('file', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const data = await res.json();
expect(data.envelopeId).toBeDefined();
expect(data.id).toBeDefined();
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: data.envelopeId,
},
include: {
envelopeItems: true,
},
});
expect(envelope?.title).toBe(payload.title);
expect(envelope.envelopeItems.length).toBe(1);
});
});
test.describe('Template create endpoint', () => {
test('should allow authorized access to template create endpoint', async ({ request }) => {
const payload = {
title: 'Test Template',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfPath = path.join(__dirname, '../../../../../assets/example.pdf');
const pdfData = fs.existsSync(pdfPath) ? fs.readFileSync(pdfPath) : Buffer.from('%PDF-1.4\n');
formData.append('file', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const data = await res.json();
expect(data.envelopeId).toBeDefined();
expect(data.id).toBeDefined();
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: data.envelopeId,
},
include: {
envelopeItems: true,
},
});
expect(envelope.title).toBe(payload.title);
expect(envelope.envelopeItems.length).toBe(1);
});
});
test.describe('Envelope API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;

View File

@ -70,7 +70,6 @@ export type CreateEnvelopeOptions = {
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
formValues?: TDocumentFormValues;
timezone?: string;
userTimezone?: string;
templateType?: TemplateType;
@ -107,7 +106,6 @@ export const createEnvelope = async ({
title,
externalId,
formValues,
timezone,
userTimezone,
folderId,
templateType,
@ -166,6 +164,7 @@ export const createEnvelope = async ({
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => {
@ -243,7 +242,7 @@ export const createEnvelope = async ({
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {

View File

@ -10,10 +10,12 @@ import { authenticatedProcedure } from '../trpc';
import {
ZCreateDocumentRequestSchema,
ZCreateDocumentResponseSchema,
createDocumentMeta,
} from './create-document.types';
export const createDocumentRoute = authenticatedProcedure
.input(ZCreateDocumentRequestSchema) // Note: Before releasing this to public, update the response schema to be correct.
.meta(createDocumentMeta)
.input(ZCreateDocumentRequestSchema)
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
@ -62,6 +64,6 @@ export const createDocumentRoute = authenticatedProcedure
return {
envelopeId: document.id,
legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId),
id: mapSecondaryIdToDocumentId(document.secondaryId),
};
});

View File

@ -8,7 +8,6 @@ import { zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
import { ZDocumentTitleSchema } from './schema';
// Currently not in use until we allow passthrough documents on create.
export const createDocumentMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
@ -42,7 +41,7 @@ export const ZCreateDocumentRequestSchema = zodFormData({
export const ZCreateDocumentResponseSchema = z.object({
envelopeId: z.string(),
legacyDocumentId: z.number(),
id: z.number(),
});
export type TCreateDocumentPayloadSchema = z.infer<typeof ZCreateDocumentPayloadSchema>;

View File

@ -10,15 +10,14 @@ import {
} from './create-envelope.types';
export const createEnvelopeRoute = authenticatedProcedure
// Todo: Envelopes - Pending direct uploads
// .meta({
// openapi: {
// method: 'POST',
// path: '/envelope/create',
// summary: 'Create envelope',
// tags: ['Envelope'],
// },
// })
.meta({
openapi: {
method: 'POST',
path: '/envelope/create',
summary: 'Create envelope',
tags: ['Envelope'],
},
})
.input(ZCreateEnvelopeRequestSchema)
.output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
@ -33,6 +32,7 @@ export const createEnvelopeRoute = authenticatedProcedure
visibility,
globalAccessAuth,
globalActionAuth,
formValues,
recipients,
folderId,
meta,
@ -121,6 +121,7 @@ export const createEnvelopeRoute = authenticatedProcedure
type,
title,
externalId,
formValues,
visibility,
globalAccessAuth,
globalActionAuth,
@ -130,7 +131,6 @@ export const createEnvelopeRoute = authenticatedProcedure
},
attachments,
meta,
normalizePdf: true,
requestMetadata: ctx.metadata,
});

View File

@ -10,11 +10,11 @@ import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-va
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import {
ZFieldHeightSchema,
ZClampedFieldHeightSchema,
ZClampedFieldPositionXSchema,
ZClampedFieldPositionYSchema,
ZClampedFieldWidthSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
@ -65,10 +65,10 @@ export const ZCreateEnvelopePayloadSchema = z.object({
)
.optional(),
page: ZFieldPageNumberSchema,
positionX: ZFieldPageXSchema,
positionY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
}),
)
.array()

View File

@ -28,8 +28,8 @@ export const ZCreateRecipientSchema = z.object({
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
});
export const ZUpdateRecipientSchema = z.object({
@ -38,8 +38,8 @@ export const ZUpdateRecipientSchema = z.object({
name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
});
export const ZCreateDocumentRecipientRequestSchema = z.object({

View File

@ -161,7 +161,6 @@ export const templateRouter = router({
*/
createTemplate: authenticatedProcedure
.meta({
// Note before releasing this to public, update the response schema to be correct.
openapi: {
method: 'POST',
path: '/template/create',
@ -207,7 +206,7 @@ export const templateRouter = router({
return {
envelopeId: envelope.id,
legacyTemplateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
};
}),

View File

@ -231,7 +231,7 @@ export const ZCreateTemplateV2ResponseSchema = z.object({
export const ZCreateTemplateResponseSchema = z.object({
envelopeId: z.string(),
legacyTemplateId: z.number(),
id: z.number(),
});
export const ZUpdateTemplateRequestSchema = z.object({