This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -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);
});