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,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);
|
||||
});
|
||||
Reference in New Issue
Block a user