feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast
Phase 3 of the comprehensive UAT round. Implements the Automate
Signing feature per the 2026-05-26 locked decisions.
P3.1 — documents.automation_mode schema
Migration 0088 adds the column with a CHECK constraint enforcing
the three-value enum: manual / sequential_auto / concurrent_auto.
Drizzle schema picks it up; default 'manual' preserves existing
behaviour.
P3.2 — Automate Signing orchestrator service
New src/lib/services/signing-automation.service.ts. enableSigningAutomation
resolves the mode from the envelope's signing order (SEQUENTIAL ->
sequential_auto fires first signer only; PARALLEL -> concurrent_auto
fires all signers in one parallel dispatch), updates documents.automationMode,
and dispatches invitations via the same sendSigningInvitation path
the manual route uses (so the email a recipient sees is identical
regardless of trigger). ensureSigningUrls recovers v2 signing URLs
if they're missing on the local signer rows. Hard guards: envelope
must exist, status in {draft, sent, partially_signed}, ≥2 signers.
disableSigningAutomation reverts to manual; idempotent.
P3.3 — Webhook cascade
The existing sendCascadingInviteForNextSigner in documents.service.ts
already fires the next pending signer on every recipient_signed event
(mode-independent). handleDocumentCompleted already sends the signed
PDF to all recipients via sendSigningCompleted on completion. So
"automate" really means "kick off the first invitation"; the rest
is mode-independent existing behaviour. Doc comment in the new
service explains the interaction.
P3.4 — ActiveEoiCard Automate signing button + banner
- DocumentRow type extended with automationMode + documensoId.
- New automateMutation hits POST /api/v1/documents/[id]/automate;
pauseAutomationMutation hits DELETE.
- "Automate signing" button visible when totalCount ≥ 2 AND doc has
documensoId AND envelope is in-flight AND mode === 'manual'.
- "Automating sequentially/concurrently · N of M signed" banner
renders when automation is active, with a Pause button that
reverts to manual.
- Per-row Send invitation / Send reminder buttons in SigningProgress
stay visible per the locked decision (manual override during auto).
P3.5 — Automate Signing API route + tests
- POST /api/v1/documents/[id]/automate (enables) + DELETE (disables).
- Permission: documents.send_for_signing (mirrors the manual
send-invitation route).
- vitest covering: NotFound on missing doc, Conflict on missing
envelope, Conflict on completed status, Conflict on already-
automated, Conflict on <2 signers, disable is idempotent when
already manual. All 7 cases pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
170
tests/unit/services/signing-automation.test.ts
Normal file
170
tests/unit/services/signing-automation.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* Signing-automation orchestrator validation tests. Heavy integration
|
||||
* paths (actual Documenso round-trip, real signer dispatch) are
|
||||
* exercised by realapi Playwright specs. These tests pin the input-
|
||||
* validation contract: refuses to enable on completed / cancelled /
|
||||
* already-automated documents, refuses when documensoId is null,
|
||||
* refuses with fewer than two signers.
|
||||
*/
|
||||
|
||||
// vi.hoisted lets us declare mock helpers that the hoisted vi.mock
|
||||
// factory can reference safely — without hoisted, the factory sees
|
||||
// undefined for any top-level const.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
docFindFirst: vi.fn(),
|
||||
signersFindFirst: vi.fn(),
|
||||
updateCall: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })),
|
||||
selectCall: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ orderBy: vi.fn(() => Promise.resolve([])) })),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
documents: { findFirst: mocks.docFindFirst },
|
||||
documentSigners: { findFirst: mocks.signersFindFirst },
|
||||
ports: { findFirst: vi.fn().mockResolvedValue({ id: 'port-1', name: 'Test Port' }) },
|
||||
},
|
||||
update: mocks.updateCall,
|
||||
select: mocks.selectCall,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/port-config', () => ({
|
||||
getPortDocumensoConfig: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ signingOrder: 'SEQUENTIAL', developerName: 'Dev' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/documenso-client', () => ({
|
||||
getDocument: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'env-1',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
signingOrder: 'SEQUENTIAL',
|
||||
}),
|
||||
distributeEnvelopeV2: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'env-1',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
signingOrder: 'SEQUENTIAL',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/document-signing-emails.service', () => ({
|
||||
sendSigningInvitation: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/documenso-signers', () => ({
|
||||
DOC_TYPE_LABEL: { eoi: 'EOI', contract: 'Contract', reservation_agreement: 'Reservation' },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
enableSigningAutomation,
|
||||
disableSigningAutomation,
|
||||
} from '@/lib/services/signing-automation.service';
|
||||
|
||||
const meta = {
|
||||
userId: 'user-1',
|
||||
portId: 'port-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test',
|
||||
};
|
||||
|
||||
describe('enableSigningAutomation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws NotFound when document does not exist', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue(null);
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(/document/i);
|
||||
});
|
||||
|
||||
it('throws Conflict when document has no Documenso envelope', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: null,
|
||||
status: 'draft',
|
||||
automationMode: 'manual',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(
|
||||
/no Documenso envelope/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws Conflict when document is completed', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: 'env-1',
|
||||
status: 'completed',
|
||||
automationMode: 'manual',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(/completed/);
|
||||
});
|
||||
|
||||
it('throws Conflict when automation is already enabled', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: 'env-1',
|
||||
status: 'sent',
|
||||
automationMode: 'sequential_auto',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(
|
||||
/already enabled/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws Conflict when fewer than two signers exist', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: 'env-1',
|
||||
status: 'sent',
|
||||
automationMode: 'manual',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
// mockSelect.from.where.orderBy resolves to [] -> ensureSigningUrls
|
||||
// returns 0 signers -> service throws "at least two".
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(
|
||||
/at least two signers/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableSigningAutomation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws NotFound when document does not exist', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue(null);
|
||||
await expect(disableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(/document/i);
|
||||
});
|
||||
|
||||
it('is a no-op when document is already manual (idempotent)', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({ id: 'doc-1', automationMode: 'manual' });
|
||||
await expect(disableSigningAutomation('doc-1', 'port-1', meta)).resolves.toBeUndefined();
|
||||
// No update should fire when already manual.
|
||||
expect(mocks.updateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user