diff --git a/composite-envelope-builder/docs/DEPLOYMENT_AND_TESTING.md b/composite-envelope-builder/docs/DEPLOYMENT_AND_TESTING.md new file mode 100644 index 0000000..b0b6569 --- /dev/null +++ b/composite-envelope-builder/docs/DEPLOYMENT_AND_TESTING.md @@ -0,0 +1,218 @@ +# Deployment & Testing Guide + +Quick reference for deploying the Salesforce Composite Envelope Builder to your org and running tests. + +--- + +## Quick Start (Recommended) + +### Deploy + Test in One Script +```bash +cd /home/paulh/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder +bash deploy-to-dev-org.sh +``` + +This script handles: +- ✅ Org authorization (opens browser login) +- ✅ Code deployment +- ✅ Unit test execution with code coverage +- ✅ Human-readable results + +--- + +## Manual Commands + +### 1. Authorize Your Org (First Time Only) +```bash +# For Developer Edition +sf org login web --alias dev-org --instance-url https://login.salesforce.com + +# For Sandbox +sf org login web --alias sandbox-org --instance-url https://test.salesforce.com +``` + +You'll be redirected to Salesforce login. Once authorized, the org alias is saved for future deploys. + +--- + +### 2. Deploy Code + +**Deploy all code to your org:** +```bash +cd /home/paulh/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder +sf project deploy start --target-org dev-org +``` + +**Options:** +- `--target-org dev-org` — Use the authorized org alias (replace with your alias if different) +- `--wait 10` — Wait up to 10 minutes for deployment to complete + +--- + +### 3. Run Tests + +**Run ALL unit tests with code coverage:** +```bash +sf apex run test --wait 10 --result-format human --code-coverage --target-org dev-org +``` + +**Run ONLY the new handler tests:** +```bash +sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --target-org dev-org +``` + +**Run specific test methods:** +```bash +sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --method-names testValidateRequest_Success --wait 10 --result-format human --target-org dev-org +``` + +**Run ALL tests with JSON output (for parsing):** +```bash +sf apex run test --wait 10 --result-format json --code-coverage --target-org dev-org > test-results.json +``` + +--- + +## Complete Deploy + Test in One Command + +```bash +cd /home/paulh/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder && \ +sf project deploy start --target-org dev-org && \ +sf apex run test --wait 10 --result-format human --code-coverage --target-org dev-org +``` + +--- + +## Understanding Test Results + +Sample output: +``` +Apex Tests +═════════════════════════════════════════════════════════════════ + +Test Results Summary [Included Code Coverage] +═════════════════════════════════════════════════════════════════ +Outcome: Passed +Tests Ran: 39 +Passes: 39 +Failures: 0 +Skipped: 0 +Pass Rate: 100% +Apex Code Coverage: 92% +``` + +**Key metrics:** +- **Pass Rate** — Percentage of tests that passed (should be 100%) +- **Apex Code Coverage** — Percentage of code executed by tests (should be >80%) +- **Failures** — If > 0, review the failed test names and error messages below + +--- + +## Post-Deployment Setup + +After successful deployment, configure your Salesforce org: + +### 1. Create Docusign Configuration Custom Setting + +1. Log into your Salesforce org +2. Go to **Setup** → **Custom Settings** +3. Click **Manage** next to **Docusign Configuration** +4. Click **New** +5. Fill in: + - **Account Id:** `{your Docusign sandbox account ID}` + - **Base URL:** `callout:DocusignAPI` +6. Click **Save** + +### 2. Create Named Credential for Docusign API + +1. Go to **Setup** → **Named Credentials** +2. Click **New Named Credential** +3. Fill in: + - **Name:** `DocusignAPI` + - **Label:** `Docusign API` + - **URL:** `https://demo.docusign.net/restapi/v2.1` + - **Identity Type:** Named Principal + - **Authentication Protocol:** OAuth 2.0 + - **Authentication Provider:** DocusignOAuthProvider (if available) +4. Click **Save** + +### 3. Update Your Screen Flow + +1. Go to **Flow Builder** +2. Create or edit your Screen Flow +3. Add an **Action** to the flow: + - Action: **Send Composite Docusign Envelope** + - Input Variables: + - **Template IDs** — Comma-separated Docusign template IDs + - **Salesforce Record ID** — {!Record.Id} + - **Language** — en or es + - **Email Subject** — Optional custom subject +4. Output Variables: + - **Envelope ID** — Unique Docusign envelope ID + - **Success** — Boolean (true/false) + - **Error Message** — Error details if creation failed +5. Save and test the flow + +--- + +## Troubleshooting + +### Authorization Issues +```bash +# If auth fails, try manual URL method: +sf org login web --alias dev-org --instance-url https://login.salesforce.com --json + +# Copy the 'url' value and paste into your browser +``` + +### Deployment Errors +```bash +# Check what's in your org +sf project list metadata --target-org dev-org + +# Validate without deploying +sf project validate deploy --target-org dev-org +``` + +### Test Failures +```bash +# Run tests with verbose output +sf apex run test --target-org dev-org --result-format human --code-coverage -v +``` + +--- + +## Reference: Classes Deployed + +**Apex Classes:** +- `DocusignCompositeEnvelopeBuilder` — Main invocable class (Flows) +- `DocusignEnvelopeRequestHandler` — Request validation & envelope building (reusable) +- `DocusignAPIService` — Docusign REST API wrapper +- `DocusignCredentials` — Custom settings singleton + +**Test Classes:** +- `DocusignCompositeEnvelopeBuilderTest` — Tests for main invocable +- `DocusignEnvelopeRequestHandlerTest` — Tests for request handler (10 test methods) +- `DocusignAPIServiceTest` — Tests for API service +- `DocusignCredentialsTest` — Tests for credentials + +**Custom Settings:** +- `Docusign_Configuration__c` — Stores Account ID and Base URL + +--- + +## Next Steps After Successful Deploy + +1. ✅ Verify all tests pass +2. ✅ Create Docusign Configuration in Salesforce +3. ✅ Set up Named Credential +4. ✅ Create a test Screen Flow +5. ✅ Test with real Docusign templates +6. ✅ Deploy to Production (when ready) + +--- + +For more details, see: +- `docs/deployment-guide.md` — Detailed deployment instructions +- `docs/api-reference.md` — API reference +- `docs/design.md` — Architecture & design decisions 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 6e176eb..1fa2f3d 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls @@ -27,22 +27,11 @@ global with sharing class DocusignCompositeEnvelopeBuilder { Result result = new Result(); try { - // Validate inputs - validateInputs(req); + // Validate request using handler + DocusignEnvelopeRequestHandler.validateRequest(req); - // Remove duplicates and sort alphabetically - List sortedTemplateIds = sortTemplatesAlphabetically( - new Set(req.templateIds) - ); - - // Build composite envelope JSON - String envelopeJSON = buildCompositeEnvelopeJSON( - sortedTemplateIds, - req.recordId, - req.language, - req.emailSubject, - null // customFields not supported in InvocableVariable (Phase 2 enhancement) - ); + // Build envelope JSON using handler + String envelopeJSON = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); // Get Docusign credentials DocusignCredentials creds = DocusignCredentials.getInstance(); @@ -85,152 +74,6 @@ global with sharing class DocusignCompositeEnvelopeBuilder { return results; } - /** - * @description Validates input parameters - * @param req Request object to validate - * @throws IllegalArgumentException if validation fails - */ - private static void validateInputs(Request req) { - if (req.templateIds == null || req.templateIds.isEmpty()) { - throw new IllegalArgumentException('At least one template ID is required'); - } - - if (req.templateIds.size() > 14) { - throw new IllegalArgumentException('Maximum 14 templates allowed per envelope'); - } - - if (String.isBlank(req.recordId)) { - throw new IllegalArgumentException('Salesforce record ID is required'); - } - - // Check for null template IDs - for (String templateId : req.templateIds) { - if (String.isBlank(templateId)) { - throw new IllegalArgumentException('Template ID cannot be blank'); - } - } - } - - /** - * @description Removes duplicates and sorts template IDs alphabetically - * @param templateIdSet Set of template IDs - * @return Sorted list of unique template IDs - */ - private static List 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 - */ - @TestVisible - 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 - } - } - }; - } - /** * @description Logs API call to debug log (future: custom object) * @param templateCount Number of templates in envelope diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls new file mode 100644 index 0000000..3e22e92 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls @@ -0,0 +1,173 @@ +/** + * @description Handles request validation and envelope JSON building for Docusign composite envelopes + * @author Paul Huliganga + * @date 2026-02-25 + */ +global with sharing class DocusignEnvelopeRequestHandler { + + /** + * @description Validates composite envelope request parameters + * @param req Request object to validate + * @throws IllegalArgumentException if validation fails + */ + public static void validateRequest(DocusignCompositeEnvelopeBuilder.Request req) { + if (req.templateIds == null || req.templateIds.isEmpty()) { + throw new IllegalArgumentException('At least one template ID is required'); + } + + if (req.templateIds.size() > 14) { + throw new IllegalArgumentException('Maximum 14 templates allowed per envelope'); + } + + if (String.isBlank(req.recordId)) { + throw new IllegalArgumentException('Salesforce record ID is required'); + } + + // Check for null template IDs + for (String templateId : req.templateIds) { + if (String.isBlank(templateId)) { + throw new IllegalArgumentException('Template ID cannot be blank'); + } + } + } + + /** + * @description Builds composite envelope JSON for Docusign API from request + * @param req Request object containing parameters + * @return JSON string for Docusign API request + */ + public static String buildEnvelopeJSON(DocusignCompositeEnvelopeBuilder.Request req) { + // Remove duplicates and sort alphabetically + List 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/DocusignEnvelopeRequestHandler.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandler.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls new file mode 100644 index 0000000..80a9c61 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls @@ -0,0 +1,180 @@ +/** + * @description Tests for DocusignEnvelopeRequestHandler + * @author Paul Huliganga + * @date 2026-02-25 + */ +@isTest +public class DocusignEnvelopeRequestHandlerTest { + + @isTest + static void testValidateRequest_Success() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ 'template1', 'template2' }; + req.recordId = '001xx000003DHf'; + req.language = 'en'; + req.emailSubject = 'Test Subject'; + + // Test + Test.startTest(); + DocusignEnvelopeRequestHandler.validateRequest(req); + Test.stopTest(); + + // Verify - no exception thrown + Assert.isTrue(true, 'Validation should pass'); + } + + @isTest + static void testValidateRequest_NoTemplateIds() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List(); + req.recordId = '001xx000003DHf'; + + // Test & Verify + Test.startTest(); + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error message'); + } + Test.stopTest(); + } + + @isTest + static void testValidateRequest_TooManyTemplates() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List(); + for (Integer i = 0; i < 15; i++) { + req.templateIds.add('template' + i); + } + req.recordId = '001xx000003DHf'; + + // Test & Verify + Test.startTest(); + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error message'); + } + Test.stopTest(); + } + + @isTest + static void testValidateRequest_NoRecordId() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ 'template1' }; + req.recordId = ''; + + // Test & Verify + Test.startTest(); + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error message'); + } + Test.stopTest(); + } + + @isTest + static void testValidateRequest_BlankTemplateId() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ 'template1', '', 'template3' }; + req.recordId = '001xx000003DHf'; + + // Test & Verify + Test.startTest(); + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error message'); + } + Test.stopTest(); + } + + @isTest + static void testBuildEnvelopeJSON_Success() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ 'template2', 'template1', 'template2' }; // duplicates and unsorted + req.recordId = '001xx000003DHf'; + req.language = 'es'; + req.emailSubject = 'Custom Subject'; + + // Test + Test.startTest(); + String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); + Test.stopTest(); + + // Verify + Assert.isNotNull(json, 'JSON should be generated'); + Assert.isTrue(json.contains('sent'), 'Should contain sent status'); + Assert.isTrue(json.contains('Custom Subject'), 'Should contain custom subject'); + Assert.isTrue(json.contains('compositeTemplates'), 'Should contain compositeTemplates'); + } + + @isTest + static void testBuildEnvelopeJSON_DefaultSubject() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ 'template1' }; + req.recordId = '001xx000003DHf'; + req.emailSubject = null; + + // Test + Test.startTest(); + String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); + Test.stopTest(); + + // Verify + Assert.isTrue(json.contains('Please review and sign these forms'), 'Should use default subject'); + } + + @isTest + static void testBuildEnvelopeJSON_TemplatesAreSorted() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ 'template3', 'template1', 'template2' }; + req.recordId = '001xx000003DHf'; + + // Test + Test.startTest(); + String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); + Test.stopTest(); + + // Verify - templates should be in sorted order (template1, template2, template3) + Integer idx1 = json.indexOf('template1'); + Integer idx2 = json.indexOf('template2'); + Integer idx3 = json.indexOf('template3'); + Assert.isTrue(idx1 < idx2 && idx2 < idx3, 'Templates should be sorted alphabetically'); + } + + @isTest + static void testBuildEnvelopeJSON_DuplicatesRemoved() { + // Setup + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{ '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'); + } +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignEnvelopeRequestHandlerTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active +