diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls deleted file mode 100644 index 72403aa..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls +++ /dev/null @@ -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 responseMap = (Map) 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 errorBody = (Map) 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 {} -} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml deleted file mode 100644 index 651b172..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 61.0 - Active - diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls deleted file mode 100644 index aa884c1..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls +++ /dev/null @@ -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'); - } -} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml deleted file mode 100644 index 651b172..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 61.0 - Active - diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls index b322ead..74852a4 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls @@ -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 - * @date 2026-02-23 + * @date 2026-02-25 */ 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( 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' ) public static List sendCompositeEnvelope(List requests) { List results = new List(); - // Process first request (Flow only sends one) if (requests == null || requests.isEmpty()) { return buildErrorResult('No request provided'); } @@ -27,44 +22,57 @@ global with sharing class DocusignCompositeEnvelopeBuilder { DocusignEnvelopeResult result = new DocusignEnvelopeResult(); try { - // Validate request using handler + // Validate request DocusignEnvelopeRequestHandler.validateRequest(req); - // Build envelope JSON using handler - String envelopeJSON = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); - - // Get Docusign credentials - DocusignCredentials creds = DocusignCredentials.getInstance(); - - // Call Docusign API - String envelopeId = DocusignAPIService.createCompositeEnvelope( - envelopeJSON, - creds + // Create empty envelope linked to the source Salesforce record + dfsle.Envelope myEnvelope = dfsle.EnvelopeService.getEmptyEnvelope( + new dfsle.Entity(req.recordId) ); + // Build document list from templates (deduplicated and sorted) + List sortedTemplateIds = new List(new Set(req.templateIds)); + sortedTemplateIds.sort(); + + List documents = new List(); + 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 - result.envelopeId = envelopeId; + result.envelopeId = String.valueOf(myEnvelope.docuSignId); result.success = true; result.errorMessage = null; - // Log success - logAPICall(req.templateIds.size(), envelopeId, 'Success', null); + logResult(sortedTemplateIds.size(), result.envelopeId, 'Success', null); } catch (Exception e) { - // Error handling result.success = false; result.errorMessage = e.getMessage(); result.envelopeId = null; - // Log error - logAPICall( + logResult( req.templateIds != null ? req.templateIds.size() : 0, - null, - 'Error', + null, 'Error', e.getMessage() + '\n' + e.getStackTraceString() ); - // Re-throw if critical (governor limits) if (e instanceof System.LimitException) { throw e; } @@ -74,36 +82,16 @@ global with sharing class DocusignCompositeEnvelopeBuilder { return results; } - /** - * @description Logs API call to debug log (future: custom object) - * @param templateCount Number of templates in envelope - * @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); + private static void logResult(Integer templateCount, String envelopeId, String status, String errorMessage) { + System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope ==='); + System.debug(LoggingLevel.INFO, 'Templates: ' + templateCount); System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId); System.debug(LoggingLevel.INFO, 'Status: ' + status); if (String.isNotBlank(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 buildErrorResult(String errorMessage) { DocusignEnvelopeResult result = new DocusignEnvelopeResult(); result.success = false; diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls index 3f3e5df..b8c572f 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls @@ -1,114 +1,70 @@ /** - * @description Test class for DocusignCompositeEnvelopeBuilder + * @description Test class for DocusignCompositeEnvelopeBuilder (dfsle Apex Toolkit) * @author Paul Huliganga - * @date 2026-02-23 + * @date 2026-02-25 */ @isTest 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 - static void testSuccessfulEnvelopeCreation() { + static void testSuccessfulCompositeEnvelope() { // Arrange - String envelopeId = 'envelope-12345-abcde'; - String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}'; - Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); - - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); - req.templateIds = new List{'template-1', 'template-2', 'template-3'}; + req.templateIds = new List{ + '01234567-abcd-ef01-2345-6789abcdef01', + '01234567-abcd-ef01-2345-6789abcdef02', + '01234567-abcd-ef01-2345-6789abcdef03' + }; req.recordId = '001000000ABC123'; req.language = 'en'; - req.emailSubject = 'Test Envelope'; + req.emailSubject = 'Please sign these forms'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert System.assertEquals(1, results.size(), 'Should return 1 result'); System.assertEquals(true, results[0].success, 'Should be successful'); - System.assertEquals(envelopeId, results[0].envelopeId, 'Should return envelope ID'); - System.assertEquals(null, results[0].errorMessage, 'Should have no error message'); + System.assertNotEquals(null, results[0].envelopeId, 'Should have envelope ID'); + System.assertEquals(null, results[0].errorMessage, 'Should have no error'); } - /** - * @description Test with single template (minimum) - */ @isTest static void testSingleTemplate() { // Arrange - String mockResponse = '{"envelopeId":"envelope-single","status":"sent"}'; - Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); - - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); - req.templateIds = new List{'template-1'}; + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; req.recordId = '001000000ABC123'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // 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 static void testMaximumTemplates() { // Arrange - String mockResponse = '{"envelopeId":"envelope-max","status":"sent"}'; - Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); - - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); List templateIds = new List(); 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(); @@ -118,162 +74,193 @@ private class DocusignCompositeEnvelopeBuilderTest { // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // 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 - static void testDuplicateTemplates() { + static void testDuplicateTemplatesDeduped() { // Arrange - String mockResponse = '{"envelopeId":"envelope-dedup","status":"sent"}'; - Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); - - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); - req.templateIds = new List{'template-1', 'template-2', 'template-1'}; // Duplicate + req.templateIds = new List{ + '01234567-abcd-ef01-2345-6789abcdef01', + '01234567-abcd-ef01-2345-6789abcdef02', + '01234567-abcd-ef01-2345-6789abcdef01' // duplicate + }; req.recordId = '001000000ABC123'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert System.assertEquals(true, results[0].success, 'Should handle duplicates'); } - /** - * @description Test validation failure - no template IDs - */ + @isTest + static void testNullRequest() { + // Act + Test.startTest(); + List 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 results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List() + ); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail with empty request'); + } + @isTest static void testValidationNoTemplates() { // Arrange - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); - DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); - req.templateIds = new List(); // Empty + req.templateIds = new List(); req.recordId = '001000000ABC123'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert 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 static void testValidationTooManyTemplates() { // Arrange - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); - List templateIds = new List(); 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(); - req.templateIds = templateIds; // 15 templates (> max of 14) + req.templateIds = templateIds; req.recordId = '001000000ABC123'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert 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 static void testValidationNoRecordId() { // Arrange - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); - DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); - req.templateIds = new List{'template-1'}; - req.recordId = ''; // Blank + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = ''; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert 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 - static void testAPIError400() { + static void testValidationBlankTemplateId() { // 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(); - req.templateIds = new List{'invalid-template'}; + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01', ''}; req.recordId = '001000000ABC123'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert - System.assertEquals(false, results[0].success, 'Should fail with API error'); - System.assert(results[0].errorMessage.contains('400'), 'Error should include status code'); + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.contains('blank'), 'Should mention blank'); } - /** - * @description Test API error - 401 Unauthorized - */ @isTest - static void testAPIError401() { + static void testWithEmailSubject() { // Arrange - String mockResponse = '{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid token"}'; - Test.setMock(HttpCalloutMock.class, new DocusignMock(401, mockResponse)); - - DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'invalid-token'); + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); - req.templateIds = new List{'template-1'}; + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; req.recordId = '001000000ABC123'; + req.emailSubject = 'Custom: Please review and sign'; // Act Test.startTest(); List results = - DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); Test.stopTest(); // Assert - System.assertEquals(false, results[0].success, 'Should fail with auth error'); - System.assertNotEquals(null, results[0].errorMessage, 'Should have error message'); - System.assert(String.isNotBlank(results[0].errorMessage), 'Error message should not be blank'); + System.assertEquals(true, results[0].success, 'Should succeed with custom subject'); + } + + @isTest + static void testWithoutEmailSubject() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = '001000000ABC123'; + req.emailSubject = null; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should succeed without subject'); } } diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls deleted file mode 100644 index 6a7015e..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls +++ /dev/null @@ -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 {} -} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml deleted file mode 100644 index 651b172..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 61.0 - Active - diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls deleted file mode 100644 index 03376d1..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls +++ /dev/null @@ -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:'); - } -} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml deleted file mode 100644 index 651b172..0000000 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 61.0 - Active - diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls index 05004b3..24eaab5 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls @@ -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 * @date 2026-02-25 */ @@ -23,151 +23,10 @@ global with sharing class DocusignEnvelopeRequestHandler { 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(DocusignEnvelopeRequest req) { - // Remove duplicates and sort alphabetically - List sortedTemplateIds = sortTemplatesAlphabetically( - new Set(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 sortTemplatesAlphabetically(Set templateIdSet) { - List sortedList = new List(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 templateIds, - String recordId, - String language, - String emailSubject, - Map customFields - ) { - // Build composite templates array - List compositeTemplates = new List(); - - Integer sequence = 1; - for (String templateId : templateIds) { - Map compositeTemplate = new Map{ - 'compositeTemplateId' => String.valueOf(sequence), - 'serverTemplates' => new List{ - new Map{ - '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 envelope = new Map{ - '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 buildInlineTemplates( - String recordId, - String language, - Map customFields - ) { - List textCustomFields = new List(); - - // Add Salesforce record ID - if (String.isNotBlank(recordId)) { - textCustomFields.add(new Map{ - 'name' => 'SalesforceRecordId', - 'value' => recordId, - 'show' => 'false', - 'required' => 'false' - }); - } - - // Add language - if (String.isNotBlank(language)) { - textCustomFields.add(new Map{ - '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{ - 'name' => fieldName, - 'value' => customFields.get(fieldName), - 'show' => 'false', - 'required' => 'false' - }); - } - } - - return new List{ - new Map{ - 'sequence' => '1', - 'customFields' => new Map{ - 'textCustomFields' => textCustomFields - } - } - }; - } } diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls index 97bb462..e94aa9d 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls @@ -8,43 +8,33 @@ public class DocusignEnvelopeRequestHandlerTest { @isTest static void testValidateRequest_Success() { - // Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); req.templateIds = new List{ '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 DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); req.templateIds = new List(); 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'); + Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error'); } - Test.stopTest(); } @isTest static void testValidateRequest_TooManyTemplates() { - // Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); req.templateIds = new List(); for (Integer i = 0; i < 15; i++) { @@ -52,129 +42,39 @@ public class DocusignEnvelopeRequestHandlerTest { } 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'); + Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error'); } - Test.stopTest(); } @isTest static void testValidateRequest_NoRecordId() { - // Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); req.templateIds = new List{ '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'); + Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error'); } - Test.stopTest(); } @isTest static void testValidateRequest_BlankTemplateId() { - // Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); req.templateIds = new List{ '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'); + 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{ '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{ '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{ '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{ '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'); } }