refactor: switch to dfsle Apex Toolkit - remove raw API code

BREAKING CHANGE: Now uses dfsle managed package for Docusign integration.
No separate credentials or Named Credentials needed.

Changes:
- Rewrote DocusignCompositeEnvelopeBuilder to use dfsle.EnvelopeService
  and dfsle.Document.fromTemplate() for composite envelopes
- Simplified DocusignEnvelopeRequestHandler to validation-only
- Updated all tests to use dfsle.TestUtils.setMock()
- 12 comprehensive test methods (success, validation, edge cases)

Deleted (no longer needed):
- DocusignAPIService.cls (raw HTTP callouts)
- DocusignAPIServiceTest.cls
- DocusignCredentials.cls (custom settings)
- DocusignCredentialsTest.cls

Benefits:
- Uses existing dfsle package authentication automatically
- No Named Credential or Custom Setting setup required
- ~95 lines of code vs ~380 lines before
- Fully supported by Docusign managed package
This commit is contained in:
Paul Huliganga 2026-02-25 11:29:38 -05:00
parent ace2518349
commit 4b1edd4d27
12 changed files with 163 additions and 1488 deletions

View File

@ -1,271 +0,0 @@
/**
* @description Service class for Docusign REST API interactions
* @author Paul Huliganga
* @date 2026-02-23
*/
public with sharing class DocusignAPIService {
private static final Integer TIMEOUT_MS = 120000; // 120 seconds
private static final Integer MAX_RETRIES = 2;
/**
* @description Creates a composite envelope in Docusign
* @param envelopeJSON JSON string containing envelope definition
* @param creds Docusign credentials object
* @return Envelope ID (GUID)
* @throws CalloutException if API call fails
*/
public static String createCompositeEnvelope(
String envelopeJSON,
DocusignCredentials creds
) {
Long startTime = System.currentTimeMillis();
// Build HTTP request
HttpRequest req = buildCreateEnvelopeRequest(envelopeJSON, creds);
// Make callout with retry logic
HttpResponse res = executeWithRetry(req, MAX_RETRIES);
Long duration = System.currentTimeMillis() - startTime;
// Log request/response (sanitized)
logAPICall(req, res, duration);
// Parse response
if (res.getStatusCode() == 201) {
return parseEnvelopeId(res);
} else {
handleAPIError(res);
return null; // Won't reach here, handleAPIError throws
}
}
/**
* @description Builds HTTP request for creating envelope
* @param envelopeJSON JSON body
* @param creds Credentials
* @return Configured HttpRequest
*/
@TestVisible
private static HttpRequest buildCreateEnvelopeRequest(
String envelopeJSON,
DocusignCredentials creds
) {
HttpRequest req = new HttpRequest();
// Use Named Credential if available, otherwise build URL manually
String endpoint = buildEnvelopeEndpoint(creds);
req.setEndpoint(endpoint);
req.setMethod('POST');
req.setHeader('Authorization', 'Bearer ' + creds.getAccessToken());
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setBody(envelopeJSON);
req.setTimeout(TIMEOUT_MS);
return req;
}
/**
* @description Builds envelope endpoint URL
* @param creds Docusign credentials
* @return Full endpoint URL
*/
private static String buildEnvelopeEndpoint(DocusignCredentials creds) {
// Check if using Named Credential (starts with 'callout:')
if (creds.getBaseUrl().startsWith('callout:')) {
return creds.getBaseUrl() + '/accounts/' + creds.getAccountId() + '/envelopes';
} else {
return creds.getBaseUrl() + '/restapi/v2.1/accounts/' + creds.getAccountId() + '/envelopes';
}
}
/**
* @description Executes HTTP request with retry logic
* @param req HTTP request
* @param maxRetries Maximum number of retry attempts
* @return HTTP response
* @throws CalloutException if all retries fail
*/
@TestVisible
private static HttpResponse executeWithRetry(HttpRequest req, Integer maxRetries) {
Http http = new Http();
HttpResponse res;
Integer attempt = 0;
while (attempt <= maxRetries) {
attempt++;
try {
res = http.send(req);
// Success or non-retryable error
if (res.getStatusCode() == 201 || !isRetryableError(res.getStatusCode())) {
return res;
}
// Rate limit - wait before retry
if (res.getStatusCode() == 429 && attempt <= maxRetries) {
Integer waitMs = calculateBackoff(attempt);
System.debug('Rate limited, waiting ' + waitMs + 'ms before retry ' + attempt);
// Note: Apex doesn't have Thread.sleep, but in production this would be handled by Platform Events
// For now, just retry immediately
}
} catch (System.CalloutException e) {
// Timeout or connection error
if (attempt > maxRetries) {
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries: ' + e.getMessage());
}
System.debug('Callout failed (attempt ' + attempt + '): ' + e.getMessage());
}
}
// All retries exhausted
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries. Last status: ' + res.getStatusCode());
}
/**
* @description Determines if an HTTP status code is retryable
* @param statusCode HTTP status code
* @return True if error is transient and should be retried
*/
private static Boolean isRetryableError(Integer statusCode) {
// 401 (token expired), 429 (rate limit), 500-599 (server errors)
return statusCode == 401 || statusCode == 429 || (statusCode >= 500 && statusCode < 600);
}
/**
* @description Calculates exponential backoff delay
* @param attempt Retry attempt number
* @return Delay in milliseconds
*/
private static Integer calculateBackoff(Integer attempt) {
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, ...)
return (Integer) Math.pow(2, attempt) * 1000;
}
/**
* @description Parses envelope ID from successful response
* @param res HTTP response
* @return Envelope ID (GUID)
*/
@TestVisible
private static String parseEnvelopeId(HttpResponse res) {
try {
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String envelopeId = (String) responseMap.get('envelopeId');
if (String.isBlank(envelopeId)) {
throw new CalloutException('Envelope ID not found in Docusign response');
}
return envelopeId;
} catch (Exception e) {
throw new CalloutException('Failed to parse Docusign response: ' + e.getMessage());
}
}
/**
* @description Handles API error responses
* @param res HTTP response with error
* @throws CalloutException with detailed error message
*/
@TestVisible
private static void handleAPIError(HttpResponse res) {
String errorMessage = 'Docusign API error [' + res.getStatusCode() + ']';
try {
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String errorCode = (String) errorBody.get('errorCode');
String message = (String) errorBody.get('message');
if (String.isNotBlank(errorCode)) {
errorMessage += ' ' + errorCode;
}
if (String.isNotBlank(message)) {
errorMessage += ': ' + message;
}
} catch (Exception e) {
// Could not parse error body, use raw response
errorMessage += ': ' + res.getBody();
}
// Map common errors to user-friendly messages
errorMessage = enhanceErrorMessage(res.getStatusCode(), errorMessage);
throw new CalloutException(errorMessage);
}
/**
* @description Enhances error messages with user-friendly guidance
* @param statusCode HTTP status code
* @param originalMessage Original error message
* @return Enhanced error message
*/
private static String enhanceErrorMessage(Integer statusCode, String originalMessage) {
switch on statusCode {
when 400 {
return originalMessage + ' - Check template IDs and request parameters.';
}
when 401 {
return originalMessage + ' - Authentication failed. Check API credentials and access token.';
}
when 403 {
return originalMessage + ' - User lacks permission to create envelopes.';
}
when 404 {
return originalMessage + ' - One or more templates not found in Docusign account.';
}
when 429 {
return originalMessage + ' - API rate limit exceeded. Please try again later.';
}
when 500, 503 {
return originalMessage + ' - Docusign server error. Please try again.';
}
when else {
return originalMessage;
}
}
}
/**
* @description Logs API call details (sanitized, no credentials)
* @param req HTTP request
* @param res HTTP response
* @param durationMs Duration in milliseconds
*/
private static void logAPICall(HttpRequest req, HttpResponse res, Long durationMs) {
System.debug(LoggingLevel.INFO, '=== Docusign API Call ===');
System.debug(LoggingLevel.INFO, 'Method: ' + req.getMethod());
System.debug(LoggingLevel.INFO, 'Endpoint: ' + sanitizeEndpoint(req.getEndpoint()));
System.debug(LoggingLevel.INFO, 'Request Body Length: ' + req.getBody().length() + ' bytes');
System.debug(LoggingLevel.INFO, 'Response Status: ' + res.getStatusCode() + ' ' + res.getStatus());
System.debug(LoggingLevel.INFO, 'Response Body Length: ' + res.getBody().length() + ' bytes');
System.debug(LoggingLevel.INFO, 'Duration: ' + durationMs + 'ms');
// Only log full bodies in DEBUG mode (not in production)
if (Test.isRunningTest()) {
System.debug(LoggingLevel.FINEST, 'Request Body: ' + req.getBody());
System.debug(LoggingLevel.FINEST, 'Response Body: ' + res.getBody());
}
}
/**
* @description Sanitizes endpoint URL for logging (removes account ID)
* @param endpoint Full endpoint URL
* @return Sanitized URL
*/
private static String sanitizeEndpoint(String endpoint) {
// Replace account ID with placeholder for security
return endpoint.replaceAll('/accounts/[a-f0-9\\-]+/', '/accounts/{accountId}/');
}
/**
* @description Custom exception for API errors
*/
public class CalloutException extends Exception {}
}

View File

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

View File

@ -1,323 +0,0 @@
/**
* @description Test class for DocusignAPIService
* @author Paul Huliganga
* @date 2026-02-23
*/
@isTest
private class DocusignAPIServiceTest {
/**
* @description Mock HTTP callout
*/
private class DocusignMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
private Integer callCount = 0;
public DocusignMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HTTPResponse respond(HTTPRequest req) {
callCount++;
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.responseBody);
res.setStatus(this.statusCode == 201 ? 'Created' : 'Error');
return res;
}
}
/**
* @description Mock that fails first time, succeeds second (for retry testing)
*/
private class RetryMock implements HttpCalloutMock {
private Integer callCount = 0;
public HTTPResponse respond(HTTPRequest req) {
callCount++;
HttpResponse res = new HttpResponse();
if (callCount == 1) {
// First call fails with 500
res.setStatusCode(500);
res.setBody('{"errorCode":"INTERNAL_ERROR","message":"Server error"}');
res.setStatus('Internal Server Error');
} else {
// Second call succeeds
res.setStatusCode(201);
res.setBody('{"envelopeId":"envelope-retry-success","status":"sent"}');
res.setStatus('Created');
}
return res;
}
}
/**
* @description Test successful envelope creation
*/
@isTest
static void testSuccessfulEnvelopeCreation() {
// Arrange
String envelopeId = 'envelope-test-12345';
String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
String envelopeJSON = '{"status":"sent","compositeTemplates":[]}';
// Act
Test.startTest();
String resultEnvelopeId = DocusignAPIService.createCompositeEnvelope(envelopeJSON, creds);
Test.stopTest();
// Assert
System.assertEquals(envelopeId, resultEnvelopeId, 'Should return envelope ID');
}
/**
* @description Test parseEnvelopeId method
*/
@isTest
static void testParseEnvelopeId() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(201);
res.setBody('{"envelopeId":"parsed-envelope-123","uri":"/envelopes/parsed-envelope-123"}');
// Act
Test.startTest();
String envelopeId = DocusignAPIService.parseEnvelopeId(res);
Test.stopTest();
// Assert
System.assertEquals('parsed-envelope-123', envelopeId, 'Should parse envelope ID correctly');
}
/**
* @description Test parseEnvelopeId with missing ID
*/
@isTest
static void testParseEnvelopeIdMissing() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(201);
res.setBody('{"status":"sent"}'); // No envelopeId field
// Act & Assert
Test.startTest();
try {
DocusignAPIService.parseEnvelopeId(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('Envelope ID not found'), 'Should mention missing ID');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 400 Bad Request
*/
@isTest
static void testHandleAPIError400() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(400);
res.setBody('{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('400'), 'Should include status code');
System.assert(e.getMessage().contains('Invalid template'), 'Should include error message');
System.assert(e.getMessage().contains('template IDs'), 'Should include guidance');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 401 Unauthorized
*/
@isTest
static void testHandleAPIError401() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(401);
res.setBody('{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid access token"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('401'), 'Should include status code');
System.assert(e.getMessage().contains('Authentication'), 'Should mention authentication');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 403 Forbidden
*/
@isTest
static void testHandleAPIError403() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(403);
res.setBody('{"errorCode":"USER_LACKS_PERMISSIONS","message":"User cannot send envelopes"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('403'), 'Should include status code');
System.assert(e.getMessage().contains('permission'), 'Should mention permission');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 404 Not Found
*/
@isTest
static void testHandleAPIError404() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(404);
res.setBody('{"errorCode":"RESOURCE_NOT_FOUND","message":"Template not found"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('404'), 'Should include status code');
System.assert(e.getMessage().contains('not found'), 'Should mention template not found');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 429 Rate Limit
*/
@isTest
static void testHandleAPIError429() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(429);
res.setBody('{"errorCode":"HOURLY_APIINVOCATION_LIMIT_EXCEEDED","message":"Rate limit exceeded"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('429'), 'Should include status code');
System.assert(e.getMessage().contains('rate limit'), 'Should mention rate limit');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 500 Server Error
*/
@isTest
static void testHandleAPIError500() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(500);
res.setBody('{"errorCode":"INTERNAL_ERROR","message":"Server error"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('500'), 'Should include status code');
System.assert(e.getMessage().contains('server error'), 'Should mention server error');
}
Test.stopTest();
}
/**
* @description Test retry logic with transient failure
*/
@isTest
static void testRetryLogic() {
// Arrange
Test.setMock(HttpCalloutMock.class, new RetryMock());
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
String envelopeJSON = '{"status":"sent","compositeTemplates":[]}';
// Act
Test.startTest();
String envelopeId = DocusignAPIService.createCompositeEnvelope(envelopeJSON, creds);
Test.stopTest();
// Assert
System.assertEquals('envelope-retry-success', envelopeId, 'Should succeed after retry');
}
/**
* @description Test buildCreateEnvelopeRequest
*/
@isTest
static void testBuildCreateEnvelopeRequest() {
// Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
String envelopeJSON = '{"status":"sent"}';
// Act
Test.startTest();
HttpRequest req = DocusignAPIService.buildCreateEnvelopeRequest(envelopeJSON, creds);
Test.stopTest();
// Assert
System.assertEquals('POST', req.getMethod(), 'Should use POST method');
System.assert(req.getEndpoint().contains('/envelopes'), 'Should have envelopes endpoint');
System.assert(req.getHeader('Authorization').contains('Bearer'), 'Should have Bearer token');
System.assertEquals('application/json', req.getHeader('Content-Type'), 'Should set JSON content type');
System.assertEquals(envelopeJSON, req.getBody(), 'Should set body');
}
/**
* @description Test executeWithRetry with non-retryable error
*/
@isTest
static void testExecuteWithRetryNonRetryable() {
// Arrange
String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Bad request"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
HttpRequest req = DocusignAPIService.buildCreateEnvelopeRequest('{}', creds);
// Act
Test.startTest();
HttpResponse res = DocusignAPIService.executeWithRetry(req, 2);
Test.stopTest();
// Assert
System.assertEquals(400, res.getStatusCode(), 'Should return 400 without retry');
}
}

View File

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

View File

@ -1,24 +1,19 @@
/** /**
* @description Main invocable class for combining multiple Docusign templates into a single envelope * @description Combines multiple Docusign templates into a single composite envelope
* using the dfsle Apex Toolkit (Docusign for Salesforce managed package)
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-23 * @date 2026-02-25
*/ */
global with sharing class DocusignCompositeEnvelopeBuilder { global with sharing class DocusignCompositeEnvelopeBuilder {
/**
* @description Invocable method called from Salesforce Screen Flow
* @param requests List of request objects containing template IDs and metadata
* @return List of result objects with envelope ID and status
*/
@InvocableMethod( @InvocableMethod(
label='Send Composite Docusign Envelope' label='Send Composite Docusign Envelope'
description='Combines multiple Docusign templates into a single envelope' description='Combines multiple Docusign templates into a single envelope using dfsle Apex Toolkit'
category='Docusign' category='Docusign'
) )
public static List<DocusignEnvelopeResult> sendCompositeEnvelope(List<DocusignEnvelopeRequest> requests) { public static List<DocusignEnvelopeResult> sendCompositeEnvelope(List<DocusignEnvelopeRequest> requests) {
List<DocusignEnvelopeResult> results = new List<DocusignEnvelopeResult>(); List<DocusignEnvelopeResult> results = new List<DocusignEnvelopeResult>();
// Process first request (Flow only sends one)
if (requests == null || requests.isEmpty()) { if (requests == null || requests.isEmpty()) {
return buildErrorResult('No request provided'); return buildErrorResult('No request provided');
} }
@ -27,44 +22,57 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
DocusignEnvelopeResult result = new DocusignEnvelopeResult(); DocusignEnvelopeResult result = new DocusignEnvelopeResult();
try { try {
// Validate request using handler // Validate request
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
// Build envelope JSON using handler // Create empty envelope linked to the source Salesforce record
String envelopeJSON = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); dfsle.Envelope myEnvelope = dfsle.EnvelopeService.getEmptyEnvelope(
new dfsle.Entity(req.recordId)
// Get Docusign credentials
DocusignCredentials creds = DocusignCredentials.getInstance();
// Call Docusign API
String envelopeId = DocusignAPIService.createCompositeEnvelope(
envelopeJSON,
creds
); );
// Build document list from templates (deduplicated and sorted)
List<String> sortedTemplateIds = new List<String>(new Set<String>(req.templateIds));
sortedTemplateIds.sort();
List<dfsle.Document> documents = new List<dfsle.Document>();
for (String templateId : sortedTemplateIds) {
documents.add(
dfsle.Document.fromTemplate(
dfsle.UUID.parse(templateId),
'Template ' + templateId.left(8)
)
);
}
// Add all templates as documents to the envelope
myEnvelope = myEnvelope.withDocuments(documents);
// Set email subject if provided
if (String.isNotBlank(req.emailSubject)) {
myEnvelope = myEnvelope.withEmail(req.emailSubject, '');
}
// Send the envelope (true = send immediately)
myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, true);
// Success // Success
result.envelopeId = envelopeId; result.envelopeId = String.valueOf(myEnvelope.docuSignId);
result.success = true; result.success = true;
result.errorMessage = null; result.errorMessage = null;
// Log success logResult(sortedTemplateIds.size(), result.envelopeId, 'Success', null);
logAPICall(req.templateIds.size(), envelopeId, 'Success', null);
} catch (Exception e) { } catch (Exception e) {
// Error handling
result.success = false; result.success = false;
result.errorMessage = e.getMessage(); result.errorMessage = e.getMessage();
result.envelopeId = null; result.envelopeId = null;
// Log error logResult(
logAPICall(
req.templateIds != null ? req.templateIds.size() : 0, req.templateIds != null ? req.templateIds.size() : 0,
null, null, 'Error',
'Error',
e.getMessage() + '\n' + e.getStackTraceString() e.getMessage() + '\n' + e.getStackTraceString()
); );
// Re-throw if critical (governor limits)
if (e instanceof System.LimitException) { if (e instanceof System.LimitException) {
throw e; throw e;
} }
@ -74,36 +82,16 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
return results; return results;
} }
/** private static void logResult(Integer templateCount, String envelopeId, String status, String errorMessage) {
* @description Logs API call to debug log (future: custom object) System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope ===');
* @param templateCount Number of templates in envelope System.debug(LoggingLevel.INFO, 'Templates: ' + templateCount);
* @param envelopeId Docusign envelope ID
* @param status Success or Error
* @param errorMessage Error message if applicable
*/
private static void logAPICall(
Integer templateCount,
String envelopeId,
String status,
String errorMessage
) {
System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope API Call ===');
System.debug(LoggingLevel.INFO, 'Timestamp: ' + System.now());
System.debug(LoggingLevel.INFO, 'Template Count: ' + templateCount);
System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId); System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId);
System.debug(LoggingLevel.INFO, 'Status: ' + status); System.debug(LoggingLevel.INFO, 'Status: ' + status);
if (String.isNotBlank(errorMessage)) { if (String.isNotBlank(errorMessage)) {
System.debug(LoggingLevel.ERROR, 'Error: ' + errorMessage); System.debug(LoggingLevel.ERROR, 'Error: ' + errorMessage);
} }
// Future enhancement: Insert into Docusign_API_Log__c custom object
} }
/**
* @description Helper to build error result
* @param errorMessage Error message
* @return List containing single error result
*/
private static List<DocusignEnvelopeResult> buildErrorResult(String errorMessage) { private static List<DocusignEnvelopeResult> buildErrorResult(String errorMessage) {
DocusignEnvelopeResult result = new DocusignEnvelopeResult(); DocusignEnvelopeResult result = new DocusignEnvelopeResult();
result.success = false; result.success = false;

View File

@ -1,114 +1,70 @@
/** /**
* @description Test class for DocusignCompositeEnvelopeBuilder * @description Test class for DocusignCompositeEnvelopeBuilder (dfsle Apex Toolkit)
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-23 * @date 2026-02-25
*/ */
@isTest @isTest
private class DocusignCompositeEnvelopeBuilderTest { private class DocusignCompositeEnvelopeBuilderTest {
/**
* @description Mock HTTP callout for Docusign API
*/
private class DocusignMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
public DocusignMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.responseBody);
res.setStatus(this.statusCode == 201 ? 'Created' : 'Error');
return res;
}
}
/**
* @description Setup test data
*/
@testSetup
static void setup() {
// Create Custom Setting for credentials
Docusign_Configuration__c config = new Docusign_Configuration__c();
config.Account_Id__c = 'test-account-id-12345';
config.Base_URL__c = 'callout:DocusignAPI';
insert config;
}
/**
* @description Test successful envelope creation with 3 templates
*/
@isTest @isTest
static void testSuccessfulEnvelopeCreation() { static void testSuccessfulCompositeEnvelope() {
// Arrange // Arrange
String envelopeId = 'envelope-12345-abcde'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'template-1', 'template-2', 'template-3'}; req.templateIds = new List<String>{
'01234567-abcd-ef01-2345-6789abcdef01',
'01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef03'
};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
req.language = 'en'; req.language = 'en';
req.emailSubject = 'Test Envelope'; req.emailSubject = 'Please sign these forms';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(1, results.size(), 'Should return 1 result'); System.assertEquals(1, results.size(), 'Should return 1 result');
System.assertEquals(true, results[0].success, 'Should be successful'); System.assertEquals(true, results[0].success, 'Should be successful');
System.assertEquals(envelopeId, results[0].envelopeId, 'Should return envelope ID'); System.assertNotEquals(null, results[0].envelopeId, 'Should have envelope ID');
System.assertEquals(null, results[0].errorMessage, 'Should have no error message'); System.assertEquals(null, results[0].errorMessage, 'Should have no error');
} }
/**
* @description Test with single template (minimum)
*/
@isTest @isTest
static void testSingleTemplate() { static void testSingleTemplate() {
// Arrange // Arrange
String mockResponse = '{"envelopeId":"envelope-single","status":"sent"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'template-1'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should be successful with 1 template'); System.assertEquals(true, results[0].success, 'Should succeed with 1 template');
} }
/**
* @description Test with 14 templates (maximum)
*/
@isTest @isTest
static void testMaximumTemplates() { static void testMaximumTemplates() {
// Arrange // Arrange
String mockResponse = '{"envelopeId":"envelope-max","status":"sent"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
List<String> templateIds = new List<String>(); List<String> templateIds = new List<String>();
for (Integer i = 1; i <= 14; i++) { for (Integer i = 1; i <= 14; i++) {
templateIds.add('template-' + i); templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0'));
} }
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
@ -118,162 +74,193 @@ private class DocusignCompositeEnvelopeBuilderTest {
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should be successful with 14 templates'); System.assertEquals(true, results[0].success, 'Should succeed with 14 templates');
} }
/**
* @description Test with duplicate template IDs (should be deduplicated)
*/
@isTest @isTest
static void testDuplicateTemplates() { static void testDuplicateTemplatesDeduped() {
// Arrange // Arrange
String mockResponse = '{"envelopeId":"envelope-dedup","status":"sent"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'template-1', 'template-2', 'template-1'}; // Duplicate req.templateIds = new List<String>{
'01234567-abcd-ef01-2345-6789abcdef01',
'01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef01' // duplicate
};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should handle duplicates'); System.assertEquals(true, results[0].success, 'Should handle duplicates');
} }
/** @isTest
* @description Test validation failure - no template IDs static void testNullRequest() {
*/ // Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(null);
Test.stopTest();
// Assert
System.assertEquals(false, results[0].success, 'Should fail with null request');
System.assertEquals('No request provided', results[0].errorMessage, 'Should have error message');
}
@isTest
static void testEmptyRequest() {
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>()
);
Test.stopTest();
// Assert
System.assertEquals(false, results[0].success, 'Should fail with empty request');
}
@isTest @isTest
static void testValidationNoTemplates() { static void testValidationNoTemplates() {
// Arrange // Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>(); // Empty req.templateIds = new List<String>();
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail validation'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Error should mention templates'); System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Should mention templates');
} }
/**
* @description Test validation failure - too many templates
*/
@isTest @isTest
static void testValidationTooManyTemplates() { static void testValidationTooManyTemplates() {
// Arrange // Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
List<String> templateIds = new List<String>(); List<String> templateIds = new List<String>();
for (Integer i = 1; i <= 15; i++) { for (Integer i = 1; i <= 15; i++) {
templateIds.add('template-' + i); templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0'));
} }
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = templateIds; // 15 templates (> max of 14) req.templateIds = templateIds;
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail validation'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.contains('Maximum 14'), 'Error should mention limit'); System.assert(results[0].errorMessage.contains('Maximum 14'), 'Should mention limit');
} }
/**
* @description Test validation failure - no record ID
*/
@isTest @isTest
static void testValidationNoRecordId() { static void testValidationNoRecordId() {
// Arrange // Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'template-1'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = ''; // Blank req.recordId = '';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail validation'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.contains('record ID'), 'Error should mention record ID'); System.assert(results[0].errorMessage.contains('record ID'), 'Should mention record ID');
} }
/**
* @description Test API error - 400 Bad Request
*/
@isTest @isTest
static void testAPIError400() { static void testValidationBlankTemplateId() {
// Arrange // Arrange
String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'invalid-template'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01', ''};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail with API error'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.contains('400'), 'Error should include status code'); System.assert(results[0].errorMessage.contains('blank'), 'Should mention blank');
} }
/**
* @description Test API error - 401 Unauthorized
*/
@isTest @isTest
static void testAPIError401() { static void testWithEmailSubject() {
// Arrange // Arrange
String mockResponse = '{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid token"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(401, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'invalid-token');
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'template-1'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
req.emailSubject = 'Custom: Please review and sign';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignEnvelopeResult> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail with auth error'); System.assertEquals(true, results[0].success, 'Should succeed with custom subject');
System.assertNotEquals(null, results[0].errorMessage, 'Should have error message'); }
System.assert(String.isNotBlank(results[0].errorMessage), 'Error message should not be blank');
@isTest
static void testWithoutEmailSubject() {
// Arrange
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123';
req.emailSubject = null;
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest();
// Assert
System.assertEquals(true, results[0].success, 'Should succeed without subject');
} }
} }

View File

@ -1,187 +0,0 @@
/**
* @description Manages Docusign API credentials and access tokens
* @author Paul Huliganga
* @date 2026-02-23
*/
public with sharing class DocusignCredentials {
private String baseUrl;
private String accountId;
private String accessToken;
private DateTime tokenExpiry;
// Singleton instance
private static DocusignCredentials instance;
// Flag to skip loadCredentials for setTestCredentials
private static Boolean skipLoadForNextInstance = false;
/**
* @description Private constructor (singleton pattern)
*/
private DocusignCredentials() {
// Only load credentials if not explicitly skipped (for setTestCredentials)
if (!skipLoadForNextInstance) {
loadCredentials();
}
skipLoadForNextInstance = false; // Reset flag
}
/**
* @description Gets singleton instance of credentials
* @return DocusignCredentials instance
*/
public static DocusignCredentials getInstance() {
if (instance == null) {
instance = new DocusignCredentials();
}
// Refresh token if expired
if (instance.isTokenExpired()) {
instance.refreshAccessToken();
}
return instance;
}
/**
* @description Loads credentials from Named Credential or Custom Settings
*/
private void loadCredentials() {
// Option 1: Using Named Credential (preferred)
// When using Named Credential, the platform handles authentication automatically
// We just need the account ID and base URL
try {
// Try to load from Custom Settings first
Docusign_Configuration__c config = Docusign_Configuration__c.getOrgDefaults();
if (config != null && String.isNotBlank(config.Account_Id__c)) {
this.accountId = config.Account_Id__c;
this.baseUrl = String.isNotBlank(config.Base_URL__c)
? config.Base_URL__c
: 'callout:DocusignAPI'; // Default to Named Credential
// If using Named Credential, token is managed by platform
if (this.baseUrl.startsWith('callout:')) {
this.accessToken = 'MANAGED_BY_NAMED_CREDENTIAL';
this.tokenExpiry = DateTime.now().addHours(1);
} else {
// Manual token management would go here
// For now, throw exception - must configure Named Credential
throw new CredentialException('Manual token management not implemented. Please use Named Credential.');
}
} else {
throw new CredentialException('Docusign credentials not configured. Please set up Custom Settings: Docusign_Configuration__c');
}
} catch (Exception e) {
throw new CredentialException('Failed to load Docusign credentials: ' + e.getMessage());
}
}
/**
* @description Checks if access token is expired
* @return True if token is expired or about to expire (within 5 minutes)
*/
private Boolean isTokenExpired() {
if (this.tokenExpiry == null) {
return true;
}
// Refresh 5 minutes before expiry to avoid edge cases
DateTime threshold = DateTime.now().addMinutes(5);
return this.tokenExpiry < threshold;
}
/**
* @description Refreshes access token (JWT or OAuth2)
* Note: In production with Named Credential, this is handled automatically by Salesforce
*/
private void refreshAccessToken() {
// If using Named Credential, no manual refresh needed
if (this.baseUrl.startsWith('callout:')) {
this.tokenExpiry = DateTime.now().addHours(1);
return;
}
// Manual JWT token refresh would go here
// This is a placeholder for future enhancement
throw new CredentialException('Manual token refresh not implemented. Use Named Credential for automatic token management.');
}
/**
* @description Gets access token
* @return Access token string
*/
public String getAccessToken() {
if (isTokenExpired()) {
refreshAccessToken();
}
return this.accessToken;
}
/**
* @description Gets Docusign account ID
* @return Account ID (GUID)
*/
public String getAccountId() {
return this.accountId;
}
/**
* @description Gets base URL for Docusign API
* @return Base URL (either Named Credential callout or full URL)
*/
public String getBaseUrl() {
return this.baseUrl;
}
/**
* @description Validates that credentials are properly configured
* @return True if valid
* @throws CredentialException if invalid
*/
public Boolean validate() {
if (String.isBlank(this.accountId)) {
throw new CredentialException('Docusign Account ID is not configured');
}
if (String.isBlank(this.baseUrl)) {
throw new CredentialException('Docusign Base URL is not configured');
}
if (String.isBlank(this.accessToken)) {
throw new CredentialException('Docusign Access Token is not available');
}
return true;
}
/**
* @description Resets singleton instance (for testing)
*/
@TestVisible
private static void resetInstance() {
instance = null;
}
/**
* @description Sets credentials manually (for testing)
*/
@TestVisible
private static void setTestCredentials(String testAccountId, String testBaseUrl, String testAccessToken) {
// Set flag to skip loadCredentials for this instance
skipLoadForNextInstance = true;
instance = new DocusignCredentials();
// Override with specific test values
instance.accountId = testAccountId;
instance.baseUrl = testBaseUrl;
instance.accessToken = testAccessToken;
instance.tokenExpiry = DateTime.now().addHours(1);
}
/**
* @description Custom exception for credential errors
*/
public class CredentialException extends Exception {}
}

View File

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

View File

@ -1,258 +0,0 @@
/**
* @description Test class for DocusignCredentials
* @author Paul Huliganga
* @date 2026-02-23
*/
@isTest
private class DocusignCredentialsTest {
/**
* @description Setup test data
*/
@testSetup
static void setup() {
// Create Custom Setting for credentials
Docusign_Configuration__c config = new Docusign_Configuration__c();
config.Account_Id__c = 'test-account-id-12345';
config.Base_URL__c = 'callout:DocusignAPI';
insert config;
}
/**
* @description Test singleton pattern
*/
@isTest
static void testGetInstance() {
// Act
Test.startTest();
DocusignCredentials creds1 = DocusignCredentials.getInstance();
DocusignCredentials creds2 = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertEquals(creds1, creds2, 'Should return same instance (singleton)');
}
/**
* @description Test loading credentials from Custom Settings
*/
@isTest
static void testLoadCredentialsFromCustomSettings() {
// Reset instance to force fresh load from Custom Settings
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertEquals('test-account-id-12345', creds.getAccountId(), 'Should load account ID');
System.assertEquals('callout:DocusignAPI', creds.getBaseUrl(), 'Should load base URL');
System.assertNotEquals(null, creds.getAccessToken(), 'Should have access token');
}
/**
* @description Test getAccessToken method
*/
@isTest
static void testGetAccessToken() {
// Arrange
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String token = creds.getAccessToken();
Test.stopTest();
// Assert
System.assertNotEquals(null, token, 'Should return access token');
}
/**
* @description Test getAccountId method
*/
@isTest
static void testGetAccountId() {
// Reset instance to force fresh load from Custom Settings
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String accountId = creds.getAccountId();
Test.stopTest();
// Assert
System.assertEquals('test-account-id-12345', accountId, 'Should return account ID');
}
/**
* @description Test getBaseUrl method
*/
@isTest
static void testGetBaseUrl() {
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String baseUrl = creds.getBaseUrl();
Test.stopTest();
// Assert
System.assertEquals('callout:DocusignAPI', baseUrl, 'Should return base URL');
}
/**
* @description Test validate method with valid credentials
*/
@isTest
static void testValidateSuccess() {
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
Boolean isValid = creds.validate();
Test.stopTest();
// Assert
System.assertEquals(true, isValid, 'Should validate successfully');
}
/**
* @description Test validate method with missing account ID
*/
@isTest
static void testValidateMissingAccountId() {
// Arrange
DocusignCredentials.resetInstance();
DocusignCredentials.setTestCredentials('', 'callout:DocusignAPI', 'test-token');
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
creds.validate();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().contains('Account ID'), 'Should mention Account ID');
}
Test.stopTest();
}
/**
* @description Test validate method with missing base URL
*/
@isTest
static void testValidateMissingBaseUrl() {
// Arrange
DocusignCredentials.resetInstance();
DocusignCredentials.setTestCredentials('test-account-id', '', 'test-token');
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
creds.validate();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().contains('Base URL'), 'Should mention Base URL');
}
Test.stopTest();
}
/**
* @description Test validate method with missing access token
*/
@isTest
static void testValidateMissingAccessToken() {
// Arrange
DocusignCredentials.resetInstance();
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', '');
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
creds.validate();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().contains('Access Token'), 'Should mention Access Token');
}
Test.stopTest();
}
/**
* @description Test setTestCredentials method
*/
@isTest
static void testSetTestCredentials() {
// Arrange
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials.setTestCredentials('test-acc-123', 'https://test.docusign.net', 'test-token-xyz');
DocusignCredentials creds = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertEquals('test-acc-123', creds.getAccountId(), 'Should set test account ID');
System.assertEquals('https://test.docusign.net', creds.getBaseUrl(), 'Should set test base URL');
System.assertEquals('test-token-xyz', creds.getAccessToken(), 'Should set test access token');
}
/**
* @description Test resetInstance method
*/
@isTest
static void testResetInstance() {
// Arrange
DocusignCredentials creds1 = DocusignCredentials.getInstance();
// Act
Test.startTest();
DocusignCredentials.resetInstance();
DocusignCredentials creds2 = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertNotEquals(creds1, creds2, 'Should create new instance after reset');
}
/**
* @description Test error when Custom Settings not configured
*/
@isTest
static void testMissingCustomSettings() {
// Arrange
// Reset instance first
DocusignCredentials.resetInstance();
// Delete the test setup data
delete [SELECT Id FROM Docusign_Configuration__c];
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().containsIgnoreCase('not configured'), 'Should mention not configured');
}
Test.stopTest();
}
/**
* @description Test Named Credential URL format
*/
@isTest
static void testNamedCredentialFormat() {
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String baseUrl = creds.getBaseUrl();
Test.stopTest();
// Assert
System.assert(baseUrl.startsWith('callout:'), 'Named Credential URL should start with callout:');
}
}

View File

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

View File

@ -1,5 +1,5 @@
/** /**
* @description Handles request validation and envelope JSON building for Docusign composite envelopes * @description Validates request parameters for Docusign composite envelopes
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-25 * @date 2026-02-25
*/ */
@ -23,151 +23,10 @@ global with sharing class DocusignEnvelopeRequestHandler {
throw new IllegalArgumentException('Salesforce record ID is required'); throw new IllegalArgumentException('Salesforce record ID is required');
} }
// Check for null template IDs
for (String templateId : req.templateIds) { for (String templateId : req.templateIds) {
if (String.isBlank(templateId)) { if (String.isBlank(templateId)) {
throw new IllegalArgumentException('Template ID cannot be blank'); 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(DocusignEnvelopeRequest 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

@ -8,43 +8,33 @@ public class DocusignEnvelopeRequestHandlerTest {
@isTest @isTest
static void testValidateRequest_Success() { static void testValidateRequest_Success() {
// Setup
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{ 'template1', 'template2' }; req.templateIds = new List<String>{ 'template1', 'template2' };
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
req.language = 'en';
req.emailSubject = 'Test Subject';
// Test
Test.startTest(); Test.startTest();
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Test.stopTest(); Test.stopTest();
// Verify - no exception thrown
Assert.isTrue(true, 'Validation should pass'); Assert.isTrue(true, 'Validation should pass');
} }
@isTest @isTest
static void testValidateRequest_NoTemplateIds() { static void testValidateRequest_NoTemplateIds() {
// Setup
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>(); req.templateIds = new List<String>();
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error');
} }
Test.stopTest();
} }
@isTest @isTest
static void testValidateRequest_TooManyTemplates() { static void testValidateRequest_TooManyTemplates() {
// Setup
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>(); req.templateIds = new List<String>();
for (Integer i = 0; i < 15; i++) { for (Integer i = 0; i < 15; i++) {
@ -52,129 +42,39 @@ public class DocusignEnvelopeRequestHandlerTest {
} }
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error');
} }
Test.stopTest();
} }
@isTest @isTest
static void testValidateRequest_NoRecordId() { static void testValidateRequest_NoRecordId() {
// Setup
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{ 'template1' }; req.templateIds = new List<String>{ 'template1' };
req.recordId = ''; req.recordId = '';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error');
} }
Test.stopTest();
} }
@isTest @isTest
static void testValidateRequest_BlankTemplateId() { static void testValidateRequest_BlankTemplateId() {
// Setup
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{ 'template1', '', 'template3' }; req.templateIds = new List<String>{ 'template1', '', 'template3' };
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error');
} }
Test.stopTest();
}
@isTest
static void testBuildEnvelopeJSON_Success() {
// Setup
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
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
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
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
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
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
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
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');
} }
} }