mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 00:32:29 +10:00
fix: wip
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } 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 { FieldType } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* Field insertion must respect the recipient's signing window: an expired
|
||||
* recipient can no longer act on the envelope at all. The V1 endpoints assert
|
||||
* this; the V2 `envelope.field.sign` route historically did not.
|
||||
*/
|
||||
|
||||
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number; value: string }) => {
|
||||
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({
|
||||
json: {
|
||||
token: input.token,
|
||||
fieldId: input.fieldId,
|
||||
fieldValue: {
|
||||
type: FieldType.TEXT,
|
||||
value: input.value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const seedV2PendingDocumentWithTextField = async () => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: signer } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [signer],
|
||||
fields: [FieldType.TEXT],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
const textField = recipient.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
if (!textField) {
|
||||
throw new Error('Seeded text field not found');
|
||||
}
|
||||
|
||||
return { recipient, textField };
|
||||
};
|
||||
|
||||
test('[ENVELOPE_FIELD_SIGN]: rejects field insertion for an expired recipient', async ({ page }) => {
|
||||
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
// Expired one hour ago.
|
||||
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// The seed pre-populates customText with a placeholder value.
|
||||
const fieldBefore = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
|
||||
|
||||
const response = await callSignEnvelopeField(page, {
|
||||
token: recipient.token,
|
||||
fieldId: textField.id,
|
||||
value: 'TEXT',
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe(fieldBefore.customText);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_FIELD_SIGN]: accepts field insertion for a recipient within their signing window', async ({ page }) => {
|
||||
// Positive control: proves the request format reaches the route, so the
|
||||
// expired-recipient rejection above cannot pass vacuously.
|
||||
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
// Expires an hour from now.
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callSignEnvelopeField(page, {
|
||||
token: recipient.token,
|
||||
fieldId: textField.id,
|
||||
value: 'TEXT',
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('TEXT');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
|
||||
|
||||
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
|
||||
|
||||
/**
|
||||
* `Recipient.signingOrder` is an Int column, but nothing constrained the input
|
||||
* to an integer. Prisma does not reject a fraction — it truncates it (1.5 -> 1),
|
||||
* so distinct orders could silently collapse onto the same value, which under
|
||||
* signing groups means "same step". Zero and negatives were persisted as-is and
|
||||
* sort ahead of everything, including the `?? 0` fallback in assistant scoping.
|
||||
*/
|
||||
|
||||
const createRecipient = async (request: APIRequestContext, token: string, envelopeId: string, signingOrder: number) =>
|
||||
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
data: [
|
||||
{
|
||||
email: `signing-order-${Date.now()}-${signingOrder}@documenso.com`,
|
||||
name: 'Signing Order Test',
|
||||
role: 'SIGNER',
|
||||
signingOrder,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test('[SIGNING_ORDER_VALIDATION]: rejects a fractional signing order with a client error', async ({ request }) => {
|
||||
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation' });
|
||||
|
||||
const response = await createRecipient(request, token, envelope.id, 1.5);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
// Nothing may be written — in particular not a silently truncated `1`.
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId: envelope.id } });
|
||||
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[SIGNING_ORDER_VALIDATION]: rejects a zero signing order', async ({ request }) => {
|
||||
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation zero' });
|
||||
|
||||
const response = await createRecipient(request, token, envelope.id, 0);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 0 } });
|
||||
|
||||
expect(persisted).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[SIGNING_ORDER_VALIDATION]: rejects a negative signing order', async ({ request }) => {
|
||||
const { envelope, token } = await apiSeedDraftDocument(request, {
|
||||
title: '[TEST] Signing order validation negative',
|
||||
});
|
||||
|
||||
const response = await createRecipient(request, token, envelope.id, -1);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: -1 } });
|
||||
|
||||
expect(persisted).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[SIGNING_ORDER_VALIDATION]: still accepts a valid positive integer signing order', async ({ request }) => {
|
||||
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation valid' });
|
||||
|
||||
const response = await createRecipient(request, token, envelope.id, 2);
|
||||
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
|
||||
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 2 } });
|
||||
|
||||
expect(persisted).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
|
||||
|
||||
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
|
||||
|
||||
/**
|
||||
* AES/QES envelopes cannot contain signing groups: two recipients sharing a
|
||||
* step sign in parallel, which breaks the per-recipient /ByteRange invariant
|
||||
* TSP signatures depend on. That rule previously lived only in the editor's
|
||||
* form schema, so the API would happily create the forbidden state.
|
||||
*
|
||||
* The signature level is seeded directly because `resolveSignatureLevel`
|
||||
* coerces AES/QES down to SES on a non-CSC instance, so it cannot be requested
|
||||
* through the API here.
|
||||
*/
|
||||
|
||||
const createRecipients = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
envelopeId: string,
|
||||
recipients: Array<{ signingOrder?: number }>,
|
||||
) =>
|
||||
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
data: recipients.map((recipient, index) => ({
|
||||
email: `tsp-grouping-${Date.now()}-${index}@documenso.com`,
|
||||
name: `TSP Recipient ${index}`,
|
||||
role: 'SIGNER',
|
||||
...recipient,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const seedEnvelopeAtSignatureLevel = async (request: APIRequestContext, signatureLevel: string) => {
|
||||
const { envelope, token } = await apiSeedDraftDocument(request, { title: `[TEST] ${signatureLevel} grouping` });
|
||||
|
||||
await prisma.envelope.update({ where: { id: envelope.id }, data: { signatureLevel } });
|
||||
|
||||
return { envelopeId: envelope.id, token };
|
||||
};
|
||||
|
||||
test('[TSP_GROUPING]: rejects two recipients sharing a signing order on an AES envelope', async ({ request }) => {
|
||||
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
|
||||
|
||||
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[TSP_GROUPING]: rejects a second recipient joining an existing step on an AES envelope', async ({ request }) => {
|
||||
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
|
||||
|
||||
const first = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
|
||||
|
||||
expect(first.ok(), await first.text()).toBeTruthy();
|
||||
|
||||
// The payload alone looks fine — only the resulting set reveals the group.
|
||||
const second = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
|
||||
|
||||
expect(second.status()).toBe(400);
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
expect(recipients).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('[TSP_GROUPING]: rejects two recipients without a signing order on a QES envelope', async ({ request }) => {
|
||||
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'QES');
|
||||
|
||||
// Both land in the same tail step, so they would sign in parallel.
|
||||
const response = await createRecipients(request, token, envelopeId, [{}, {}]);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[TSP_GROUPING]: accepts distinct signing orders on an AES envelope', async ({ request }) => {
|
||||
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
|
||||
|
||||
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 2 }]);
|
||||
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
expect(recipients).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('[TSP_GROUPING]: still allows signing groups on an SES envelope', async ({ request }) => {
|
||||
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'SES');
|
||||
|
||||
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
|
||||
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([1, 1]);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { signDirectSignaturePad } from '../fixtures/signature';
|
||||
|
||||
/**
|
||||
* An assistant sharing a signing step with an unsigned peer cannot dictate
|
||||
* the next signer: the flow does not advance until the whole step completes,
|
||||
* so the server ignores any dictated identity. The signing page must
|
||||
* therefore not OFFER dictation in that state — historically it did, because
|
||||
* the assistant's recipient list excludes their own group peers, and the
|
||||
* client derived dictation eligibility from that truncated list while the
|
||||
* server decided from the full one.
|
||||
*/
|
||||
test('[NEXT_RECIPIENT_DICTATION]: assistant with an unsigned group peer is not offered dictation', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: assistant } = await seedUser();
|
||||
const { user: peerSigner } = await seedUser();
|
||||
const { user: lastSigner } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [assistant, peerSigner, lastSigner],
|
||||
recipientsCreateOptions: [
|
||||
// The assistant shares step 1 with an unsigned peer; step 2 holds a
|
||||
// single recipient — the exact shape where dictation looks available
|
||||
// from the assistant's truncated recipient list.
|
||||
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
|
||||
{ signingOrder: 1, role: RecipientRole.SIGNER },
|
||||
{ signingOrder: 2, role: RecipientRole.SIGNER },
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
update: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const assistantRecipient = recipients[0];
|
||||
const lastRecipient = recipients[2];
|
||||
|
||||
const signUrl = `/sign/${assistantRecipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByRole('radio', { name: assistantRecipient.name }).click();
|
||||
|
||||
// Fill in the assistant's own fields.
|
||||
for (const field of assistantRecipient.fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.SIGNATURE) {
|
||||
await signDirectSignaturePad(page);
|
||||
await page.getByRole('button', { name: 'Sign', exact: true }).click();
|
||||
}
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The unsigned peer blocks advancement, so dictation must not be offered.
|
||||
await expect(dialog.getByText('The next recipient to sign this document will be')).not.toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||
|
||||
// Later recipients' fields are still uninserted, so the confirm button
|
||||
// reads "Proceed" rather than "Continue".
|
||||
await dialog.getByRole('button', { name: /Continue|Proceed/ }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// The assistant completed; nobody was renamed and the flow did not advance
|
||||
// past the unsigned peer.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const assistantAfter = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: assistantRecipient.id },
|
||||
});
|
||||
|
||||
return assistantAfter.signingStatus;
|
||||
})
|
||||
.toBe(SigningStatus.SIGNED);
|
||||
|
||||
const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: lastRecipient.id } });
|
||||
|
||||
expect(lastAfter.name).toBe(lastRecipient.name);
|
||||
expect(lastAfter.email).toBe(lastRecipient.email);
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } });
|
||||
|
||||
expect(envelope.status).toBe('PENDING');
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } 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 { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* A recipient without a signing order sits in the LAST step — the convention
|
||||
* `effectiveOrder` encodes and the server sorts by (NULLS LAST). The
|
||||
* assistant scoping filters must agree with it:
|
||||
*
|
||||
* - an ordered assistant may assist a null-order recipient (they are in the
|
||||
* strictly later tail step), and
|
||||
* - a null-order assistant may assist NOBODY (nobody comes after the last
|
||||
* step) — historically `signingOrder ?? 0` treated them as FIRST, letting
|
||||
* their token prefill every ordered recipient's fields.
|
||||
*
|
||||
* Null orders are only produced via the API, which is why no editor-driven
|
||||
* test covers this.
|
||||
*/
|
||||
|
||||
const seedAssistantDocument = async (options: {
|
||||
assistantOrder: number | null;
|
||||
signerOrder: number | null;
|
||||
internalVersion?: number;
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: assistantUser } = await seedUser();
|
||||
const { user: signerUser } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [assistantUser, signerUser],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: options.assistantOrder, role: RecipientRole.ASSISTANT },
|
||||
{ signingOrder: options.signerOrder, role: RecipientRole.SIGNER },
|
||||
],
|
||||
fields: [FieldType.TEXT],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: options.internalVersion ?? 1,
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The seed returns recipients ordered by signingOrder (nulls last), so
|
||||
// positional destructuring would swap roles — select by role instead.
|
||||
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
|
||||
const signer = recipients.find((recipient) => recipient.role === RecipientRole.SIGNER);
|
||||
|
||||
if (!assistant || !signer) {
|
||||
throw new Error('Seeded recipients not found');
|
||||
}
|
||||
|
||||
const signerTextField = signer.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
if (!signerTextField) {
|
||||
throw new Error('Seeded text field not found');
|
||||
}
|
||||
|
||||
return { assistant, signer, signerTextField };
|
||||
};
|
||||
|
||||
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number }) => {
|
||||
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({
|
||||
json: {
|
||||
token: input.token,
|
||||
fieldId: input.fieldId,
|
||||
fieldValue: {
|
||||
type: FieldType.TEXT,
|
||||
value: 'TEXT',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: an ordered assistant can assist a null-order (tail-step) recipient', async () => {
|
||||
const { assistant, signer, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: 1,
|
||||
signerOrder: null,
|
||||
});
|
||||
|
||||
// The tail-step recipient is strictly later, so they must be assistable.
|
||||
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||
|
||||
expect(assistableRecipients.map((recipient) => recipient.id)).toContain(signer.id);
|
||||
|
||||
// Their non-signature fields must be visible to the assistant.
|
||||
const fields = await getFieldsForToken({ token: assistant.token });
|
||||
|
||||
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
|
||||
|
||||
// And prefillable.
|
||||
await signFieldWithToken({
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
value: 'TEXT',
|
||||
});
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
});
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: a null-order (tail-step) assistant cannot assist anyone', async () => {
|
||||
const { assistant, signer, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: null,
|
||||
signerOrder: 1,
|
||||
});
|
||||
|
||||
// The null-order assistant sits in the last step: nobody comes after them.
|
||||
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||
|
||||
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
|
||||
|
||||
// Every ordered recipient is in an EARLIER step — prefilling must fail.
|
||||
await expect(
|
||||
signFieldWithToken({
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
value: 'TEXT',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.id).not.toBe(signer.id); // sanity: distinct entities
|
||||
});
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: V2 route allows an ordered assistant to prefill a null-order recipient', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { assistant, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: 1,
|
||||
signerOrder: null,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
const response = await callSignEnvelopeField(page, {
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
});
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: V2 route rejects a null-order assistant prefilling an ordered recipient', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { assistant, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: null,
|
||||
signerOrder: 1,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
const response = await callSignEnvelopeField(page, {
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
type SeededGroupEnvelope = {
|
||||
assistantToken: string;
|
||||
assistantOwnTextFieldId: number;
|
||||
peerTextFieldId: number;
|
||||
peerSignatureFieldId: number;
|
||||
laterTextFieldId: number;
|
||||
laterSignatureFieldId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds a pending SEQUENTIAL envelope where the ASSISTANT shares a signing
|
||||
* step (duplicate signingOrder) with a SIGNER:
|
||||
*
|
||||
* - Step 1: ASSISTANT (own TEXT field) + "Peer Signer" (SIGNATURE + TEXT).
|
||||
* - Step 2: "Later Signer" (SIGNATURE + TEXT).
|
||||
*
|
||||
* Product rule under signing groups: assistants only assist STRICTLY LATER
|
||||
* steps — never their own group peers — and never insert SIGNATURE fields
|
||||
* belonging to anyone else.
|
||||
*/
|
||||
const seedGroupedAssistantEnvelope = async (request: APIRequestContext): Promise<SeededGroupEnvelope> => {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const peerEmail = `peer-signer-${timestamp}@documenso.com`;
|
||||
const laterEmail = `later-signer-${timestamp}@documenso.com`;
|
||||
|
||||
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
title: '[TEST] Grouped assistant envelope',
|
||||
meta: {
|
||||
signingOrder: 'SEQUENTIAL',
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
email: `assistant-${timestamp}@documenso.com`,
|
||||
name: 'Assistant',
|
||||
role: 'ASSISTANT',
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: peerEmail,
|
||||
name: 'Peer Signer',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: laterEmail,
|
||||
name: 'Later Signer',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
fieldsPerRecipient: [
|
||||
[{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 25, width: 5, height: 5 },
|
||||
],
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 35, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 45, width: 5, height: 5 },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const assistant = distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
|
||||
const peer = distributeResult.recipients.find((r) => r.email === peerEmail);
|
||||
const later = distributeResult.recipients.find((r) => r.email === laterEmail);
|
||||
|
||||
if (!assistant || !peer || !later) {
|
||||
throw new Error('Seeded recipients not found');
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
const findField = (recipientId: number, type: FieldType) => {
|
||||
const field = fields.find((f) => f.recipientId === recipientId && f.type === type);
|
||||
|
||||
if (!field) {
|
||||
throw new Error(`Field ${type} not found for recipient ${recipientId}`);
|
||||
}
|
||||
|
||||
return field;
|
||||
};
|
||||
|
||||
return {
|
||||
assistantToken: assistant.token,
|
||||
assistantOwnTextFieldId: findField(assistant.id, FieldType.TEXT).id,
|
||||
peerTextFieldId: findField(peer.id, FieldType.TEXT).id,
|
||||
peerSignatureFieldId: findField(peer.id, FieldType.SIGNATURE).id,
|
||||
laterTextFieldId: findField(later.id, FieldType.TEXT).id,
|
||||
laterSignatureFieldId: findField(later.id, FieldType.SIGNATURE).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_GROUPS]: same-step (group peer) field access', () => {
|
||||
test('field.signFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
|
||||
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: peerTextFieldId,
|
||||
value: 'TEXT',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: peerTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe('');
|
||||
});
|
||||
|
||||
test('field.removeSignedFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
|
||||
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
// Pre-insert the peer's field so a successful (incorrect) uninsert is detectable.
|
||||
await prisma.field.update({
|
||||
where: { id: peerTextFieldId },
|
||||
data: { inserted: true, customText: 'pre-existing-value' },
|
||||
});
|
||||
|
||||
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: peerTextFieldId,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: peerTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('pre-existing-value');
|
||||
});
|
||||
|
||||
test('envelope.field.sign (V2) rejects a group peer field', async ({ request }) => {
|
||||
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: assistantToken,
|
||||
fieldId: peerTextFieldId,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: peerTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
});
|
||||
|
||||
test('getFieldsForToken excludes group peer fields but keeps own and later-step fields', async ({ request }) => {
|
||||
const {
|
||||
assistantToken,
|
||||
assistantOwnTextFieldId,
|
||||
peerTextFieldId,
|
||||
peerSignatureFieldId,
|
||||
laterTextFieldId,
|
||||
laterSignatureFieldId,
|
||||
} = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const fields = await getFieldsForToken({ token: assistantToken });
|
||||
const fieldIds = fields.map((field) => field.id);
|
||||
|
||||
// Own fields and strictly-later non-signature fields remain visible.
|
||||
expect(fieldIds).toContain(assistantOwnTextFieldId);
|
||||
expect(fieldIds).toContain(laterTextFieldId);
|
||||
|
||||
// Group peer fields are never visible to the assistant.
|
||||
expect(fieldIds).not.toContain(peerTextFieldId);
|
||||
expect(fieldIds).not.toContain(peerSignatureFieldId);
|
||||
|
||||
// Signature fields of other recipients are never visible to the assistant.
|
||||
expect(fieldIds).not.toContain(laterSignatureFieldId);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ASSISTANT_SIGNING_GROUPS]: signature fields of other recipients', () => {
|
||||
test('field.signFieldWithToken (V1) rejects inserting a later recipient signature field', async ({ request }) => {
|
||||
const { assistantToken, laterSignatureFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: laterSignatureFieldId,
|
||||
value: 'John Doe',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: laterSignatureFieldId },
|
||||
include: { signature: true },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.signature).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ASSISTANT_SIGNING_GROUPS]: preserved assistant abilities', () => {
|
||||
test('field.signFieldWithToken (V1) still allows filling the assistant own field', async ({ request }) => {
|
||||
const { assistantToken, assistantOwnTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: assistantOwnTextFieldId,
|
||||
value: 'MY OWN TEXT',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: assistantOwnTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('MY OWN TEXT');
|
||||
});
|
||||
|
||||
test('field.signFieldWithToken (V1) still allows prefilling a later recipient text field', async ({ request }) => {
|
||||
const { assistantToken, laterTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: laterTextFieldId,
|
||||
value: 'PREFILLED FOR LATER SIGNER',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: laterTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('PREFILLED FOR LATER SIGNER');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
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, SendStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dictation lets a signer rewrite who signs next. It cannot be allowed to
|
||||
* operate on a signing group, for two reasons the server enforces separately:
|
||||
*
|
||||
* 1. The next step must hold exactly one recipient (`nextGroup.length === 1`),
|
||||
* otherwise there is no single "next signer" to rewrite.
|
||||
* 2. A signer whose own step is still pending (a peer has not signed) does not
|
||||
* advance the flow at all, so they never reach the dictation branch.
|
||||
*
|
||||
* Both are silent — passing `nextSigner` into a state that disallows dictation
|
||||
* is ignored rather than rejected — which is exactly why they need asserting.
|
||||
* The existing dictation specs all drive the UI and none use a grouped step.
|
||||
*/
|
||||
|
||||
const DICTATED_SIGNER = {
|
||||
name: 'Dictated Signer',
|
||||
email: 'dictated-signer@example.com',
|
||||
};
|
||||
|
||||
const expectRecipientUpdatedAuditLogCount = async (envelopeId: string, expected: number) => {
|
||||
const auditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLogs.length).toBe(expected);
|
||||
};
|
||||
|
||||
const seedDictationDocument = async (signingOrders: number[]) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const signers = await Promise.all(signingOrders.map(async () => (await seedUser()).user));
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: signers,
|
||||
recipientsCreateOptions: signingOrders.map((signingOrder) => ({
|
||||
signingOrder,
|
||||
// The seed defaults every recipient to SENT; later steps of a real
|
||||
// SEQUENTIAL document are NOT_SENT until their step unlocks.
|
||||
sendStatus: signingOrder === 1 ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
})),
|
||||
// No fields, so completion is not blocked by unsigned required fields.
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
update: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return signers.map((signer) => {
|
||||
const recipient = recipients.find((item) => item.email === signer.email);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(`Seeded recipient ${signer.email} not found`);
|
||||
}
|
||||
|
||||
return recipient;
|
||||
});
|
||||
};
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: dictation is ignored when the next step is a group', async () => {
|
||||
// Steps: 1 = first, 2 = two grouped recipients.
|
||||
const [first, groupA, groupB] = await seedDictationDocument([1, 2, 2]);
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: first.token,
|
||||
id: { type: 'envelopeId', id: first.envelopeId },
|
||||
nextSigner: DICTATED_SIGNER,
|
||||
});
|
||||
|
||||
const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } });
|
||||
const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } });
|
||||
|
||||
// Neither member of the group may be rewritten.
|
||||
expect(groupAAfter.email).toBe(groupA.email);
|
||||
expect(groupAAfter.name).toBe(groupA.name);
|
||||
expect(groupBAfter.email).toBe(groupB.email);
|
||||
expect(groupBAfter.name).toBe(groupB.name);
|
||||
|
||||
// The dictated identity must not have leaked onto anyone.
|
||||
const dictated = await prisma.recipient.findFirst({
|
||||
where: { envelopeId: first.envelopeId, email: DICTATED_SIGNER.email },
|
||||
});
|
||||
|
||||
expect(dictated).toBeNull();
|
||||
|
||||
// A rewrite that did not happen must not be recorded as having happened.
|
||||
await expectRecipientUpdatedAuditLogCount(first.envelopeId, 0);
|
||||
|
||||
// The group is still activated as normal — only the rewrite is suppressed.
|
||||
expect(groupAAfter.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(groupBAfter.sendStatus).toBe(SendStatus.SENT);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: a group member cannot dictate while a peer is still unsigned', async () => {
|
||||
// Steps: 1 = two grouped recipients, 2 = last.
|
||||
const [groupA, groupB, last] = await seedDictationDocument([1, 1, 2]);
|
||||
|
||||
// The first member of the group signs while their peer is still outstanding.
|
||||
await completeDocumentWithToken({
|
||||
token: groupA.token,
|
||||
id: { type: 'envelopeId', id: groupA.envelopeId },
|
||||
nextSigner: DICTATED_SIGNER,
|
||||
});
|
||||
|
||||
const lastWhilePeerPending = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: last.id },
|
||||
});
|
||||
|
||||
// The step never unlocked, so there was nothing to dictate.
|
||||
expect(lastWhilePeerPending.email).toBe(last.email);
|
||||
expect(lastWhilePeerPending.name).toBe(last.name);
|
||||
expect(lastWhilePeerPending.sendStatus).toBe(SendStatus.NOT_SENT);
|
||||
await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 0);
|
||||
|
||||
// The peer completing the group *does* advance to a single-recipient step,
|
||||
// so dictation applies — the positive control for the assertions above.
|
||||
await completeDocumentWithToken({
|
||||
token: groupB.token,
|
||||
id: { type: 'envelopeId', id: groupB.envelopeId },
|
||||
nextSigner: DICTATED_SIGNER,
|
||||
});
|
||||
|
||||
const lastAfterGroupComplete = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: last.id },
|
||||
});
|
||||
|
||||
expect(lastAfterGroupComplete.email).toBe(DICTATED_SIGNER.email);
|
||||
expect(lastAfterGroupComplete.name).toBe(DICTATED_SIGNER.name);
|
||||
expect(lastAfterGroupComplete.sendStatus).toBe(SendStatus.SENT);
|
||||
await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 1);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
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, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* A viewer's completion dialog activates the next-signer validator from
|
||||
* `allowDictateNextSigner` alone, while the name/email inputs only render
|
||||
* when a dictatable next recipient exists. When dictation is enabled but the
|
||||
* next step is not dictatable (a group of two or more, or the viewer is
|
||||
* last), submission must still work — historically it failed Zod validation
|
||||
* on the hidden inputs and "Mark as Viewed" silently did nothing.
|
||||
*/
|
||||
|
||||
const seedViewerDictationDocument = async (
|
||||
recipientsCreateOptions: {
|
||||
signingOrder: number;
|
||||
role?: RecipientRole;
|
||||
sendStatus?: SendStatus;
|
||||
}[],
|
||||
) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const signers = await Promise.all(recipientsCreateOptions.map(async () => (await seedUser()).user));
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: signers,
|
||||
recipientsCreateOptions,
|
||||
// No fields, so completion is not blocked by unsigned required fields.
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
update: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { document, recipients };
|
||||
};
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: viewer can mark as viewed when the next step is a group', async ({ page }) => {
|
||||
const { document, recipients } = await seedViewerDictationDocument([
|
||||
{ signingOrder: 1, role: RecipientRole.VIEWER },
|
||||
// The next step is a group of two, so there is no single dictatable next
|
||||
// recipient — the dialog must not demand one.
|
||||
{ signingOrder: 2, sendStatus: SendStatus.NOT_SENT },
|
||||
{ signingOrder: 2, sendStatus: SendStatus.NOT_SENT },
|
||||
]);
|
||||
|
||||
const [viewer, groupA, groupB] = recipients;
|
||||
|
||||
const signUrl = `/sign/${viewer.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'View Document' })).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
// Retry the click: it can land before hydration attaches the handler.
|
||||
await expect(async () => {
|
||||
await page.getByRole('button', { name: 'Mark as viewed', exact: true }).click();
|
||||
await expect(dialog).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
|
||||
// No dictation inputs: a group cannot be dictated over.
|
||||
await expect(dialog.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Mark as Viewed', exact: true }).click();
|
||||
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// The viewer completed and the group's step unlocked.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updatedRecipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: document.id },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
return updatedRecipients.map((recipient) => [recipient.signingStatus, recipient.sendStatus]);
|
||||
})
|
||||
.toEqual([
|
||||
[SigningStatus.SIGNED, SendStatus.SENT],
|
||||
[SigningStatus.NOT_SIGNED, SendStatus.SENT],
|
||||
[SigningStatus.NOT_SIGNED, SendStatus.SENT],
|
||||
]);
|
||||
|
||||
// Nobody was renamed: no next-signer values existed to apply.
|
||||
const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } });
|
||||
const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } });
|
||||
|
||||
expect(groupAAfter.email).toBe(groupA.email);
|
||||
expect(groupBAfter.email).toBe(groupB.email);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: viewer can mark as viewed when they are the last recipient', async ({ page }) => {
|
||||
const { recipients } = await seedViewerDictationDocument([
|
||||
{ signingOrder: 1 },
|
||||
{ signingOrder: 2, role: RecipientRole.VIEWER, sendStatus: SendStatus.NOT_SENT },
|
||||
]);
|
||||
|
||||
const [signer, viewer] = recipients;
|
||||
|
||||
// Advance the flow to the viewer's turn.
|
||||
await completeDocumentWithToken({
|
||||
token: signer.token,
|
||||
id: { type: 'envelopeId', id: signer.envelopeId },
|
||||
});
|
||||
|
||||
const signUrl = `/sign/${viewer.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'View Document' })).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
// Retry the click: it can land before hydration attaches the handler.
|
||||
await expect(async () => {
|
||||
await page.getByRole('button', { name: 'Mark as viewed', exact: true }).click();
|
||||
await expect(dialog).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
|
||||
// No dictation inputs: there is nobody after the viewer.
|
||||
await expect(dialog.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Mark as Viewed', exact: true }).click();
|
||||
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewerAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: viewer.id } });
|
||||
|
||||
return viewerAfter.signingStatus;
|
||||
})
|
||||
.toBe(SigningStatus.SIGNED);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
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';
|
||||
|
||||
test('[LEGACY_EDITOR]: document legacy editor redirects to the V2 envelope', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelope = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
// `page.goto` follows redirects and would report the destination's 200, so
|
||||
// assert the redirect itself through the (cookie-sharing) request context.
|
||||
const response = await page.request.get(`/t/${team.url}/documents/${envelope.id}/legacy_editor`, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers().location).toContain(`/t/${team.url}/documents/${envelope.id}/edit`);
|
||||
});
|
||||
|
||||
test('[LEGACY_EDITOR]: template legacy editor redirects to the V2 envelope', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelope = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
// `page.goto` follows redirects and would report the destination's 200, so
|
||||
// assert the redirect itself through the (cookie-sharing) request context.
|
||||
const response = await page.request.get(`/t/${team.url}/templates/${envelope.id}/legacy_editor`, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers().location).toContain(`/t/${team.url}/templates/${envelope.id}/edit`);
|
||||
});
|
||||
|
||||
test('[LEGACY_EDITOR]: document legacy editor still loads a V1 envelope', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelope = await seedBlankDocument(user, team.id, { internalVersion: 1 });
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const response = await page.goto(`/t/${team.url}/documents/${envelope.id}/legacy_editor`);
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('[LEGACY_EDITOR]: template legacy editor still loads a V1 envelope', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelope = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: { internalVersion: 1 },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const response = await page.goto(`/t/${team.url}/templates/${envelope.id}/legacy_editor`);
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
assertRecipientRole,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRows,
|
||||
getSigningOrderInputs,
|
||||
getRecipientStepCards,
|
||||
openDocumentEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
@@ -34,14 +34,14 @@ const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
|
||||
await assertRecipientRole(root, 1, 'Needs to sign');
|
||||
await assertRecipientRole(root, 2, 'Receives copy');
|
||||
|
||||
// Only the two signers have signing order inputs, showing 1 and 2.
|
||||
await expect(getSigningOrderInputs(root)).toHaveCount(2);
|
||||
await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2');
|
||||
// Only the two signers render as ordered group cards, showing groups 1 and 2.
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(2);
|
||||
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(root.getByText('Group 2', { exact: true })).toBeVisible();
|
||||
|
||||
// The CC row itself renders no signing order input (placeholder div instead).
|
||||
// The CC row itself renders outside the group cards with no drag handle.
|
||||
const ccRow = getRecipientRows(root).nth(2);
|
||||
await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0);
|
||||
await expect(ccRow.locator('[data-testid="recipient-row-drag-handle"]')).toHaveCount(0);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
@@ -61,8 +61,8 @@ test.describe('document editor', () => {
|
||||
await setRecipientName(root, 1, CC_RECIPIENT.name);
|
||||
await setRecipientRole(root, 1, 'Receives copy');
|
||||
|
||||
// Once the row becomes CC, its signing order input disappears.
|
||||
await expect(getSigningOrderInputs(root)).toHaveCount(1);
|
||||
// Once the row becomes CC, it drops out of the ordered group cards.
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(1);
|
||||
|
||||
// Add signer B third. The new row is inserted before the CC recipient,
|
||||
// which is kept last by the client-side sorting.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder } from '@prisma/client';
|
||||
|
||||
import {
|
||||
clickAddSignerButton,
|
||||
dragGroupCardOntoCard,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientStepCards,
|
||||
moveGroupCardUp,
|
||||
openDocumentEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
sweepRecipientRowOverCard,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
/**
|
||||
* Recipient signing groups are an SES feature: on AES/QES (CSC-mode)
|
||||
* instances every signing recipient must hold a distinct signing order, so
|
||||
* the editor must not offer the group affordances (card combine, row-to-card
|
||||
* join) while still allowing step reordering and ungrouping of invalid
|
||||
* API-created state.
|
||||
*/
|
||||
|
||||
const GROUP_BADGE_TEXT = '2 recipients · any order';
|
||||
|
||||
/**
|
||||
* Forces the client bundle into CSC mode for this page.
|
||||
*
|
||||
* `IS_INSTANCE_CSC_MODE()` reads `window.__ENV__.NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC`
|
||||
* on the client, and `window.__ENV__` is assigned by an inline script during
|
||||
* hydration — the property trap rewrites the flag whenever that assignment
|
||||
* happens, regardless of script ordering.
|
||||
*
|
||||
* Passed as a raw string: the test runner's esbuild transform decorates
|
||||
* serialized functions with `__name` helper calls that don't exist in the
|
||||
* browser, which would make the script throw before installing the trap.
|
||||
*/
|
||||
const forceCscClientMode = async (page: Page) => {
|
||||
await page.addInitScript(
|
||||
`(() => {
|
||||
let currentEnv;
|
||||
|
||||
Object.defineProperty(window, '__ENV__', {
|
||||
configurable: true,
|
||||
get: () => currentEnv,
|
||||
set: (value) => {
|
||||
currentEnv = { ...value, NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: 'true' };
|
||||
},
|
||||
});
|
||||
})();`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CSC envelopes are always SEQUENTIAL, but the seeded blank document defaults
|
||||
* to PARALLEL and the signing-order toggle is hidden in CSC mode — flip the
|
||||
* meta directly and reload so the editor renders the sequential step UI.
|
||||
*/
|
||||
const makeEnvelopeSequential = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (!surface.envelopeId) {
|
||||
throw new Error('Expected surface to have an envelope ID');
|
||||
}
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: { id: surface.envelopeId },
|
||||
data: {
|
||||
documentMeta: {
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await surface.root.reload();
|
||||
};
|
||||
|
||||
const setupTwoSequentialSigners = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(2);
|
||||
};
|
||||
|
||||
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
|
||||
const { envelopeId } = surface;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected surface to have an envelope ID');
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
|
||||
};
|
||||
|
||||
test.describe('document editor (csc mode)', () => {
|
||||
test('csc: merging step cards into a group is unavailable', async ({ page }) => {
|
||||
await forceCscClientMode(page);
|
||||
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await makeEnvelopeSequential(surface);
|
||||
await setupTwoSequentialSigners(surface);
|
||||
|
||||
// The keyboard combine helper throws when the target card never enters
|
||||
// the combine state — exactly what "combining is disabled" looks like.
|
||||
await expect(dragGroupCardOntoCard(surface.root, 1, 0)).rejects.toThrow(
|
||||
'Combine drag did not reach the target card',
|
||||
);
|
||||
|
||||
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible();
|
||||
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('csc: dropping a recipient row onto a card does not join the group', async ({ page }) => {
|
||||
await forceCscClientMode(page);
|
||||
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await makeEnvelopeSequential(surface);
|
||||
await setupTwoSequentialSigners(surface);
|
||||
|
||||
const sweep = await sweepRecipientRowOverCard(surface.root, 1, 0);
|
||||
|
||||
// The gap zones activating proves the drag itself was live, so the card
|
||||
// staying inactive is a real refusal rather than a failed gesture.
|
||||
expect(sweep.sawGapActive).toBe(true);
|
||||
expect(sweep.sawCardActive).toBe(false);
|
||||
expect(sweep.dropped).toBe(false);
|
||||
|
||||
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible();
|
||||
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('csc: step cards can still be reordered', async ({ page }) => {
|
||||
await forceCscClientMode(page);
|
||||
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await makeEnvelopeSequential(surface);
|
||||
await setupTwoSequentialSigners(surface);
|
||||
|
||||
await moveGroupCardUp(surface.root, 1);
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue('bob@example.com');
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue('alice@example.com');
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 2],
|
||||
['bob@example.com', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('csc: an existing group can still be ungrouped', async ({ page }) => {
|
||||
await forceCscClientMode(page);
|
||||
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
if (!surface.envelopeId) {
|
||||
throw new Error('Expected surface to have an envelope ID');
|
||||
}
|
||||
|
||||
// A signing group can only exist on a CSC envelope through out-of-band
|
||||
// writes (API-created state); ungrouping must stay available to repair it.
|
||||
await prisma.recipient.createMany({
|
||||
data: [
|
||||
{
|
||||
envelopeId: surface.envelopeId,
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
token: nanoid(),
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
envelopeId: surface.envelopeId,
|
||||
email: 'bob@example.com',
|
||||
name: 'Bob',
|
||||
token: nanoid(),
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await makeEnvelopeSequential(surface);
|
||||
|
||||
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).toBeVisible();
|
||||
|
||||
const ungroupButton = surface.root.getByTestId('ungroup-step-button');
|
||||
|
||||
await expect(ungroupButton).toBeEnabled();
|
||||
await ungroupButton.click();
|
||||
|
||||
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible();
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('document editor (non-csc control)', () => {
|
||||
// Control test proving `dragRecipientRowOntoCard` performs a real join when
|
||||
// grouping is available — without it the disabled-join test above could
|
||||
// pass vacuously because the drag itself silently failed.
|
||||
test('control: dropping a recipient row onto a card joins the group', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(2);
|
||||
|
||||
// The mouse-driven join drag is timing-sensitive under load, so retry the
|
||||
// whole gesture until the group forms; a cancelled sweep leaves the order
|
||||
// untouched, and a completed drop joins the two rows into one step.
|
||||
await expect(async () => {
|
||||
const sweep = await sweepRecipientRowOverCard(root, 1, 0);
|
||||
|
||||
expect(sweep.dropped).toBe(true);
|
||||
|
||||
await expect(root.getByText(GROUP_BADGE_TEXT)).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass({ timeout: 90_000 });
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickAddSignerButton,
|
||||
dragGroupCardOntoCard,
|
||||
dragRecipientRowToGap,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientStepCards,
|
||||
moveGroupCardUp,
|
||||
openDocumentEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
|
||||
const { envelopeId } = surface;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected surface to have an envelope ID');
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
|
||||
};
|
||||
|
||||
const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
await setRecipientName(root, 0, 'Alice');
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 2, 'carol@example.com');
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
|
||||
// Three standalone groups.
|
||||
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
|
||||
|
||||
// Drag carol's card onto bob's card to merge them into one group.
|
||||
await dragGroupCardOntoCard(root, 2, 1);
|
||||
|
||||
await expect(root.getByText('2 recipients · any order')).toBeVisible();
|
||||
await expect(root.getByTestId('ungroup-step-button')).toBeVisible();
|
||||
await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible();
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
['carol@example.com', 2],
|
||||
]);
|
||||
|
||||
// Groups survive a reload (grouped normalization on load).
|
||||
await root.reload();
|
||||
await expect(root.getByText('2 recipients · any order')).toBeVisible();
|
||||
|
||||
// Ungroup dissolves back into sequential groups.
|
||||
await root.getByTestId('ungroup-step-button').click();
|
||||
|
||||
await expect(root.getByText('2 recipients · any order')).not.toBeVisible();
|
||||
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
['carol@example.com', 3],
|
||||
]);
|
||||
|
||||
// Drag bob's row into the gap after the last group, moving him to the end.
|
||||
await dragRecipientRowToGap(root, 1, 3);
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 3],
|
||||
['carol@example.com', 2],
|
||||
]);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('documents: group recipients via drag and drop and ungroup', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await runGroupingFlow(surface);
|
||||
});
|
||||
|
||||
test('documents: reordered group cards can still be dragged', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(2);
|
||||
|
||||
// Move bob's card into position 1.
|
||||
await moveGroupCardUp(root, 1);
|
||||
|
||||
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue('bob@example.com');
|
||||
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue('alice@example.com');
|
||||
|
||||
// Regression: after a reorder, the card moved into position 2 must still
|
||||
// be draggable — positional drag-and-drop ids used to go stale on mounted
|
||||
// cards, silently killing their drag handles. Prove it by completing a
|
||||
// merge with the repositioned card.
|
||||
await dragGroupCardOntoCard(root, 1, 0);
|
||||
|
||||
await expect(root.getByText('2 recipients · any order')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('templates: group recipients via drag and drop and ungroup', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
await runGroupingFlow(surface);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickAddSignerButton,
|
||||
openDocumentEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
/**
|
||||
* A newly added recipient is created by the first autosave, and the editor
|
||||
* must adopt the server-assigned id for subsequent saves. Historically the id
|
||||
* was never synced back into the form while staying on the recipients step,
|
||||
* so every following autosave resent the signer id-less — the server deleted
|
||||
* the previously created row and recreated it with a fresh id and signing
|
||||
* token, polluting the audit log with removed/added pairs on every edit.
|
||||
*/
|
||||
|
||||
const getRecipientByEmail = async (surface: TEnvelopeEditorSurface, email: string) => {
|
||||
const { envelopeId } = surface;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected surface to have an envelope ID');
|
||||
}
|
||||
|
||||
await expect.poll(async () => prisma.recipient.count({ where: { envelopeId, email } }), { timeout: 15_000 }).toBe(1);
|
||||
|
||||
return await prisma.recipient.findFirstOrThrow({ where: { envelopeId, email } });
|
||||
};
|
||||
|
||||
const waitForRecipientName = async (surface: TEnvelopeEditorSurface, email: string, name: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: { envelopeId: surface.envelopeId, email },
|
||||
});
|
||||
|
||||
return recipient?.name;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(name);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('documents: recipient id and token remain stable across autosaves', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root, envelopeId } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
await setRecipientName(root, 0, 'Alice');
|
||||
|
||||
const aliceInitial = await getRecipientByEmail(surface, 'alice@example.com');
|
||||
|
||||
// Edit while staying on the recipients step: the same row must be
|
||||
// updated, not deleted and recreated.
|
||||
await setRecipientName(root, 0, 'Alice Two');
|
||||
await waitForRecipientName(surface, 'alice@example.com', 'Alice Two');
|
||||
|
||||
const aliceAfterEdit = await getRecipientByEmail(surface, 'alice@example.com');
|
||||
|
||||
expect(aliceAfterEdit.id).toBe(aliceInitial.id);
|
||||
expect(aliceAfterEdit.token).toBe(aliceInitial.token);
|
||||
|
||||
// Adding another signer resends the whole set — alice must survive it,
|
||||
// and bob must then survive an edit to alice.
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
const bobInitial = await getRecipientByEmail(surface, 'bob@example.com');
|
||||
|
||||
await setRecipientName(root, 0, 'Alice Three');
|
||||
await waitForRecipientName(surface, 'alice@example.com', 'Alice Three');
|
||||
|
||||
const aliceFinal = await getRecipientByEmail(surface, 'alice@example.com');
|
||||
const bobFinal = await getRecipientByEmail(surface, 'bob@example.com');
|
||||
|
||||
expect(aliceFinal.id).toBe(aliceInitial.id);
|
||||
expect(aliceFinal.token).toBe(aliceInitial.token);
|
||||
expect(bobFinal.id).toBe(bobInitial.id);
|
||||
expect(bobFinal.token).toBe(bobInitial.token);
|
||||
|
||||
// One creation per recipient and zero deletions in the audit trail.
|
||||
const auditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
type: {
|
||||
in: [DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED, DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdCount = auditLogs.filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED).length;
|
||||
const deletedCount = auditLogs.filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED).length;
|
||||
|
||||
expect(deletedCount).toBe(0);
|
||||
expect(createdCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('templates: recipient id and token remain stable across autosaves', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
await setRecipientName(root, 0, 'Alice');
|
||||
|
||||
const aliceInitial = await getRecipientByEmail(surface, 'alice@example.com');
|
||||
|
||||
await setRecipientName(root, 0, 'Alice Two');
|
||||
await waitForRecipientName(surface, 'alice@example.com', 'Alice Two');
|
||||
|
||||
const aliceAfterEdit = await getRecipientByEmail(surface, 'alice@example.com');
|
||||
|
||||
expect(aliceAfterEdit.id).toBe(aliceInitial.id);
|
||||
expect(aliceAfterEdit.token).toBe(aliceInitial.token);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { getRecipientStepCards } from '../fixtures/envelope-editor';
|
||||
|
||||
/**
|
||||
* Signing is sequential, so a recipient who has already acted is at or before
|
||||
* the current step. Those steps hold persisted signing orders that the server
|
||||
* will not let us rewrite, so ordering is locked up to and including the last
|
||||
* of them. Later steps can only contain recipients who have not acted, so they
|
||||
* stay fully rearrangeable.
|
||||
*/
|
||||
|
||||
test('[LOCKED_STEPS]: ordering is locked up to the signed step and free afterwards', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: signed } = await seedUser();
|
||||
const { user: signedPeer } = await seedUser();
|
||||
const { user: pendingB } = await seedUser();
|
||||
const { user: pendingC } = await seedUser();
|
||||
|
||||
const { document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [signed, signedPeer, pendingB, pendingC],
|
||||
recipientsCreateOptions: [
|
||||
// Step 1 is a group, and one of its members has signed.
|
||||
{ signingOrder: 1, signingStatus: SigningStatus.SIGNED },
|
||||
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
{ signingOrder: 2, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
{ signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
],
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: 2,
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(getRecipientStepCards(page)).toHaveCount(3);
|
||||
|
||||
const stepHandles = page.getByTestId('step-drag-handle');
|
||||
|
||||
// Step 1 contains a signed recipient, so it is locked.
|
||||
await expect(stepHandles.nth(0)).toHaveClass(/pointer-events-none/);
|
||||
|
||||
// Its ungroup control is unavailable too — splitting it would rewrite the
|
||||
// signed recipient's persisted order.
|
||||
await expect(page.getByTestId('ungroup-step-button')).toBeDisabled();
|
||||
|
||||
// Steps after it hold only recipients who cannot have acted yet.
|
||||
await expect(stepHandles.nth(1)).not.toHaveClass(/pointer-events-none/);
|
||||
await expect(stepHandles.nth(2)).not.toHaveClass(/pointer-events-none/);
|
||||
|
||||
// Nothing was rewritten by simply opening the editor.
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
|
||||
|
||||
expect(recipients.find((r) => r.email === signed.email)?.signingOrder).toBe(1);
|
||||
expect(recipients.find((r) => r.email === signedPeer.email)?.signingOrder).toBe(1);
|
||||
expect(recipients.find((r) => r.email === pendingB.email)?.signingOrder).toBe(2);
|
||||
expect(recipients.find((r) => r.email === pendingC.email)?.signingOrder).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* A signed recipient can sit out of sequence — a direct template signs at its
|
||||
* own template order, field insertion has no turn check, and a document can be
|
||||
* switched from parallel to sequential mid-flight. The rule is "up to and
|
||||
* including the last signed step" rather than "the signed prefix" precisely so
|
||||
* these stay safe: the earlier unsigned step is locked too.
|
||||
*/
|
||||
test('[LOCKED_STEPS]: a signed recipient mid-sequence locks the steps before it', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstPending } = await seedUser();
|
||||
const { user: signedSecond } = await seedUser();
|
||||
const { user: lastPending } = await seedUser();
|
||||
|
||||
const { document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstPending, signedSecond, lastPending],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
{ signingOrder: 2, signingStatus: SigningStatus.SIGNED },
|
||||
{ signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
],
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: 2,
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(getRecipientStepCards(page)).toHaveCount(3);
|
||||
|
||||
const stepHandles = page.getByTestId('step-drag-handle');
|
||||
|
||||
// Step 1 has no signed recipient, but it sits before one — moving it would
|
||||
// reshuffle the signed recipient's position, so it is locked as well.
|
||||
await expect(stepHandles.nth(0)).toHaveClass(/pointer-events-none/);
|
||||
await expect(stepHandles.nth(1)).toHaveClass(/pointer-events-none/);
|
||||
|
||||
// Only the step after the signed one remains movable.
|
||||
await expect(stepHandles.nth(2)).not.toHaveClass(/pointer-events-none/);
|
||||
});
|
||||
|
||||
test('[LOCKED_STEPS]: every step stays draggable when nobody has signed', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: first } = await seedUser();
|
||||
const { user: second } = await seedUser();
|
||||
|
||||
const { document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [first, second],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
{ signingOrder: 2, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
],
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: 2,
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(getRecipientStepCards(page)).toHaveCount(2);
|
||||
|
||||
const stepHandles = page.getByTestId('step-drag-handle');
|
||||
|
||||
await expect(stepHandles.nth(0)).not.toHaveClass(/pointer-events-none/);
|
||||
await expect(stepHandles.nth(1)).not.toHaveClass(/pointer-events-none/);
|
||||
});
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { getRecipientEmailInputs, getRecipientStepCards, setRecipientName } from '../fixtures/envelope-editor';
|
||||
|
||||
/**
|
||||
* A recipient with no persisted signing order means "last" everywhere on the
|
||||
* server (queries sort NULLS LAST, and `effectiveOrder` maps null to the end).
|
||||
* The editor must not invent an order from array position: the guess can land
|
||||
* on a real order — which now means "same signing step" — or move the
|
||||
* recipient ahead of one that was meant to sign first.
|
||||
*/
|
||||
|
||||
const seedMixedOrderEnvelope = async (options: { firstOrder: number }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: ordered } = await seedUser();
|
||||
const { user: unordered } = await seedUser();
|
||||
|
||||
const { document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [ordered, unordered],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: options.firstOrder, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
// Created second, so it takes the higher id — this is the position the
|
||||
// editor used to turn into `index + 1`.
|
||||
{ signingOrder: null, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
],
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: 2,
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { user, team, document, orderedEmail: ordered.email, unorderedEmail: unordered.email };
|
||||
};
|
||||
|
||||
test('[NULL_ORDER_HYDRATION]: a null-order recipient does not join an existing step', async ({ page }) => {
|
||||
// The unordered recipient sits at index 1, so `index + 1` would collide with
|
||||
// the persisted order 2 and render the two as one group.
|
||||
const { user, team, document, orderedEmail, unorderedEmail } = await seedMixedOrderEnvelope({ firstOrder: 2 });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(getRecipientEmailInputs(page)).toHaveCount(2);
|
||||
|
||||
// Two independent steps, not a single group.
|
||||
await expect(getRecipientStepCards(page)).toHaveCount(2);
|
||||
await expect(page.getByText('2 recipients · any order')).not.toBeVisible();
|
||||
|
||||
// Persisted orders must stay distinct once the editor saves.
|
||||
await setRecipientName(page, 1, 'Renamed Unordered');
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
|
||||
|
||||
return recipients.find((recipient) => recipient.email === unorderedEmail)?.name;
|
||||
})
|
||||
.toBe('Renamed Unordered');
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
|
||||
|
||||
const orderedRecipient = recipients.find((recipient) => recipient.email === orderedEmail);
|
||||
const unorderedRecipient = recipients.find((recipient) => recipient.email === unorderedEmail);
|
||||
|
||||
expect(orderedRecipient?.signingOrder).not.toBe(unorderedRecipient?.signingOrder);
|
||||
});
|
||||
|
||||
test('[NULL_ORDER_HYDRATION]: a null-order recipient stays last', async ({ page }) => {
|
||||
// Persisted order 3 with the unordered recipient at index 1: `index + 1`
|
||||
// would give it 2 and move it ahead of the recipient meant to sign first.
|
||||
const { user, team, document, orderedEmail, unorderedEmail } = await seedMixedOrderEnvelope({ firstOrder: 3 });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(getRecipientEmailInputs(page)).toHaveCount(2);
|
||||
|
||||
// The ordered recipient must still be shown first.
|
||||
await expect(getRecipientEmailInputs(page).nth(0)).toHaveValue(orderedEmail);
|
||||
await expect(getRecipientEmailInputs(page).nth(1)).toHaveValue(unorderedEmail);
|
||||
});
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
dragRecipientRowToGap,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientNameInputs,
|
||||
getRecipientRemoveButtons,
|
||||
getSigningOrderInputs,
|
||||
getRecipientStepCards,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
setSigningOrderValue,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleAllowDictateSigners,
|
||||
toggleSigningOrder,
|
||||
@@ -112,46 +112,71 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<Recipi
|
||||
await setRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await setRecipientRole(surface.root, 2, 'Receives copy');
|
||||
|
||||
// The role selects must reflect the change immediately, without requiring a
|
||||
// navigation or reload (regression: leaf controllers going stale after a
|
||||
// root-level signers array update).
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await assertRecipientRole(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 expect(getRecipientStepCards(surface.root)).toHaveCount(2);
|
||||
|
||||
// Reordering is drag-only. Pointer-emulated drags are unreliable inside the
|
||||
// embedded authoring surface (its inner scroll container auto-scrolls and
|
||||
// cancels the emulated drag), so the drag-swap is exercised on the native
|
||||
// surfaces only — the same component drives all surfaces.
|
||||
const shouldSwapViaDrag = !surface.isEmbedded;
|
||||
|
||||
if (shouldSwapViaDrag) {
|
||||
// Let the debounced autosave from the edits above land before dragging —
|
||||
// the editor re-rendering mid-drag would cancel the drag.
|
||||
await surface.root.waitForTimeout(1500);
|
||||
|
||||
// Drag the first recipient's row into the gap after the last group,
|
||||
// swapping the two.
|
||||
await dragRecipientRowToGap(surface.root, 0, 2);
|
||||
}
|
||||
|
||||
await toggleAllowDictateSigners(surface.root, true);
|
||||
|
||||
await navigateToAddFieldsAndBack(surface.root);
|
||||
|
||||
const [firstRecipient, secondRecipient] = shouldSwapViaDrag
|
||||
? [TEST_RECIPIENT_VALUES.secondRecipient, primaryRecipient]
|
||||
: [primaryRecipient, TEST_RECIPIENT_VALUES.secondRecipient];
|
||||
|
||||
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(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(firstRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(secondRecipient.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 expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(firstRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(secondRecipient.name);
|
||||
|
||||
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||
await assertRecipientRole(surface.root, 0, shouldSwapViaDrag ? 'Needs to approve' : 'Needs to sign');
|
||||
await assertRecipientRole(surface.root, 1, shouldSwapViaDrag ? 'Needs to sign' : 'Needs to approve');
|
||||
|
||||
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');
|
||||
await expect(surface.root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(surface.root.getByText('Group 2', { exact: true })).toBeVisible();
|
||||
|
||||
return {
|
||||
externalId,
|
||||
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||
expectedRecipientsBySigningOrder: [
|
||||
{
|
||||
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
role: RecipientRole.APPROVER,
|
||||
email: firstRecipient.email,
|
||||
name: firstRecipient.name,
|
||||
role: shouldSwapViaDrag ? RecipientRole.APPROVER : RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: primaryRecipient.email,
|
||||
name: primaryRecipient.name,
|
||||
role: RecipientRole.SIGNER,
|
||||
email: secondRecipient.email,
|
||||
name: secondRecipient.name,
|
||||
role: shouldSwapViaDrag ? RecipientRole.SIGNER : RecipientRole.APPROVER,
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed
|
||||
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 type { Locator, Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from './authentication';
|
||||
@@ -264,8 +264,6 @@ export const getRecipientRows = (root: Page) =>
|
||||
|
||||
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();
|
||||
@@ -335,10 +333,334 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) =>
|
||||
}
|
||||
};
|
||||
|
||||
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||
const input = getSigningOrderInputs(root).nth(index);
|
||||
await input.fill(value.toString());
|
||||
await input.blur();
|
||||
/**
|
||||
* Performs a mouse-based drag from a drag handle onto a target element.
|
||||
*
|
||||
* `@hello-pangea/dnd` only starts a drag once the pointer travels a small
|
||||
* distance while pressed, and it hit-tests drop targets using the CENTRE of
|
||||
* the dragged element — not the cursor. Since drag handles sit at the edge of
|
||||
* wide rows/cards, the cursor destination is compensated so the dragged
|
||||
* element's centre lands on the target's centre.
|
||||
*/
|
||||
export const dragHandleToTarget = async (
|
||||
root: Page,
|
||||
handle: Locator,
|
||||
target: Locator,
|
||||
options: { activeClass: string },
|
||||
) => {
|
||||
const { activeClass } = options;
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
|
||||
const handleBox = await handle.boundingBox();
|
||||
|
||||
if (!handleBox) {
|
||||
throw new Error('Unable to resolve drag handle position');
|
||||
}
|
||||
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
await root.mouse.move(startX, startY);
|
||||
await root.mouse.down();
|
||||
|
||||
// Exceed the drag activation threshold, then wait for drag-dependent layout
|
||||
// (e.g. expanding gap drop-zones) to settle before resolving positions.
|
||||
const cursorX = startX + 8;
|
||||
const cursorY = startY;
|
||||
|
||||
await root.mouse.move(cursorX, cursorY, { steps: 2 });
|
||||
await root.waitForTimeout(300);
|
||||
|
||||
// The dragged element is the handle's draggable ancestor; while dragging it
|
||||
// is fixed-positioned and follows the cursor at a constant offset. Drop
|
||||
// targeting uses the dragged element's CENTRE, not the cursor, so the
|
||||
// cursor destination is compensated by that offset.
|
||||
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
|
||||
const draggedBox = await draggedElement.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
if (!draggedBox || !targetBox) {
|
||||
await root.mouse.up();
|
||||
|
||||
throw new Error('Unable to resolve drag positions');
|
||||
}
|
||||
|
||||
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
|
||||
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
|
||||
|
||||
const hasBecomeActive = async () => {
|
||||
const className = await target.getAttribute('class');
|
||||
|
||||
return Boolean(className?.includes(activeClass));
|
||||
};
|
||||
|
||||
// The highlight class is rendered from the library's own drag state, so it
|
||||
// cannot disagree with where a drop will land — both phases below only drop
|
||||
// once the target reports the drag as over it AND that state survives a
|
||||
// short confirmation dwell (it can flicker while crossing a card's
|
||||
// reorder/combine boundary).
|
||||
//
|
||||
// The cursor is always clamped inside the viewport: moving outside the
|
||||
// window cancels the drag (pointercancel), and holding near the bottom edge
|
||||
// lets the library auto-scroll the target up to the cursor instead.
|
||||
const viewportHeight = root.viewportSize()?.height ?? 720;
|
||||
const maxCursorY = viewportHeight - 40;
|
||||
|
||||
const confirmAndDrop = async () => {
|
||||
if (!(await hasBecomeActive())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await root.waitForTimeout(150);
|
||||
|
||||
if (!(await hasBecomeActive())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await root.mouse.up();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let hasDropped = false;
|
||||
|
||||
// Crawl-and-drop: approach from above and inch downward through the
|
||||
// corridor. Captured drop-target geometry can drift a few pixels from the
|
||||
// live layout for small targets, so a slow traversal is the reliable way to
|
||||
// hit them.
|
||||
const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX;
|
||||
const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY);
|
||||
|
||||
await root.mouse.move(crawlX, crawlStartY, { steps: 15 });
|
||||
await root.waitForTimeout(150);
|
||||
|
||||
for (let step = 1; step <= 80; step += 1) {
|
||||
if (await confirmAndDrop()) {
|
||||
hasDropped = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 });
|
||||
await root.waitForTimeout(70);
|
||||
}
|
||||
|
||||
if (!hasDropped) {
|
||||
await root.mouse.up();
|
||||
}
|
||||
|
||||
await root.waitForTimeout(400);
|
||||
};
|
||||
|
||||
export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]');
|
||||
|
||||
export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]');
|
||||
|
||||
export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]');
|
||||
|
||||
export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]');
|
||||
|
||||
/**
|
||||
* Drags a whole group card onto another card, merging the two groups.
|
||||
*
|
||||
* Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are
|
||||
* unreliable because approaching a card traverses its reorder edge, which
|
||||
* displaces the target away from the cursor. Keyboard drags step through
|
||||
* positions (including combine states) deterministically.
|
||||
*/
|
||||
export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => {
|
||||
const handle = getStepDragHandles(root).nth(sourceCardIndex);
|
||||
const target = getRecipientStepCards(root).nth(targetCardIndex);
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await handle.focus();
|
||||
|
||||
// Lift.
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(250);
|
||||
|
||||
const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown';
|
||||
|
||||
for (let press = 0; press < 4; press += 1) {
|
||||
await root.keyboard.press(direction);
|
||||
await root.waitForTimeout(250);
|
||||
|
||||
const targetClassName = await target.getAttribute('class');
|
||||
|
||||
if (targetClassName?.includes('ring-primary')) {
|
||||
// Drop while the target reports the combine state.
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(400);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await root.keyboard.press('Escape');
|
||||
|
||||
throw new Error('Combine drag did not reach the target card');
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a group card one position up via keyboard drag. With combining
|
||||
* enabled, the first ArrowUp enters the combine state with the card above and
|
||||
* the second moves above it.
|
||||
*/
|
||||
export const moveGroupCardUp = async (root: Page, cardIndex: number) => {
|
||||
const handle = getStepDragHandles(root).nth(cardIndex);
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await handle.focus();
|
||||
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(250);
|
||||
await root.keyboard.press('ArrowUp');
|
||||
await root.waitForTimeout(250);
|
||||
await root.keyboard.press('ArrowUp');
|
||||
await root.waitForTimeout(250);
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(400);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drags a recipient row into a gap between group cards, extracting it into
|
||||
* its own standalone group at that position.
|
||||
*/
|
||||
export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => {
|
||||
await dragHandleToTarget(
|
||||
root,
|
||||
getRecipientRowDragHandles(root).nth(rowIndex),
|
||||
getRecipientStepGaps(root).nth(gapIndex),
|
||||
// The marker class applied to a gap drop-zone while dragged over.
|
||||
{ activeClass: 'gap-active' },
|
||||
);
|
||||
};
|
||||
|
||||
export type SweepRecipientRowOverCardResult = {
|
||||
/**
|
||||
* Whether any gap drop-zone activated during the sweep — proof the drag
|
||||
* gesture itself was live, so "the card never activated" cannot be a
|
||||
* false negative from a drag that silently failed to start.
|
||||
*/
|
||||
sawGapActive: boolean;
|
||||
/**
|
||||
* Whether the target card reported the row as a join target (`ring-primary`).
|
||||
*/
|
||||
sawCardActive: boolean;
|
||||
/**
|
||||
* Whether the row was dropped onto the card (only when it became active).
|
||||
*/
|
||||
dropped: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drags a recipient row across a group card's body, dropping it to join the
|
||||
* group as soon as the card activates. If the card never activates (e.g. the
|
||||
* join drop-zone is disabled), the drag is cancelled with Escape so no
|
||||
* accidental gap-drop mutates the order.
|
||||
*
|
||||
* Unlike `dragHandleToTarget`'s fixed-interval crawl, each sweep position
|
||||
* polls for activation with a generous budget, which keeps the gesture
|
||||
* reliable when rendering lags under parallel test load.
|
||||
*/
|
||||
export const sweepRecipientRowOverCard = async (
|
||||
root: Page,
|
||||
rowIndex: number,
|
||||
cardIndex: number,
|
||||
): Promise<SweepRecipientRowOverCardResult> => {
|
||||
const handle = getRecipientRowDragHandles(root).nth(rowIndex);
|
||||
const card = getRecipientStepCards(root).nth(cardIndex);
|
||||
|
||||
const result: SweepRecipientRowOverCardResult = {
|
||||
sawGapActive: false,
|
||||
sawCardActive: false,
|
||||
dropped: false,
|
||||
};
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
|
||||
const handleBox = await handle.boundingBox();
|
||||
|
||||
if (!handleBox) {
|
||||
throw new Error('Unable to resolve drag handle position');
|
||||
}
|
||||
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
await root.mouse.move(startX, startY);
|
||||
await root.mouse.down();
|
||||
|
||||
// Exceed the drag activation threshold, then wait for drag-dependent
|
||||
// layout (expanding drop-zones) to settle before resolving positions.
|
||||
const cursorX = startX + 8;
|
||||
const cursorY = startY;
|
||||
|
||||
await root.mouse.move(cursorX, cursorY, { steps: 2 });
|
||||
await root.waitForTimeout(300);
|
||||
|
||||
// Drop targeting uses the dragged element's CENTRE, not the cursor, so
|
||||
// cursor destinations are compensated by the constant cursor-to-centre
|
||||
// offset captured at lift time.
|
||||
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
|
||||
const draggedBox = await draggedElement.boundingBox();
|
||||
const cardBox = await card.boundingBox();
|
||||
|
||||
if (!draggedBox || !cardBox) {
|
||||
await root.mouse.up();
|
||||
|
||||
throw new Error('Unable to resolve drag positions');
|
||||
}
|
||||
|
||||
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
|
||||
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
|
||||
|
||||
const sweepX = cardBox.x + cardBox.width / 2 - itemOffsetX;
|
||||
const sweepFromY = cardBox.y - itemOffsetY - 40;
|
||||
const sweepToY = cardBox.y + cardBox.height - itemOffsetY + 80;
|
||||
|
||||
await root.mouse.move(sweepX, sweepFromY, { steps: 15 });
|
||||
|
||||
for (let y = sweepFromY; y <= sweepToY && !result.dropped; y += 8) {
|
||||
await root.mouse.move(sweepX, y, { steps: 2 });
|
||||
|
||||
// Poll for activation: drag state is rendered on animation frames, so
|
||||
// under load the classes can trail the cursor by hundreds of ms.
|
||||
for (let tick = 0; tick < 6; tick += 1) {
|
||||
const cardClassName = (await card.getAttribute('class')) ?? '';
|
||||
|
||||
if (cardClassName.includes('ring-primary')) {
|
||||
result.sawCardActive = true;
|
||||
|
||||
await root.mouse.up();
|
||||
|
||||
result.dropped = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result.sawGapActive) {
|
||||
const activeGapCount = await root.locator('[data-testid="recipient-step-gap"].gap-active').count();
|
||||
|
||||
result.sawGapActive = activeGapCount > 0;
|
||||
}
|
||||
|
||||
await root.waitForTimeout(50);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.dropped) {
|
||||
// Cancel rather than release: releasing over an active gap would extract
|
||||
// the row into a new step, silently mutating the signing order.
|
||||
await root.keyboard.press('Escape');
|
||||
await root.waitForTimeout(100);
|
||||
await root.mouse.up();
|
||||
}
|
||||
|
||||
await root.waitForTimeout(400);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
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, SigningStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Rejecting a document only marks the recipient; the envelope is moved to
|
||||
* REJECTED later, asynchronously, by the seal job. Until that lands the
|
||||
* envelope is still PENDING, so another recipient can complete and the
|
||||
* advancement logic runs with a REJECTED recipient in the list.
|
||||
*
|
||||
* That recipient must never be treated as the next signing group — doing so
|
||||
* re-marks them as sent and emails them a signing request for a document they
|
||||
* declined. (For rejected TSP envelopes the seal job always throws, so this
|
||||
* state is permanent rather than a narrow race.)
|
||||
*/
|
||||
|
||||
const expectSigningRequestJobCount = async (recipientId: number, expected: number) => {
|
||||
const jobs = await prisma.backgroundJob.findMany({
|
||||
where: {
|
||||
jobId: 'send.signing.requested.email',
|
||||
payload: {
|
||||
path: ['recipientId'],
|
||||
equals: recipientId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(jobs.length).toBe(expected);
|
||||
};
|
||||
|
||||
test('[REJECTED_ADVANCEMENT]: a rejected recipient is skipped when the next group is activated', async () => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: rejectedSigner } = await seedUser();
|
||||
const { user: laterSigner } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, rejectedSigner, laterSigner],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
{ signingOrder: 2, signingStatus: SigningStatus.REJECTED },
|
||||
{ signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED },
|
||||
],
|
||||
// No fields, so completion is not blocked by unsigned required fields.
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = recipients.find((recipient) => recipient.email === firstSigner.email);
|
||||
const rejected = recipients.find((recipient) => recipient.email === rejectedSigner.email);
|
||||
const later = recipients.find((recipient) => recipient.email === laterSigner.email);
|
||||
|
||||
if (!first || !rejected || !later) {
|
||||
throw new Error('Seeded recipients not found');
|
||||
}
|
||||
|
||||
// The seed never sets sentAt, so it is a clean signal for "was activated".
|
||||
expect(rejected.sentAt).toBeNull();
|
||||
expect(later.sentAt).toBeNull();
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: first.token,
|
||||
id: { type: 'envelopeId', id: first.envelopeId },
|
||||
});
|
||||
|
||||
const rejectedAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: rejected.id } });
|
||||
const laterAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: later.id } });
|
||||
|
||||
// The rejected recipient is left alone entirely.
|
||||
expect(rejectedAfter.sentAt).toBeNull();
|
||||
expect(rejectedAfter.signingStatus).toBe(SigningStatus.REJECTED);
|
||||
await expectSigningRequestJobCount(rejected.id, 0);
|
||||
|
||||
// The genuinely pending next step is activated instead.
|
||||
expect(laterAfter.sentAt).not.toBeNull();
|
||||
await expectSigningRequestJobCount(later.id, 1);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
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, SendStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* A signing group is a set of recipients sharing one `signingOrder`. Two
|
||||
* server-side guarantees define the feature, and neither was asserted anywhere:
|
||||
*
|
||||
* 1. When a step unlocks, *every* member of that step is activated together.
|
||||
* 2. The next step stays locked until *every* member of the current step has
|
||||
* signed — one member finishing must not advance the flow.
|
||||
*
|
||||
* These drive `completeDocumentWithToken` directly rather than the browser, so
|
||||
* the side effects (`sendStatus`, `sentAt`, signing-request jobs) can be
|
||||
* asserted precisely, and without the cost of four UI signing flows.
|
||||
*/
|
||||
|
||||
const expectSigningRequestJobCount = async (recipientId: number, expected: number) => {
|
||||
const jobs = await prisma.backgroundJob.findMany({
|
||||
where: {
|
||||
jobId: 'send.signing.requested.email',
|
||||
payload: {
|
||||
path: ['recipientId'],
|
||||
equals: recipientId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(jobs.length).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Steps: 1 = `first`, 2 = `groupA` + `groupB` (the group), 3 = `last`.
|
||||
*/
|
||||
const seedGroupedDocument = async () => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: groupASigner } = await seedUser();
|
||||
const { user: groupBSigner } = await seedUser();
|
||||
const { user: lastSigner } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, groupASigner, groupBSigner, lastSigner],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1, sendStatus: SendStatus.SENT },
|
||||
// The seed marks every recipient SENT by default, but a real SEQUENTIAL
|
||||
// document leaves later steps NOT_SENT until their step unlocks. Without
|
||||
// this, `sendStatus` would be meaningless as an "activated" signal.
|
||||
{ signingOrder: 2, sendStatus: SendStatus.NOT_SENT },
|
||||
{ signingOrder: 2, sendStatus: SendStatus.NOT_SENT },
|
||||
{ signingOrder: 3, sendStatus: SendStatus.NOT_SENT },
|
||||
],
|
||||
// No fields, so completion is not blocked by unsigned required fields.
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const findByEmail = (email: string) => {
|
||||
const recipient = recipients.find((item) => item.email === email);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(`Seeded recipient ${email} not found`);
|
||||
}
|
||||
|
||||
return recipient;
|
||||
};
|
||||
|
||||
return {
|
||||
first: findByEmail(firstSigner.email),
|
||||
groupA: findByEmail(groupASigner.email),
|
||||
groupB: findByEmail(groupBSigner.email),
|
||||
last: findByEmail(lastSigner.email),
|
||||
};
|
||||
};
|
||||
|
||||
test('[SIGNING_GROUPS]: unlocking a step activates every member of that step, and only that step', async () => {
|
||||
const { first, groupA, groupB, last } = await seedGroupedDocument();
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: first.token,
|
||||
id: { type: 'envelopeId', id: first.envelopeId },
|
||||
});
|
||||
|
||||
const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } });
|
||||
const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } });
|
||||
const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: last.id } });
|
||||
|
||||
// Both members of step 2 are activated together.
|
||||
expect(groupAAfter.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(groupAAfter.sentAt).not.toBeNull();
|
||||
await expectSigningRequestJobCount(groupA.id, 1);
|
||||
|
||||
expect(groupBAfter.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(groupBAfter.sentAt).not.toBeNull();
|
||||
await expectSigningRequestJobCount(groupB.id, 1);
|
||||
|
||||
// Step 3 is not pulled forward with them.
|
||||
expect(lastAfter.sendStatus).toBe(SendStatus.NOT_SENT);
|
||||
expect(lastAfter.sentAt).toBeNull();
|
||||
await expectSigningRequestJobCount(last.id, 0);
|
||||
});
|
||||
|
||||
test('[SIGNING_GROUPS]: the next step stays locked until every member of the group has signed', async () => {
|
||||
const { first, groupA, groupB, last } = await seedGroupedDocument();
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: first.token,
|
||||
id: { type: 'envelopeId', id: first.envelopeId },
|
||||
});
|
||||
|
||||
// Only one of the two group members signs.
|
||||
await completeDocumentWithToken({
|
||||
token: groupA.token,
|
||||
id: { type: 'envelopeId', id: groupA.envelopeId },
|
||||
});
|
||||
|
||||
const lastWhileGroupPending = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: last.id },
|
||||
});
|
||||
|
||||
expect(lastWhileGroupPending.sendStatus).toBe(SendStatus.NOT_SENT);
|
||||
expect(lastWhileGroupPending.sentAt).toBeNull();
|
||||
await expectSigningRequestJobCount(last.id, 0);
|
||||
|
||||
// The outstanding peer must not be re-notified by their peer's completion.
|
||||
await expectSigningRequestJobCount(groupB.id, 1);
|
||||
|
||||
// The final member of the group signs; the flow advances.
|
||||
await completeDocumentWithToken({
|
||||
token: groupB.token,
|
||||
id: { type: 'envelopeId', id: groupB.envelopeId },
|
||||
});
|
||||
|
||||
const lastAfterGroupComplete = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: last.id },
|
||||
});
|
||||
|
||||
expect(lastAfterGroupComplete.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(lastAfterGroupComplete.sentAt).not.toBeNull();
|
||||
await expectSigningRequestJobCount(last.id, 1);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } 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 { DocumentSigningOrder, DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
type SeededRecipient = Awaited<ReturnType<typeof seedPendingDocumentWithFullFields>>['recipients'][number];
|
||||
|
||||
const completeSigning = async (page: Page, recipient: SeededRecipient) => {
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
for (const field of recipient.fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
};
|
||||
|
||||
const expectWaiting = async (page: Page, token: string) => {
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}/waiting`);
|
||||
};
|
||||
|
||||
test('[SIGNING_GROUPS]: group members sign in any order and gate the next step', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: signer1 } = await seedUser();
|
||||
const { user: signer2a } = await seedUser();
|
||||
const { user: signer2b } = await seedUser();
|
||||
const { user: signer3 } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [signer1, signer2a, signer2b, signer3],
|
||||
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 2 }, { signingOrder: 3 }],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [recipient1, recipient2a, recipient2b, recipient3] = recipients;
|
||||
|
||||
// While step 1 is pending, both group members and step 3 are blocked.
|
||||
await expectWaiting(page, recipient2a.token);
|
||||
await expectWaiting(page, recipient2b.token);
|
||||
await expectWaiting(page, recipient3.token);
|
||||
|
||||
await completeSigning(page, recipient1);
|
||||
|
||||
// The group is now active; step 3 is still blocked.
|
||||
await expectWaiting(page, recipient3.token);
|
||||
|
||||
// Sign with the SECOND group member first to prove any-order signing.
|
||||
await completeSigning(page, recipient2b);
|
||||
|
||||
// One group member remains — step 3 stays blocked.
|
||||
await expectWaiting(page, recipient3.token);
|
||||
|
||||
await completeSigning(page, recipient2a);
|
||||
|
||||
// The whole group is done — step 3 unlocks and completes the document.
|
||||
await completeSigning(page, recipient3);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return envelope.status;
|
||||
})
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createDocumentFromDirectTemplate } from '@documenso/lib/server-only/template/create-document-from-direct-template';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
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 { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* "Dictate next signer" lets the signer choose who acts in the NEXT step. With
|
||||
* signing groups the direct recipient can share a step with someone else, and
|
||||
* because the direct recipient is created as SIGNED before the pending query
|
||||
* runs, that same-step peer would otherwise look like the "next" recipient.
|
||||
*
|
||||
* The UI never offers dictation in that case, so this exercises the server
|
||||
* directly — the only way the gap is reachable.
|
||||
*/
|
||||
|
||||
const requestMetadata: ApiRequestMetadata = {
|
||||
requestMetadata: {},
|
||||
source: 'app',
|
||||
auth: null,
|
||||
};
|
||||
|
||||
const PEER_EMAIL = 'peer@documenso.com';
|
||||
const PEER_NAME = 'Peer Signer';
|
||||
const LATER_EMAIL = 'later@documenso.com';
|
||||
const LATER_NAME = 'Later Signer';
|
||||
|
||||
const DICTATED = { email: 'dictated@documenso.com', name: 'Dictated Signer' };
|
||||
|
||||
/**
|
||||
* Seeds a direct template whose direct recipient sits at `directSigningOrder`,
|
||||
* plus a peer at `peerSigningOrder` and a signer in a strictly later step.
|
||||
*/
|
||||
const seedDirectTemplateWithPeer = async (options: { peerSigningOrder: number }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: '[TEST] Direct template dictation',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: template.documentMetaId },
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: template.id },
|
||||
});
|
||||
|
||||
// Every SIGNER needs a signature field or the direct-template flow rejects
|
||||
// the template before it reaches the dictation logic.
|
||||
const createSigner = async (email: string, name: string, signingOrder: number) => {
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
envelopeId: template.id,
|
||||
email,
|
||||
name,
|
||||
token: Math.random().toString().slice(2, 12),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 20 + signingOrder * 5,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
},
|
||||
});
|
||||
|
||||
return recipient;
|
||||
};
|
||||
|
||||
const peer = await createSigner(PEER_EMAIL, PEER_NAME, options.peerSigningOrder);
|
||||
const later = await createSigner(LATER_EMAIL, LATER_NAME, 2);
|
||||
|
||||
const directRecipient = template.recipients.find((recipient) => recipient.signingOrder === 1);
|
||||
const directSignatureField = template.fields.find((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!directRecipient || !directSignatureField) {
|
||||
throw new Error('Seeded direct template is missing its recipient or signature field');
|
||||
}
|
||||
|
||||
// Read updatedAt last: the writes above bump it, and the flow rejects a stale value.
|
||||
const refreshed = await prisma.envelope.findFirstOrThrow({ where: { id: template.id } });
|
||||
|
||||
return {
|
||||
directLinkToken: template.directLink?.token ?? '',
|
||||
directSignatureFieldId: directSignatureField.id,
|
||||
templateUpdatedAt: refreshed.updatedAt,
|
||||
peer,
|
||||
later,
|
||||
};
|
||||
};
|
||||
|
||||
const signDirectTemplate = async (seeded: Awaited<ReturnType<typeof seedDirectTemplateWithPeer>>) =>
|
||||
await createDocumentFromDirectTemplate({
|
||||
directRecipientName: 'Direct Signer',
|
||||
directRecipientEmail: 'direct-signer@documenso.com',
|
||||
directTemplateToken: seeded.directLinkToken,
|
||||
templateUpdatedAt: seeded.templateUpdatedAt,
|
||||
signedFieldValues: [
|
||||
{
|
||||
token: seeded.directLinkToken,
|
||||
fieldId: seeded.directSignatureFieldId,
|
||||
value: 'Direct Signer',
|
||||
isBase64: false,
|
||||
},
|
||||
],
|
||||
nextSigner: DICTATED,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATE_DICTATION]: does not dictate a recipient sharing the direct recipient step', async () => {
|
||||
const seeded = await seedDirectTemplateWithPeer({ peerSigningOrder: 1 });
|
||||
|
||||
const { envelopeId } = await signDirectTemplate(seeded);
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
const peer = recipients.find((recipient) => recipient.email === PEER_EMAIL);
|
||||
const dictated = recipients.find((recipient) => recipient.email === DICTATED.email);
|
||||
|
||||
// The same-step peer must be untouched...
|
||||
expect(peer).toBeDefined();
|
||||
expect(peer?.name).toBe(PEER_NAME);
|
||||
expect(peer?.signingOrder).toBe(1);
|
||||
|
||||
// ...and nobody at all should have been renamed, since the next step is not reachable yet.
|
||||
expect(dictated).toBeUndefined();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATE_DICTATION]: still dictates the next step when the direct recipient is alone', async () => {
|
||||
const seeded = await seedDirectTemplateWithPeer({ peerSigningOrder: 3 });
|
||||
|
||||
const { envelopeId } = await signDirectTemplate(seeded);
|
||||
|
||||
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
|
||||
|
||||
// The order-2 signer is the sole member of the next step, so dictation applies.
|
||||
const dictated = recipients.find((recipient) => recipient.email === DICTATED.email);
|
||||
|
||||
expect(dictated).toBeDefined();
|
||||
expect(dictated?.name).toBe(DICTATED.name);
|
||||
expect(dictated?.signingOrder).toBe(2);
|
||||
|
||||
// The untouched recipients keep their seeded identities.
|
||||
expect(recipients.some((recipient) => recipient.email === LATER_EMAIL)).toBe(false);
|
||||
expect(recipients.some((recipient) => recipient.email === PEER_EMAIL)).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user