refactor: extract request handling to global DocusignEnvelopeRequestHandler class + deployment guide

Changes:
- Move validation logic to DocusignEnvelopeRequestHandler.validateRequest()
- Move envelope JSON building to DocusignEnvelopeRequestHandler.buildEnvelopeJSON()
- Add comprehensive test suite for handler (10 test methods)
- Simplify main invocable method - focus on orchestration
- Add DEPLOYMENT_AND_TESTING.md reference guide with all CLI commands
- Handler is reusable for future enhancements and integrations

Benefits:
- Single Responsibility Principle
- Reusable handler for other classes
- Better testability
- Clean separation of concerns
This commit is contained in:
Paul Huliganga 2026-02-25 09:32:55 -05:00
parent 4f734f0d17
commit 2f7dcf3520
6 changed files with 585 additions and 161 deletions

View File

@ -0,0 +1,218 @@
# Deployment & Testing Guide
Quick reference for deploying the Salesforce Composite Envelope Builder to your org and running tests.
---
## Quick Start (Recommended)
### Deploy + Test in One Script
```bash
cd /home/paulh/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder
bash deploy-to-dev-org.sh
```
This script handles:
- ✅ Org authorization (opens browser login)
- ✅ Code deployment
- ✅ Unit test execution with code coverage
- ✅ Human-readable results
---
## Manual Commands
### 1. Authorize Your Org (First Time Only)
```bash
# For Developer Edition
sf org login web --alias dev-org --instance-url https://login.salesforce.com
# For Sandbox
sf org login web --alias sandbox-org --instance-url https://test.salesforce.com
```
You'll be redirected to Salesforce login. Once authorized, the org alias is saved for future deploys.
---
### 2. Deploy Code
**Deploy all code to your org:**
```bash
cd /home/paulh/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder
sf project deploy start --target-org dev-org
```
**Options:**
- `--target-org dev-org` — Use the authorized org alias (replace with your alias if different)
- `--wait 10` — Wait up to 10 minutes for deployment to complete
---
### 3. Run Tests
**Run ALL unit tests with code coverage:**
```bash
sf apex run test --wait 10 --result-format human --code-coverage --target-org dev-org
```
**Run ONLY the new handler tests:**
```bash
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --target-org dev-org
```
**Run specific test methods:**
```bash
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --method-names testValidateRequest_Success --wait 10 --result-format human --target-org dev-org
```
**Run ALL tests with JSON output (for parsing):**
```bash
sf apex run test --wait 10 --result-format json --code-coverage --target-org dev-org > test-results.json
```
---
## Complete Deploy + Test in One Command
```bash
cd /home/paulh/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder && \
sf project deploy start --target-org dev-org && \
sf apex run test --wait 10 --result-format human --code-coverage --target-org dev-org
```
---
## Understanding Test Results
Sample output:
```
Apex Tests
═════════════════════════════════════════════════════════════════
Test Results Summary [Included Code Coverage]
═════════════════════════════════════════════════════════════════
Outcome: Passed
Tests Ran: 39
Passes: 39
Failures: 0
Skipped: 0
Pass Rate: 100%
Apex Code Coverage: 92%
```
**Key metrics:**
- **Pass Rate** — Percentage of tests that passed (should be 100%)
- **Apex Code Coverage** — Percentage of code executed by tests (should be >80%)
- **Failures** — If > 0, review the failed test names and error messages below
---
## Post-Deployment Setup
After successful deployment, configure your Salesforce org:
### 1. Create Docusign Configuration Custom Setting
1. Log into your Salesforce org
2. Go to **Setup** → **Custom Settings**
3. Click **Manage** next to **Docusign Configuration**
4. Click **New**
5. Fill in:
- **Account Id:** `{your Docusign sandbox account ID}`
- **Base URL:** `callout:DocusignAPI`
6. Click **Save**
### 2. Create Named Credential for Docusign API
1. Go to **Setup** → **Named Credentials**
2. Click **New Named Credential**
3. Fill in:
- **Name:** `DocusignAPI`
- **Label:** `Docusign API`
- **URL:** `https://demo.docusign.net/restapi/v2.1`
- **Identity Type:** Named Principal
- **Authentication Protocol:** OAuth 2.0
- **Authentication Provider:** DocusignOAuthProvider (if available)
4. Click **Save**
### 3. Update Your Screen Flow
1. Go to **Flow Builder**
2. Create or edit your Screen Flow
3. Add an **Action** to the flow:
- Action: **Send Composite Docusign Envelope**
- Input Variables:
- **Template IDs** — Comma-separated Docusign template IDs
- **Salesforce Record ID** — {!Record.Id}
- **Language** — en or es
- **Email Subject** — Optional custom subject
4. Output Variables:
- **Envelope ID** — Unique Docusign envelope ID
- **Success** — Boolean (true/false)
- **Error Message** — Error details if creation failed
5. Save and test the flow
---
## Troubleshooting
### Authorization Issues
```bash
# If auth fails, try manual URL method:
sf org login web --alias dev-org --instance-url https://login.salesforce.com --json
# Copy the 'url' value and paste into your browser
```
### Deployment Errors
```bash
# Check what's in your org
sf project list metadata --target-org dev-org
# Validate without deploying
sf project validate deploy --target-org dev-org
```
### Test Failures
```bash
# Run tests with verbose output
sf apex run test --target-org dev-org --result-format human --code-coverage -v
```
---
## Reference: Classes Deployed
**Apex Classes:**
- `DocusignCompositeEnvelopeBuilder` — Main invocable class (Flows)
- `DocusignEnvelopeRequestHandler` — Request validation & envelope building (reusable)
- `DocusignAPIService` — Docusign REST API wrapper
- `DocusignCredentials` — Custom settings singleton
**Test Classes:**
- `DocusignCompositeEnvelopeBuilderTest` — Tests for main invocable
- `DocusignEnvelopeRequestHandlerTest` — Tests for request handler (10 test methods)
- `DocusignAPIServiceTest` — Tests for API service
- `DocusignCredentialsTest` — Tests for credentials
**Custom Settings:**
- `Docusign_Configuration__c` — Stores Account ID and Base URL
---
## Next Steps After Successful Deploy
1. ✅ Verify all tests pass
2. ✅ Create Docusign Configuration in Salesforce
3. ✅ Set up Named Credential
4. ✅ Create a test Screen Flow
5. ✅ Test with real Docusign templates
6. ✅ Deploy to Production (when ready)
---
For more details, see:
- `docs/deployment-guide.md` — Detailed deployment instructions
- `docs/api-reference.md` — API reference
- `docs/design.md` — Architecture & design decisions

View File

@ -27,22 +27,11 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
Result result = new Result(); Result result = new Result();
try { try {
// Validate inputs // Validate request using handler
validateInputs(req); DocusignEnvelopeRequestHandler.validateRequest(req);
// Remove duplicates and sort alphabetically // Build envelope JSON using handler
List<String> sortedTemplateIds = sortTemplatesAlphabetically( String envelopeJSON = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
new Set<String>(req.templateIds)
);
// Build composite envelope JSON
String envelopeJSON = buildCompositeEnvelopeJSON(
sortedTemplateIds,
req.recordId,
req.language,
req.emailSubject,
null // customFields not supported in InvocableVariable (Phase 2 enhancement)
);
// Get Docusign credentials // Get Docusign credentials
DocusignCredentials creds = DocusignCredentials.getInstance(); DocusignCredentials creds = DocusignCredentials.getInstance();
@ -85,152 +74,6 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
return results; return results;
} }
/**
* @description Validates input parameters
* @param req Request object to validate
* @throws IllegalArgumentException if validation fails
*/
private static void validateInputs(Request req) {
if (req.templateIds == null || req.templateIds.isEmpty()) {
throw new IllegalArgumentException('At least one template ID is required');
}
if (req.templateIds.size() > 14) {
throw new IllegalArgumentException('Maximum 14 templates allowed per envelope');
}
if (String.isBlank(req.recordId)) {
throw new IllegalArgumentException('Salesforce record ID is required');
}
// Check for null template IDs
for (String templateId : req.templateIds) {
if (String.isBlank(templateId)) {
throw new IllegalArgumentException('Template ID cannot be blank');
}
}
}
/**
* @description Removes duplicates and sorts template IDs alphabetically
* @param templateIdSet Set of template IDs
* @return Sorted list of unique template IDs
*/
private static List<String> sortTemplatesAlphabetically(Set<String> templateIdSet) {
List<String> sortedList = new List<String>(templateIdSet);
sortedList.sort();
return sortedList;
}
/**
* @description Builds composite envelope JSON for Docusign API
* @param templateIds List of template IDs to combine
* @param recordId Salesforce record ID for custom fields
* @param language Language code (en/es)
* @param emailSubject Email subject line
* @param customFields Map of custom field name/value pairs
* @return JSON string for Docusign API request
*/
@TestVisible
private static String buildCompositeEnvelopeJSON(
List<String> templateIds,
String recordId,
String language,
String emailSubject,
Map<String, String> customFields
) {
// Build composite templates array
List<Object> compositeTemplates = new List<Object>();
Integer sequence = 1;
for (String templateId : templateIds) {
Map<String, Object> compositeTemplate = new Map<String, Object>{
'compositeTemplateId' => String.valueOf(sequence),
'serverTemplates' => new List<Object>{
new Map<String, Object>{
'sequence' => String.valueOf(sequence),
'templateId' => templateId
}
}
};
// Add custom fields if this is the first template
if (sequence == 1 && (String.isNotBlank(recordId) || customFields != null)) {
compositeTemplate.put('inlineTemplates', buildInlineTemplates(recordId, language, customFields));
}
compositeTemplates.add(compositeTemplate);
sequence++;
}
// Build envelope object
Map<String, Object> envelope = new Map<String, Object>{
'status' => 'sent',
'emailSubject' => String.isNotBlank(emailSubject)
? emailSubject
: 'Please review and sign these forms',
'compositeTemplates' => compositeTemplates
};
return JSON.serialize(envelope);
}
/**
* @description Builds inline templates for custom fields
* @param recordId Salesforce record ID
* @param language Language code
* @param customFields Additional custom fields
* @return List of inline template objects
*/
private static List<Object> buildInlineTemplates(
String recordId,
String language,
Map<String, String> customFields
) {
List<Object> textCustomFields = new List<Object>();
// Add Salesforce record ID
if (String.isNotBlank(recordId)) {
textCustomFields.add(new Map<String, Object>{
'name' => 'SalesforceRecordId',
'value' => recordId,
'show' => 'false',
'required' => 'false'
});
}
// Add language
if (String.isNotBlank(language)) {
textCustomFields.add(new Map<String, Object>{
'name' => 'Language',
'value' => language,
'show' => 'false',
'required' => 'false'
});
}
// Add additional custom fields
if (customFields != null && !customFields.isEmpty()) {
for (String fieldName : customFields.keySet()) {
textCustomFields.add(new Map<String, Object>{
'name' => fieldName,
'value' => customFields.get(fieldName),
'show' => 'false',
'required' => 'false'
});
}
}
return new List<Object>{
new Map<String, Object>{
'sequence' => '1',
'customFields' => new Map<String, Object>{
'textCustomFields' => textCustomFields
}
}
};
}
/** /**
* @description Logs API call to debug log (future: custom object) * @description Logs API call to debug log (future: custom object)
* @param templateCount Number of templates in envelope * @param templateCount Number of templates in envelope

View File

@ -0,0 +1,173 @@
/**
* @description Handles request validation and envelope JSON building for Docusign composite envelopes
* @author Paul Huliganga
* @date 2026-02-25
*/
global with sharing class DocusignEnvelopeRequestHandler {
/**
* @description Validates composite envelope request parameters
* @param req Request object to validate
* @throws IllegalArgumentException if validation fails
*/
public static void validateRequest(DocusignCompositeEnvelopeBuilder.Request req) {
if (req.templateIds == null || req.templateIds.isEmpty()) {
throw new IllegalArgumentException('At least one template ID is required');
}
if (req.templateIds.size() > 14) {
throw new IllegalArgumentException('Maximum 14 templates allowed per envelope');
}
if (String.isBlank(req.recordId)) {
throw new IllegalArgumentException('Salesforce record ID is required');
}
// Check for null template IDs
for (String templateId : req.templateIds) {
if (String.isBlank(templateId)) {
throw new IllegalArgumentException('Template ID cannot be blank');
}
}
}
/**
* @description Builds composite envelope JSON for Docusign API from request
* @param req Request object containing parameters
* @return JSON string for Docusign API request
*/
public static String buildEnvelopeJSON(DocusignCompositeEnvelopeBuilder.Request req) {
// Remove duplicates and sort alphabetically
List<String> sortedTemplateIds = sortTemplatesAlphabetically(
new Set<String>(req.templateIds)
);
// Build composite envelope JSON
return buildCompositeEnvelopeJSON(
sortedTemplateIds,
req.recordId,
req.language,
req.emailSubject,
null // customFields not supported in InvocableVariable (Phase 2 enhancement)
);
}
/**
* @description Removes duplicates and sorts template IDs alphabetically
* @param templateIdSet Set of template IDs
* @return Sorted list of unique template IDs
*/
private static List<String> sortTemplatesAlphabetically(Set<String> templateIdSet) {
List<String> sortedList = new List<String>(templateIdSet);
sortedList.sort();
return sortedList;
}
/**
* @description Builds composite envelope JSON for Docusign API
* @param templateIds List of template IDs to combine
* @param recordId Salesforce record ID for custom fields
* @param language Language code (en/es)
* @param emailSubject Email subject line
* @param customFields Map of custom field name/value pairs
* @return JSON string for Docusign API request
*/
private static String buildCompositeEnvelopeJSON(
List<String> templateIds,
String recordId,
String language,
String emailSubject,
Map<String, String> customFields
) {
// Build composite templates array
List<Object> compositeTemplates = new List<Object>();
Integer sequence = 1;
for (String templateId : templateIds) {
Map<String, Object> compositeTemplate = new Map<String, Object>{
'compositeTemplateId' => String.valueOf(sequence),
'serverTemplates' => new List<Object>{
new Map<String, Object>{
'sequence' => String.valueOf(sequence),
'templateId' => templateId
}
}
};
// Add custom fields if this is the first template
if (sequence == 1 && (String.isNotBlank(recordId) || customFields != null)) {
compositeTemplate.put('inlineTemplates', buildInlineTemplates(recordId, language, customFields));
}
compositeTemplates.add(compositeTemplate);
sequence++;
}
// Build envelope object
Map<String, Object> envelope = new Map<String, Object>{
'status' => 'sent',
'emailSubject' => String.isNotBlank(emailSubject)
? emailSubject
: 'Please review and sign these forms',
'compositeTemplates' => compositeTemplates
};
return JSON.serialize(envelope);
}
/**
* @description Builds inline templates for custom fields
* @param recordId Salesforce record ID
* @param language Language code
* @param customFields Additional custom fields
* @return List of inline template objects
*/
private static List<Object> buildInlineTemplates(
String recordId,
String language,
Map<String, String> customFields
) {
List<Object> textCustomFields = new List<Object>();
// Add Salesforce record ID
if (String.isNotBlank(recordId)) {
textCustomFields.add(new Map<String, Object>{
'name' => 'SalesforceRecordId',
'value' => recordId,
'show' => 'false',
'required' => 'false'
});
}
// Add language
if (String.isNotBlank(language)) {
textCustomFields.add(new Map<String, Object>{
'name' => 'Language',
'value' => language,
'show' => 'false',
'required' => 'false'
});
}
// Add additional custom fields
if (customFields != null && !customFields.isEmpty()) {
for (String fieldName : customFields.keySet()) {
textCustomFields.add(new Map<String, Object>{
'name' => fieldName,
'value' => customFields.get(fieldName),
'show' => 'false',
'required' => 'false'
});
}
}
return new List<Object>{
new Map<String, Object>{
'sequence' => '1',
'customFields' => new Map<String, Object>{
'textCustomFields' => textCustomFields
}
}
};
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -0,0 +1,180 @@
/**
* @description Tests for DocusignEnvelopeRequestHandler
* @author Paul Huliganga
* @date 2026-02-25
*/
@isTest
public class DocusignEnvelopeRequestHandlerTest {
@isTest
static void testValidateRequest_Success() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1', 'template2' };
req.recordId = '001xx000003DHf';
req.language = 'en';
req.emailSubject = 'Test Subject';
// Test
Test.startTest();
DocusignEnvelopeRequestHandler.validateRequest(req);
Test.stopTest();
// Verify - no exception thrown
Assert.isTrue(true, 'Validation should pass');
}
@isTest
static void testValidateRequest_NoTemplateIds() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>();
req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try {
DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error message');
}
Test.stopTest();
}
@isTest
static void testValidateRequest_TooManyTemplates() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>();
for (Integer i = 0; i < 15; i++) {
req.templateIds.add('template' + i);
}
req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try {
DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error message');
}
Test.stopTest();
}
@isTest
static void testValidateRequest_NoRecordId() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1' };
req.recordId = '';
// Test & Verify
Test.startTest();
try {
DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error message');
}
Test.stopTest();
}
@isTest
static void testValidateRequest_BlankTemplateId() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1', '', 'template3' };
req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try {
DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error message');
}
Test.stopTest();
}
@isTest
static void testBuildEnvelopeJSON_Success() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template2', 'template1', 'template2' }; // duplicates and unsorted
req.recordId = '001xx000003DHf';
req.language = 'es';
req.emailSubject = 'Custom Subject';
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify
Assert.isNotNull(json, 'JSON should be generated');
Assert.isTrue(json.contains('sent'), 'Should contain sent status');
Assert.isTrue(json.contains('Custom Subject'), 'Should contain custom subject');
Assert.isTrue(json.contains('compositeTemplates'), 'Should contain compositeTemplates');
}
@isTest
static void testBuildEnvelopeJSON_DefaultSubject() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1' };
req.recordId = '001xx000003DHf';
req.emailSubject = null;
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify
Assert.isTrue(json.contains('Please review and sign these forms'), 'Should use default subject');
}
@isTest
static void testBuildEnvelopeJSON_TemplatesAreSorted() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template3', 'template1', 'template2' };
req.recordId = '001xx000003DHf';
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify - templates should be in sorted order (template1, template2, template3)
Integer idx1 = json.indexOf('template1');
Integer idx2 = json.indexOf('template2');
Integer idx3 = json.indexOf('template3');
Assert.isTrue(idx1 < idx2 && idx2 < idx3, 'Templates should be sorted alphabetically');
}
@isTest
static void testBuildEnvelopeJSON_DuplicatesRemoved() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1', 'template1', 'template2' };
req.recordId = '001xx000003DHf';
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify - should only have 2 compositeTemplate entries
Integer count = 0;
Integer pos = 0;
while ((pos = json.indexOf('compositeTemplateId', pos)) != -1) {
count++;
pos++;
}
Assert.areEqual(2, count, 'Should have 2 unique templates after deduplication');
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<status>Active</status>
</ApexClass>