Add CLMAdminService, DocusignESignatureService, and fix code review issues

New classes:
- CLMAdminService: UI-facing orchestration — account/letter settings from
  metadata, document generation, task status polling, file attachment,
  folder browsing; persists all CLM results to Appraiser_Case__c
- DocusignESignatureService: eSignature API browsing (accounts, templates,
  envelopes, login info, user info)
- CLMAdminServiceTest, CLMDocGenCalloutTest, DocusignESignatureServiceTest

Updated classes (AppraiserCasePayloadBuilder, CLMDocGenCallout):
- CLMDocGenCallout: full XML merge callout stack, task status polling,
  document download, recursive document href discovery, account-based
  endpoint building; HTTP_TIMEOUT made public
- AppraiserCasePayloadBuilder: formatMailingAddress made public so
  CLMAdminService can reuse it rather than duplicating the logic

Code review bug fixes:
- Fix null fields emitting literal "null" in generated XML — add safeValue()
  helper; String.valueOf(null) returns "null" so escapeXml's null guard
  never fired
- Fix unguarded inline SOQL in getCaseContext and getDocGenPreview — throws
  QueryException for missing records instead of AuraHandledException; now
  uses list query with isEmpty guard
- Remove duplicate formatAddress in CLMAdminService; delegate to
  AppraiserCasePayloadBuilder.formatMailingAddress
- Replace hardcoded 30000 timeout in performGet with CLMDocGenCallout.HTTP_TIMEOUT
- Remove duplicate JSDoc on getTaskStatus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
paulh 2026-04-09 20:36:16 -04:00
parent 62b78faf1a
commit 45814dc2d5
13 changed files with 2271 additions and 43 deletions

View File

@ -21,7 +21,30 @@ public class AppraiserCasePayloadBuilder {
Map<String, Object> payload = new Map<String, Object>();
payload.put('AppraiserCaseNumber', appraiserCase.Name);
payload.put('AppraiserFieldReviewDate', formatDate(appraiserCase.Appraiser_Field_Review_Date__c));
payload.put('PropertyAddress', appraiserCase.Property_Address__c);
payload.put('LetterSentDate', formatDate(appraiserCase.Letter_Sent_Date__c));
payload.put('FHACaseNumber', appraiserCase.FHA_Case_Number__c);
payload.put('AppraiserName', appraiserCase.Appraiser_Name__c);
payload.put('AppraiserSalutation', appraiserCase.Appraiser_Salutation__c);
payload.put('AppraiserLastName', appraiserCase.Appraiser_Last_Name__c);
payload.put('AppraiserEmail', appraiserCase.Appraiser_Email__c);
payload.put('AppraiserStreet', appraiserCase.Appraiser_Street__c);
payload.put('AppraiserCity', appraiserCase.Appraiser_City__c);
payload.put('AppraiserStateProvince', appraiserCase.Appraiser_State_Province__c);
payload.put('AppraiserPostalCode', appraiserCase.Appraiser_Postal_Code__c);
payload.put('AppraiserCountry', appraiserCase.Appraiser_Country__c);
payload.put('AppraiserAddress', formatMailingAddress(
appraiserCase.Appraiser_Street__c,
appraiserCase.Appraiser_City__c,
appraiserCase.Appraiser_State_Province__c,
appraiserCase.Appraiser_Postal_Code__c,
appraiserCase.Appraiser_Country__c
));
payload.put('PropertyStreet', appraiserCase.Property_Street__c);
payload.put('PropertyCity', appraiserCase.Property_City__c);
payload.put('PropertyStateProvince', appraiserCase.Property_State_Province__c);
payload.put('PropertyPostalCode', appraiserCase.Property_Postal_Code__c);
payload.put('PropertyCountry', appraiserCase.Property_Country__c);
payload.put('PropertyAddress', formatAddress(appraiserCase));
// Transform child deficiencies into DeficiencyList array
List<Map<String, Object>> deficiencyList = new List<Map<String, Object>>();
@ -31,6 +54,7 @@ public class AppraiserCasePayloadBuilder {
defMap.put('deficiencyNumber', deficiency.Deficiency_Number__c);
defMap.put('description', deficiency.Description__c);
defMap.put('resolution', deficiency.Resolution__c);
defMap.put('reference', deficiency.Reference__c);
deficiencyList.add(defMap);
}
}
@ -60,8 +84,23 @@ public class AppraiserCasePayloadBuilder {
Id,
Name,
Appraiser_Field_Review_Date__c,
Property_Address__c,
(SELECT Id, Deficiency_Number__c, Description__c, Resolution__c
Letter_Sent_Date__c,
FHA_Case_Number__c,
Appraiser_Name__c,
Appraiser_Salutation__c,
Appraiser_Last_Name__c,
Appraiser_Email__c,
Appraiser_Street__c,
Appraiser_City__c,
Appraiser_State_Province__c,
Appraiser_Postal_Code__c,
Appraiser_Country__c,
Property_Street__c,
Property_City__c,
Property_State_Province__c,
Property_Postal_Code__c,
Property_Country__c,
(SELECT Id, Deficiency_Number__c, Description__c, Resolution__c, Reference__c
FROM Deficiencies__r
ORDER BY Deficiency_Number__c ASC)
FROM Appraiser_Case__c
@ -73,11 +112,60 @@ public class AppraiserCasePayloadBuilder {
}
/**
* @description Format date for CLM merge (YYYY-MM-DD or null).
* @description Format date for CLM merge (for example Apr 09, 2026) or null.
* @param dt Date field value
* @return String Formatted date or null
*/
private static String formatDate(Date dt) {
return dt != null ? dt.format() : null;
return dt != null
? DateTime.newInstance(dt, Time.newInstance(0, 0, 0, 0)).format('MMM dd, yyyy')
: null;
}
private static String formatAddress(Appraiser_Case__c appraiserCase) {
return formatMailingAddress(
appraiserCase.Property_Street__c,
appraiserCase.Property_City__c,
appraiserCase.Property_State_Province__c,
appraiserCase.Property_Postal_Code__c,
appraiserCase.Property_Country__c
);
}
public static String formatMailingAddress(
String street,
String city,
String stateProvince,
String postalCode,
String country
) {
List<String> lines = new List<String>();
if (String.isNotBlank(street)) {
lines.add(street.trim());
}
List<String> localityParts = new List<String>();
if (String.isNotBlank(city)) {
localityParts.add(city.trim());
}
if (String.isNotBlank(stateProvince)) {
localityParts.add(stateProvince.trim());
}
String locality = String.join(localityParts, ', ');
if (String.isNotBlank(postalCode)) {
locality = String.isNotBlank(locality)
? locality + ' ' + postalCode.trim()
: postalCode.trim();
}
if (String.isNotBlank(locality)) {
lines.add(locality);
}
if (String.isNotBlank(country)) {
lines.add(country.trim());
}
return lines.isEmpty() ? null : String.join(lines, ', ');
}
}

View File

@ -5,8 +5,23 @@ private class AppraiserCasePayloadBuilderTest {
static void setupTestData() {
// Create test Appraiser Case
Appraiser_Case__c testCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.parse('04/02/2026'),
Property_Address__c = '123 Main St, Denver, CO 80202'
Appraiser_Field_Review_Date__c = Date.newInstance(2026, 4, 2),
Letter_Sent_Date__c = Date.newInstance(2026, 4, 9),
FHA_Case_Number__c = '123-4567890',
Appraiser_Name__c = 'Jamie Appraiser',
Appraiser_Salutation__c = 'Ms.',
Appraiser_Last_Name__c = 'Appraiser',
Appraiser_Email__c = 'jamie.appraiser@example.com',
Appraiser_Street__c = '245 Lexington Ave',
Appraiser_City__c = 'New York',
Appraiser_State_Province__c = 'NY',
Appraiser_Postal_Code__c = '10016',
Appraiser_Country__c = 'USA',
Property_Street__c = '123 Main St',
Property_City__c = 'Denver',
Property_State_Province__c = 'CO',
Property_Postal_Code__c = '80202',
Property_Country__c = 'USA'
);
insert testCase;
@ -16,13 +31,15 @@ private class AppraiserCasePayloadBuilderTest {
Appraiser_Case__c = testCase.Id,
Deficiency_Number__c = 1,
Description__c = 'Missing comparable sale adjustment detail.',
Resolution__c = 'Added adjustment rationale and supporting calculations.'
Resolution__c = 'Added adjustment rationale and supporting calculations.',
Reference__c = 'VC-1'
));
testDefs.add(new Appraiser_Case_Deficiency__c(
Appraiser_Case__c = testCase.Id,
Deficiency_Number__c = 2,
Description__c = 'Neighborhood trend explanation insufficient.',
Resolution__c = 'Expanded market trend narrative with MLS evidence.'
Resolution__c = 'Expanded market trend narrative with MLS evidence.',
Reference__c = 'MC-2'
));
insert testDefs;
}
@ -36,11 +53,39 @@ private class AppraiserCasePayloadBuilderTest {
Assert.isNotNull(payload, 'Payload should not be null');
Assert.isTrue(payload.containsKey('AppraiserCaseNumber'), 'Payload should contain AppraiserCaseNumber');
Assert.isTrue(payload.containsKey('AppraiserFieldReviewDate'), 'Payload should contain AppraiserFieldReviewDate');
Assert.isTrue(payload.containsKey('LetterSentDate'), 'Payload should contain LetterSentDate');
Assert.isTrue(payload.containsKey('FHACaseNumber'), 'Payload should contain FHACaseNumber');
Assert.isTrue(payload.containsKey('AppraiserName'), 'Payload should contain AppraiserName');
Assert.isTrue(payload.containsKey('AppraiserSalutation'), 'Payload should contain AppraiserSalutation');
Assert.isTrue(payload.containsKey('AppraiserLastName'), 'Payload should contain AppraiserLastName');
Assert.isTrue(payload.containsKey('AppraiserEmail'), 'Payload should contain AppraiserEmail');
Assert.isTrue(payload.containsKey('AppraiserAddress'), 'Payload should contain AppraiserAddress');
Assert.isTrue(payload.containsKey('PropertyAddress'), 'Payload should contain PropertyAddress');
Assert.isTrue(payload.containsKey('PropertyStreet'), 'Payload should contain PropertyStreet');
Assert.isTrue(payload.containsKey('PropertyCity'), 'Payload should contain PropertyCity');
Assert.isTrue(payload.containsKey('PropertyStateProvince'), 'Payload should contain PropertyStateProvince');
Assert.isTrue(payload.containsKey('PropertyPostalCode'), 'Payload should contain PropertyPostalCode');
Assert.isTrue(payload.containsKey('PropertyCountry'), 'Payload should contain PropertyCountry');
Assert.isTrue(payload.containsKey('DeficiencyList'), 'Payload should contain DeficiencyList');
Assert.areEqual('123 Main St', (String) payload.get('PropertyStreet'));
Assert.areEqual('Denver', (String) payload.get('PropertyCity'));
Assert.areEqual('CO', (String) payload.get('PropertyStateProvince'));
Assert.areEqual('80202', (String) payload.get('PropertyPostalCode'));
Assert.areEqual('USA', (String) payload.get('PropertyCountry'));
Assert.areEqual('123-4567890', (String) payload.get('FHACaseNumber'));
Assert.areEqual('Apr 02, 2026', (String) payload.get('AppraiserFieldReviewDate'));
Assert.areEqual('Apr 09, 2026', (String) payload.get('LetterSentDate'));
Assert.areEqual('Jamie Appraiser', (String) payload.get('AppraiserName'));
Assert.areEqual('Ms.', (String) payload.get('AppraiserSalutation'));
Assert.areEqual('Appraiser', (String) payload.get('AppraiserLastName'));
Assert.areEqual('jamie.appraiser@example.com', (String) payload.get('AppraiserEmail'));
Assert.areEqual('245 Lexington Ave, New York, NY 10016, USA', (String) payload.get('AppraiserAddress'));
Assert.areEqual('123 Main St, Denver, CO 80202, USA', (String) payload.get('PropertyAddress'));
List<Object> deficiencyList = (List<Object>) payload.get('DeficiencyList');
Assert.areEqual(2, deficiencyList.size(), 'DeficiencyList should contain 2 items');
Map<String, Object> firstDeficiency = (Map<String, Object>) deficiencyList[0];
Assert.areEqual('VC-1', (String) firstDeficiency.get('reference'));
}
@IsTest
@ -58,7 +103,10 @@ private class AppraiserCasePayloadBuilderTest {
static void testPayloadWithNullDate() {
// Create case without review date
Appraiser_Case__c testCase = new Appraiser_Case__c(
Property_Address__c = '456 Oak Ave, Boulder, CO 80301'
Property_Street__c = '456 Oak Ave',
Property_City__c = 'Boulder',
Property_State_Province__c = 'CO',
Property_Postal_Code__c = '80301'
);
insert testCase;
@ -66,6 +114,7 @@ private class AppraiserCasePayloadBuilderTest {
Assert.isNotNull(payload, 'Payload should not be null even with null date');
Assert.isNull(payload.get('AppraiserFieldReviewDate'), 'Null date should map to null in payload');
Assert.areEqual('456 Oak Ave, Boulder, CO 80301', (String) payload.get('PropertyAddress'));
}
@IsTest

View File

@ -0,0 +1,744 @@
public with sharing class CLMAdminService {
public class CaseDeficiencyItem {
@AuraEnabled public Id recordId;
@AuraEnabled public Decimal deficiencyNumber;
@AuraEnabled public String description;
@AuraEnabled public String resolution;
@AuraEnabled public String reference;
}
public class CaseContext {
@AuraEnabled public Id caseId;
@AuraEnabled public String caseNumber;
@AuraEnabled public String propertyAddress;
@AuraEnabled public String lastDocGenStatus;
@AuraEnabled public String lastDocGenMessage;
@AuraEnabled public String lastClmAccountCode;
@AuraEnabled public String lastDocGenTaskId;
@AuraEnabled public String lastDocGenTaskUrl;
@AuraEnabled public String generatedDocumentUrl;
@AuraEnabled public String generatedDocumentId;
@AuraEnabled public String attachedFileContentDocumentId;
@AuraEnabled public String attachedFileUrl;
@AuraEnabled public Datetime lastDocGenRequestedAt;
@AuraEnabled public Datetime lastDocGenCompletedAt;
@AuraEnabled public List<CaseDeficiencyItem> deficiencies;
}
public class FileAttachmentResult {
@AuraEnabled public Boolean success;
@AuraEnabled public String message;
@AuraEnabled public String contentDocumentId;
@AuraEnabled public String fileUrl;
@AuraEnabled public String fileTitle;
}
public class AccountSettings {
@AuraEnabled public String accountCode;
@AuraEnabled public String accountDisplayName;
@AuraEnabled public String environment;
@AuraEnabled public String clmAccountId;
@AuraEnabled public String clmApiNamedCredential;
@AuraEnabled public String clmDownloadNamedCredential;
@AuraEnabled public String eSignatureRestNamedCredential;
@AuraEnabled public String templateRootFolderHref;
@AuraEnabled public String destinationRootFolderHref;
@AuraEnabled public String defaultTemplateDocumentHref;
@AuraEnabled public String defaultDocumentNamePrefix;
@AuraEnabled public Boolean active;
}
public class LetterSettings {
@AuraEnabled public String accountCode;
@AuraEnabled public String letterCode;
@AuraEnabled public String letterDisplayName;
@AuraEnabled public String description;
@AuraEnabled public Boolean isDefault;
@AuraEnabled public Decimal sortOrder;
@AuraEnabled public String templateRootFolderHref;
@AuraEnabled public String destinationRootFolderHref;
@AuraEnabled public String defaultTemplateDocumentHref;
@AuraEnabled public String defaultDocumentNamePrefix;
@AuraEnabled public Boolean active;
}
public class ResourceItem {
@AuraEnabled public String name;
@AuraEnabled public String href;
@AuraEnabled public String type;
@AuraEnabled public String parentHref;
@AuraEnabled public String rawJson;
}
public class FolderContents {
@AuraEnabled public ResourceItem folder;
@AuraEnabled public List<ResourceItem> folders;
@AuraEnabled public List<ResourceItem> documents;
}
public class DocGenPreview {
@AuraEnabled public String accountCode;
@AuraEnabled public String accountDisplayName;
@AuraEnabled public String letterCode;
@AuraEnabled public String letterDisplayName;
@AuraEnabled public String mergeTaskEndpointUrl;
@AuraEnabled public String templateDocHref;
@AuraEnabled public String destinationFolderHref;
@AuraEnabled public String destinationDocName;
@AuraEnabled public String payloadJson;
@AuraEnabled public String dataXml;
@AuraEnabled public String requestBodyJson;
}
@AuraEnabled(cacheable=true)
public static List<AccountSettings> listAccountSettings() {
List<AccountSettings> settings = new List<AccountSettings>();
for (CLM_Account_Setting__mdt row : [
SELECT DeveloperName,
Account_Code__c,
Account_Display_Name__c,
Environment_Code__c,
CLM_Account_Id__c,
CLM_Api_Named_Credential__c,
CLM_Download_Named_Credential__c,
ESignature_Rest_Named_Credential__c,
Template_Root_Folder_Href__c,
Destination_Root_Folder_Href__c,
Default_Template_Document_Href__c,
Default_Destination_Document_Name_Prefix__c,
Active__c
FROM CLM_Account_Setting__mdt
WHERE Active__c = true
ORDER BY Account_Display_Name__c ASC, DeveloperName ASC
]) {
settings.add(toAccountSettings(row));
}
return settings;
}
@AuraEnabled(cacheable=true)
public static AccountSettings getAccountSettings(String accountCode) {
CLM_Account_Setting__mdt row = resolveAccountSetting(accountCode);
return row == null ? null : toAccountSettings(row);
}
@AuraEnabled(cacheable=true)
public static List<LetterSettings> listLetterSettings(String accountCode) {
AccountSettings account = getAccountSettings(accountCode);
List<LetterSettings> letters = new List<LetterSettings>();
if (account == null) {
return letters;
}
for (CLM_Letter_Definition__mdt row : [
SELECT DeveloperName,
Account_Code__c,
Letter_Code__c,
Letter_Display_Name__c,
Description__c,
Active__c,
Is_Default__c,
Sort_Order__c,
Template_Root_Folder_Href__c,
Destination_Root_Folder_Href__c,
Default_Template_Document_Href__c,
Default_Destination_Document_Name_Prefix__c
FROM CLM_Letter_Definition__mdt
WHERE Active__c = true
AND Account_Code__c = :account.accountCode
ORDER BY Is_Default__c DESC, Sort_Order__c ASC, Letter_Display_Name__c ASC, DeveloperName ASC
]) {
letters.add(toLetterSettings(row, account));
}
if (letters.isEmpty()) {
letters.add(buildFallbackLetterSettings(account));
}
return letters;
}
@AuraEnabled(cacheable=true)
public static LetterSettings getLetterSettings(String accountCode, String letterCode) {
AccountSettings account = getAccountSettings(accountCode);
if (account == null) {
return null;
}
String normalizedLetterCode = String.isBlank(letterCode) ? null : letterCode.trim();
if (String.isNotBlank(normalizedLetterCode)) {
List<CLM_Letter_Definition__mdt> rows = [
SELECT DeveloperName,
Account_Code__c,
Letter_Code__c,
Letter_Display_Name__c,
Description__c,
Active__c,
Is_Default__c,
Sort_Order__c,
Template_Root_Folder_Href__c,
Destination_Root_Folder_Href__c,
Default_Template_Document_Href__c,
Default_Destination_Document_Name_Prefix__c
FROM CLM_Letter_Definition__mdt
WHERE Active__c = true
AND Account_Code__c = :account.accountCode
AND Letter_Code__c = :normalizedLetterCode
LIMIT 1
];
if (!rows.isEmpty()) {
return toLetterSettings(rows[0], account);
}
}
List<LetterSettings> letters = listLetterSettings(account.accountCode);
for (LetterSettings letter : letters) {
if (letter.isDefault) {
return letter;
}
}
return letters.isEmpty() ? buildFallbackLetterSettings(account) : letters[0];
}
@AuraEnabled(cacheable=false)
public static CLMDocGenCallout.CLMDocGenResponse generateDocument(
Id appraiserCaseId,
String templateDocHref,
String destinationFolderHref,
String destinationDocName,
String accountCode
) {
CLM_Account_Setting__mdt account = requireAccountSetting(accountCode);
CLMDocGenCallout.CLMDocGenResponse response = CLMDocGenCallout.generateDocument(
(String) appraiserCaseId,
templateDocHref,
destinationFolderHref,
destinationDocName,
account.Environment_Code__c,
account.CLM_Account_Id__c,
account.CLM_Api_Named_Credential__c
);
persistDocGenResult(appraiserCaseId, templateDocHref, destinationFolderHref, response, false, accountCode);
return response;
}
@AuraEnabled(cacheable=false)
public static CLMDocGenCallout.CLMDocGenResponse getTaskStatus(Id appraiserCaseId, String taskId, String accountCode) {
CLM_Account_Setting__mdt account = requireAccountSetting(accountCode);
CLMDocGenCallout.CLMDocGenResponse response = CLMDocGenCallout.getTaskStatus(
taskId,
account.Environment_Code__c,
account.CLM_Account_Id__c,
account.CLM_Api_Named_Credential__c
);
persistDocGenResult(appraiserCaseId, null, null, response, true, accountCode);
return response;
}
@AuraEnabled(cacheable=false)
public static String probeResource(String resourceOrHref, String accountCode) {
CLM_Account_Setting__mdt account = requireAccountSetting(accountCode);
return performGet(resourceOrHref, account).getBody();
}
@AuraEnabled(cacheable=false)
public static DocGenPreview getDocGenPreview(Id appraiserCaseId, String accountCode, String letterCode) {
if (appraiserCaseId == null) {
throw new AuraHandledException('appraiserCaseId is required');
}
AccountSettings account = getAccountSettings(accountCode);
if (account == null) {
throw new AuraHandledException('No active CLM account setting was found for ' + accountCode + '.');
}
LetterSettings letter = getLetterSettings(account.accountCode, letterCode);
List<Appraiser_Case__c> previewRows = [
SELECT Id, Name
FROM Appraiser_Case__c
WHERE Id = :appraiserCaseId
LIMIT 1
];
if (previewRows.isEmpty()) {
throw new AuraHandledException('Appraiser Case not found: ' + appraiserCaseId);
}
Appraiser_Case__c appraiserCase = previewRows[0];
String prefix = letter != null && String.isNotBlank(letter.defaultDocumentNamePrefix)
? letter.defaultDocumentNamePrefix
: account.defaultDocumentNamePrefix;
DocGenPreview preview = new DocGenPreview();
preview.accountCode = account.accountCode;
preview.accountDisplayName = account.accountDisplayName;
preview.letterCode = letter != null ? letter.letterCode : 'APPRAISER_REVIEW';
preview.letterDisplayName = letter != null ? letter.letterDisplayName : 'Appraiser Review Letter';
preview.templateDocHref = letter != null ? letter.defaultTemplateDocumentHref : account.defaultTemplateDocumentHref;
preview.destinationFolderHref = letter != null ? letter.destinationRootFolderHref : account.destinationRootFolderHref;
preview.destinationDocName = buildDefaultDocumentName(prefix, appraiserCase.Name);
preview.mergeTaskEndpointUrl = CLMDocGenCallout.buildDocumentXmlMergeTasksUrl(
preview.templateDocHref,
preview.destinationFolderHref,
account.environment,
account.clmAccountId
);
preview.payloadJson = JSON.serializePretty(AppraiserCasePayloadBuilder.buildPayload((String) appraiserCaseId));
preview.dataXml = CLMDocGenCallout.prettyPrintXml(CLMDocGenCallout.buildDataXmlForCase((String) appraiserCaseId));
preview.requestBodyJson = CLMDocGenCallout.buildRequestBodyJson(
(String) appraiserCaseId,
preview.templateDocHref,
preview.destinationFolderHref,
preview.destinationDocName
);
return preview;
}
@AuraEnabled(cacheable=false)
public static CaseContext getCaseContext(Id appraiserCaseId) {
List<Appraiser_Case__c> contextRows = [
SELECT Id,
Name,
Property_Street__c,
Property_City__c,
Property_State_Province__c,
Property_Postal_Code__c,
Property_Country__c,
Last_DocGen_Status__c,
Last_DocGen_Message__c,
Last_CLM_Account_Code__c,
Last_DocGen_Task_Id__c,
Last_DocGen_Task_Url__c,
Generated_Document_Url__c,
Generated_Document_Id__c,
Attached_File_Content_Document_Id__c,
Attached_File_Url__c,
Last_DocGen_Requested_At__c,
Last_DocGen_Completed_At__c,
(SELECT Id,
Deficiency_Number__c,
Description__c,
Resolution__c,
Reference__c
FROM Deficiencies__r
ORDER BY Deficiency_Number__c ASC, CreatedDate ASC)
FROM Appraiser_Case__c
WHERE Id = :appraiserCaseId
LIMIT 1
];
if (contextRows.isEmpty()) {
throw new AuraHandledException('Appraiser Case not found: ' + appraiserCaseId);
}
Appraiser_Case__c appraiserCase = contextRows[0];
CaseContext context = new CaseContext();
context.caseId = appraiserCase.Id;
context.caseNumber = appraiserCase.Name;
context.propertyAddress = formatAddress(appraiserCase);
context.lastDocGenStatus = appraiserCase.Last_DocGen_Status__c;
context.lastDocGenMessage = appraiserCase.Last_DocGen_Message__c;
context.lastClmAccountCode = appraiserCase.Last_CLM_Account_Code__c;
context.lastDocGenTaskId = appraiserCase.Last_DocGen_Task_Id__c;
context.lastDocGenTaskUrl = appraiserCase.Last_DocGen_Task_Url__c;
context.generatedDocumentUrl = appraiserCase.Generated_Document_Url__c;
context.generatedDocumentId = appraiserCase.Generated_Document_Id__c;
context.attachedFileContentDocumentId = appraiserCase.Attached_File_Content_Document_Id__c;
context.attachedFileUrl = appraiserCase.Attached_File_Url__c;
context.lastDocGenRequestedAt = appraiserCase.Last_DocGen_Requested_At__c;
context.lastDocGenCompletedAt = appraiserCase.Last_DocGen_Completed_At__c;
context.deficiencies = new List<CaseDeficiencyItem>();
if (appraiserCase.Deficiencies__r != null) {
for (Appraiser_Case_Deficiency__c deficiency : appraiserCase.Deficiencies__r) {
CaseDeficiencyItem item = new CaseDeficiencyItem();
item.recordId = deficiency.Id;
item.deficiencyNumber = deficiency.Deficiency_Number__c;
item.description = deficiency.Description__c;
item.resolution = deficiency.Resolution__c;
item.reference = deficiency.Reference__c;
context.deficiencies.add(item);
}
}
return context;
}
@AuraEnabled(cacheable=false)
public static FileAttachmentResult attachGeneratedDocumentToCase(Id appraiserCaseId, String accountCode) {
if (appraiserCaseId == null) {
throw new AuraHandledException('appraiserCaseId is required');
}
CLM_Account_Setting__mdt account = requireAccountSetting(accountCode);
Appraiser_Case__c appraiserCase = [
SELECT Id,
Name,
Generated_Document_Url__c,
Generated_Document_Id__c
FROM Appraiser_Case__c
WHERE Id = :appraiserCaseId
LIMIT 1
];
if (String.isBlank(appraiserCase.Generated_Document_Url__c)) {
throw new AuraHandledException('No generated document is available to attach yet.');
}
CLMDocGenCallout.DownloadedDocument downloaded = CLMDocGenCallout.downloadDocument(
appraiserCase.Generated_Document_Url__c,
account.Environment_Code__c,
account.CLM_Account_Id__c,
account.CLM_Download_Named_Credential__c
);
String fileName = String.isNotBlank(downloaded.fileName)
? downloaded.fileName
: 'Generated_' + appraiserCase.Name + '.docx';
String title = fileName.contains('.')
? fileName.substringBeforeLast('.')
: fileName;
ContentVersion version = new ContentVersion(
Title = title,
PathOnClient = '/' + fileName,
VersionData = downloaded.body
);
insert version;
version = [
SELECT Id, ContentDocumentId
FROM ContentVersion
WHERE Id = :version.Id
LIMIT 1
];
insert new ContentDocumentLink(
ContentDocumentId = version.ContentDocumentId,
LinkedEntityId = appraiserCase.Id,
ShareType = 'V',
Visibility = 'AllUsers'
);
String fileUrl = '/lightning/r/ContentDocument/' + version.ContentDocumentId + '/view';
update new Appraiser_Case__c(
Id = appraiserCase.Id,
Last_CLM_Account_Code__c = accountCode,
Attached_File_Content_Document_Id__c = version.ContentDocumentId,
Attached_File_Url__c = fileUrl
);
FileAttachmentResult result = new FileAttachmentResult();
result.success = true;
result.message = 'Generated document attached to the case.';
result.contentDocumentId = version.ContentDocumentId;
result.fileUrl = fileUrl;
result.fileTitle = title;
return result;
}
private static String formatAddress(Appraiser_Case__c appraiserCase) {
return AppraiserCasePayloadBuilder.formatMailingAddress(
appraiserCase.Property_Street__c,
appraiserCase.Property_City__c,
appraiserCase.Property_State_Province__c,
appraiserCase.Property_Postal_Code__c,
appraiserCase.Property_Country__c
);
}
@AuraEnabled(cacheable=false)
public static FolderContents getFolderContents(String folderHref, String accountCode) {
if (String.isBlank(folderHref)) {
throw new IllegalArgumentException('folderHref is required');
}
CLM_Account_Setting__mdt account = requireAccountSetting(accountCode);
FolderContents contents = new FolderContents();
contents.folder = parseSingleResource(performGet(folderHref, account).getBody(), 'Folder');
contents.folders = parseResourceList(performGet(folderHref + '/folders', account).getBody(), 'Folder', folderHref);
contents.documents = parseResourceList(performGet(folderHref + '/documents', account).getBody(), 'Document', folderHref);
return contents;
}
private static HttpResponse performGet(String resourceOrHref, CLM_Account_Setting__mdt account) {
HttpRequest req = new HttpRequest();
req.setEndpoint(CLMDocGenCallout.buildEndpointForResource(
resourceOrHref,
account.CLM_Account_Id__c,
account.CLM_Api_Named_Credential__c
));
req.setMethod('GET');
req.setTimeout(CLMDocGenCallout.HTTP_TIMEOUT);
HttpResponse res = new Http().send(req);
Integer statusCode = res.getStatusCode();
if (statusCode < 200 || statusCode >= 300) {
throw new AuraHandledException('CLM API Error (HTTP ' + statusCode + '): ' + res.getBody());
}
return res;
}
@TestVisible
private static ResourceItem parseSingleResource(String body, String defaultType) {
Object root = JSON.deserializeUntyped(body);
if (!(root instanceof Map<String, Object>)) {
return null;
}
return parseResource((Map<String, Object>) root, defaultType, null);
}
@TestVisible
private static List<ResourceItem> parseResourceList(String body, String defaultType, String parentHref) {
Object root = JSON.deserializeUntyped(body);
List<Object> records = unwrapList(root);
List<ResourceItem> items = new List<ResourceItem>();
for (Object record : records) {
if (record instanceof Map<String, Object>) {
items.add(parseResource((Map<String, Object>) record, defaultType, parentHref));
}
}
return items;
}
private static List<Object> unwrapList(Object root) {
if (root instanceof List<Object>) {
return (List<Object>) root;
}
if (root instanceof Map<String, Object>) {
Map<String, Object> payload = (Map<String, Object>) root;
for (String key : new List<String>{ 'Results', 'Items', 'Documents', 'Folders' }) {
Object value = payload.get(key);
if (value instanceof List<Object>) {
return (List<Object>) value;
}
}
if (payload.size() == 1) {
for (Object value : payload.values()) {
if (value instanceof List<Object>) {
return (List<Object>) value;
}
}
}
}
return new List<Object>();
}
private static ResourceItem parseResource(Map<String, Object> source, String defaultType, String parentHref) {
ResourceItem item = new ResourceItem();
item.name = firstString(source, new List<String>{ 'Name', 'DisplayName', 'Title', 'Label' });
item.href = firstString(source, new List<String>{ 'Href', 'Uri', 'Location' });
item.type = firstString(source, new List<String>{ 'Type', 'ObjectType', 'ItemType' });
item.parentHref = extractParentHref(source, parentHref);
item.rawJson = JSON.serialize(source);
if (String.isBlank(item.type)) {
item.type = defaultType;
}
if (String.isBlank(item.name) && String.isNotBlank(item.href)) {
item.name = item.href.substringAfterLast('/');
}
return item;
}
private static String extractParentHref(Map<String, Object> source, String fallbackValue) {
Object parentValue = source.get('Parent');
if (parentValue instanceof Map<String, Object>) {
String href = firstString((Map<String, Object>) parentValue, new List<String>{ 'Href', 'Uri', 'Location' });
if (String.isNotBlank(href)) {
return href;
}
}
return fallbackValue;
}
private static String firstString(Map<String, Object> source, List<String> keys) {
for (String key : keys) {
Object value = source.get(key);
if (value != null) {
String textValue = String.valueOf(value);
if (String.isNotBlank(textValue)) {
return textValue;
}
}
}
return null;
}
private static void persistDocGenResult(
Id appraiserCaseId,
String templateDocHref,
String destinationFolderHref,
CLMDocGenCallout.CLMDocGenResponse response,
Boolean isStatusRefresh,
String accountCode
) {
if (appraiserCaseId == null || response == null) {
return;
}
Appraiser_Case__c updateCase = new Appraiser_Case__c(Id = appraiserCaseId);
updateCase.Last_CLM_Account_Code__c = accountCode;
updateCase.Last_DocGen_Status__c = String.isNotBlank(response.taskStatus) ? response.taskStatus : (response.success ? 'Submitted' : 'Failed');
updateCase.Last_DocGen_Message__c = response.message;
updateCase.Last_DocGen_Task_Id__c = response.documentId;
updateCase.Last_DocGen_Task_Url__c = response.documentUrl;
if (!isStatusRefresh) {
updateCase.Last_DocGen_Requested_At__c = System.now();
updateCase.Last_Template_Document_Href__c = templateDocHref;
updateCase.Last_Destination_Folder_Href__c = destinationFolderHref;
}
if (String.isNotBlank(response.generatedDocumentUrl)) {
updateCase.Generated_Document_Url__c = response.generatedDocumentUrl;
updateCase.Generated_Document_Id__c = response.generatedDocumentId;
}
if (response.success && String.valueOf(response.taskStatus).toLowerCase() == 'completed') {
updateCase.Last_DocGen_Completed_At__c = System.now();
}
update updateCase;
}
private static CLM_Account_Setting__mdt requireAccountSetting(String accountCode) {
CLM_Account_Setting__mdt row = resolveAccountSetting(accountCode);
if (row == null) {
throw new AuraHandledException('No active CLM account setting was found for ' + accountCode + '.');
}
return row;
}
private static CLM_Account_Setting__mdt resolveAccountSetting(String accountCode) {
String normalizedCode = String.isBlank(accountCode) ? null : accountCode.trim();
List<CLM_Account_Setting__mdt> rows;
if (String.isNotBlank(normalizedCode)) {
rows = [
SELECT DeveloperName,
Account_Code__c,
Account_Display_Name__c,
Environment_Code__c,
CLM_Account_Id__c,
CLM_Api_Named_Credential__c,
CLM_Download_Named_Credential__c,
ESignature_Rest_Named_Credential__c,
Template_Root_Folder_Href__c,
Destination_Root_Folder_Href__c,
Default_Template_Document_Href__c,
Default_Destination_Document_Name_Prefix__c,
Active__c
FROM CLM_Account_Setting__mdt
WHERE Active__c = true
AND DeveloperName = :normalizedCode
LIMIT 1
];
if (rows.isEmpty()) {
rows = [
SELECT DeveloperName,
Account_Code__c,
Account_Display_Name__c,
Environment_Code__c,
CLM_Account_Id__c,
CLM_Api_Named_Credential__c,
CLM_Download_Named_Credential__c,
ESignature_Rest_Named_Credential__c,
Template_Root_Folder_Href__c,
Destination_Root_Folder_Href__c,
Default_Template_Document_Href__c,
Default_Destination_Document_Name_Prefix__c,
Active__c
FROM CLM_Account_Setting__mdt
WHERE Active__c = true
AND Account_Code__c = :normalizedCode
LIMIT 1
];
}
} else {
rows = [
SELECT DeveloperName,
Account_Code__c,
Account_Display_Name__c,
Environment_Code__c,
CLM_Account_Id__c,
CLM_Api_Named_Credential__c,
CLM_Download_Named_Credential__c,
ESignature_Rest_Named_Credential__c,
Template_Root_Folder_Href__c,
Destination_Root_Folder_Href__c,
Default_Template_Document_Href__c,
Default_Destination_Document_Name_Prefix__c,
Active__c
FROM CLM_Account_Setting__mdt
WHERE Active__c = true
ORDER BY Account_Display_Name__c ASC, DeveloperName ASC
LIMIT 1
];
}
return rows.isEmpty() ? null : rows[0];
}
private static AccountSettings toAccountSettings(CLM_Account_Setting__mdt row) {
AccountSettings settings = new AccountSettings();
settings.accountCode = String.isNotBlank(row.Account_Code__c) ? row.Account_Code__c : row.DeveloperName;
settings.accountDisplayName = String.isNotBlank(row.Account_Display_Name__c) ? row.Account_Display_Name__c : row.DeveloperName;
settings.environment = row.Environment_Code__c;
settings.clmAccountId = row.CLM_Account_Id__c;
settings.clmApiNamedCredential = row.CLM_Api_Named_Credential__c;
settings.clmDownloadNamedCredential = row.CLM_Download_Named_Credential__c;
settings.eSignatureRestNamedCredential = row.ESignature_Rest_Named_Credential__c;
settings.templateRootFolderHref = row.Template_Root_Folder_Href__c;
settings.destinationRootFolderHref = row.Destination_Root_Folder_Href__c;
settings.defaultTemplateDocumentHref = row.Default_Template_Document_Href__c;
settings.defaultDocumentNamePrefix = row.Default_Destination_Document_Name_Prefix__c;
settings.active = row.Active__c;
return settings;
}
private static LetterSettings toLetterSettings(CLM_Letter_Definition__mdt row, AccountSettings account) {
LetterSettings settings = new LetterSettings();
settings.accountCode = String.isNotBlank(row.Account_Code__c) ? row.Account_Code__c : account.accountCode;
settings.letterCode = String.isNotBlank(row.Letter_Code__c) ? row.Letter_Code__c : 'APPRAISER_REVIEW';
settings.letterDisplayName = String.isNotBlank(row.Letter_Display_Name__c) ? row.Letter_Display_Name__c : row.DeveloperName;
settings.description = row.Description__c;
settings.isDefault = row.Is_Default__c;
settings.sortOrder = row.Sort_Order__c;
settings.templateRootFolderHref = firstNonBlankValue(row.Template_Root_Folder_Href__c, account.templateRootFolderHref);
settings.destinationRootFolderHref = firstNonBlankValue(row.Destination_Root_Folder_Href__c, account.destinationRootFolderHref);
settings.defaultTemplateDocumentHref = firstNonBlankValue(row.Default_Template_Document_Href__c, account.defaultTemplateDocumentHref);
settings.defaultDocumentNamePrefix = firstNonBlankValue(row.Default_Destination_Document_Name_Prefix__c, account.defaultDocumentNamePrefix);
settings.active = row.Active__c;
return settings;
}
private static LetterSettings buildFallbackLetterSettings(AccountSettings account) {
LetterSettings settings = new LetterSettings();
settings.accountCode = account.accountCode;
settings.letterCode = 'APPRAISER_REVIEW';
settings.letterDisplayName = 'Appraiser Review Letter';
settings.description = 'Fallback current appraiser letter flow.';
settings.isDefault = true;
settings.sortOrder = 10;
settings.templateRootFolderHref = account.templateRootFolderHref;
settings.destinationRootFolderHref = account.destinationRootFolderHref;
settings.defaultTemplateDocumentHref = account.defaultTemplateDocumentHref;
settings.defaultDocumentNamePrefix = account.defaultDocumentNamePrefix;
settings.active = true;
return settings;
}
private static String firstNonBlankValue(String preferredValue, String fallbackValue) {
return String.isNotBlank(preferredValue) ? preferredValue : fallbackValue;
}
private static String buildDefaultDocumentName(String prefix, String caseNumber) {
String normalizedPrefix = String.isNotBlank(prefix) ? prefix : 'Review';
return String.isNotBlank(caseNumber)
? normalizedPrefix + '_' + caseNumber + '.docx'
: normalizedPrefix + '.docx';
}
}

View File

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

View File

@ -0,0 +1,286 @@
@IsTest
private class CLMAdminServiceTest {
private class FolderBrowseMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
if (req.getMethod() == 'POST' && req.getEndpoint().contains('/documentxmlmergetasks')) {
res.setStatusCode(200);
res.setBody('{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documentxmlmergetasks/TASK-555","Status":"Queued","Result":{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/generated-555"}}');
return res;
}
if (req.getEndpoint().endsWith('/folders/root-folder')) {
res.setStatusCode(200);
res.setBody('{"Name":"Templates Root","Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-folder","Type":"Folder","Parent":{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-parent"}}');
return res;
}
if (req.getEndpoint().endsWith('/folders/root-folder/folders')) {
res.setStatusCode(200);
res.setBody('{"Results":[{"Name":"Residential","Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/residential","Parent":{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-folder"}}]}');
return res;
}
if (req.getEndpoint().endsWith('/folders/root-folder/documents')) {
res.setStatusCode(200);
res.setBody('{"Results":[{"Name":"Appraiser Review Letter Template","Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documents/template-1"}]}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documentxmlmergetasks')) {
res.setStatusCode(200);
res.setBody('{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documentxmlmergetasks/TASK-555","Status":"Completed","Result":{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/generated-555"}}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documents/generated-555')) {
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
res.setHeader('Content-Disposition', 'attachment; filename="Review_AC-000001.docx"');
res.setBodyAsBlob(Blob.valueOf('docx-binary'));
return res;
}
res.setStatusCode(404);
res.setBody('{"Message":"Not Found"}');
return res;
}
}
@IsTest
static void parsesFolderContents() {
Test.setMock(HttpCalloutMock.class, new FolderBrowseMock());
Test.startTest();
CLMAdminService.FolderContents contents = CLMAdminService.getFolderContents(
'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-folder',
'DTC_CLM_Demo'
);
String body = CLMAdminService.probeResource('/documentxmlmergetasks/TASK-555', 'DTC_IAM_Enterprise');
Test.stopTest();
System.assertEquals('Templates Root', contents.folder.name);
System.assertEquals('https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-parent', contents.folder.parentHref);
System.assertEquals(1, contents.folders.size());
System.assertEquals('Residential', contents.folders[0].name);
System.assertEquals(1, contents.documents.size());
System.assertEquals('Appraiser Review Letter Template', contents.documents[0].name);
System.assert(body.contains('Completed'));
}
@IsTest
static void returnsAccountSettingsFromMetadata() {
Test.startTest();
List<CLMAdminService.AccountSettings> accounts = CLMAdminService.listAccountSettings();
CLMAdminService.AccountSettings settings = CLMAdminService.getAccountSettings('DTC_CLM_Demo');
Test.stopTest();
Set<String> accountCodes = new Set<String>();
for (CLMAdminService.AccountSettings account : accounts) {
accountCodes.add(account.accountCode);
}
System.assert(accountCodes.contains('DTC_CLM_Demo'));
System.assert(accountCodes.contains('DTC_IAM_Enterprise'));
System.assert(accountCodes.contains('DTC_HUD_Demo'));
System.assertNotEquals(null, settings);
System.assertEquals('DTC_CLM_Demo', settings.accountCode);
System.assertEquals('DTC CLM Demo', settings.accountDisplayName);
System.assertEquals('UAT', settings.environment);
System.assert(settings.destinationRootFolderHref.contains('/folders/'));
System.assert(settings.defaultTemplateDocumentHref.contains('/documents/'));
System.assertEquals('Review', settings.defaultDocumentNamePrefix);
}
@IsTest
static void returnsLetterSettingsFromMetadata() {
Test.startTest();
List<CLMAdminService.LetterSettings> letters = CLMAdminService.listLetterSettings('DTC_CLM_Demo');
CLMAdminService.LetterSettings settings = CLMAdminService.getLetterSettings('DTC_CLM_Demo', 'APPRAISER_REVIEW');
Test.stopTest();
System.assert(!letters.isEmpty());
System.assertEquals('APPRAISER_REVIEW', settings.letterCode);
System.assertEquals('Appraiser Review Letter', settings.letterDisplayName);
System.assertEquals(true, settings.isDefault);
System.assertEquals('Review', settings.defaultDocumentNamePrefix);
}
@IsTest
static void buildsDocGenPreviewForSelectedLetter() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today(),
Letter_Sent_Date__c = Date.newInstance(2026, 4, 9),
FHA_Case_Number__c = '123-4567890',
Appraiser_Name__c = 'Jamie Carter',
Appraiser_Last_Name__c = 'Carter',
Appraiser_Street__c = '12 Park Ave',
Appraiser_City__c = 'New York',
Appraiser_State_Province__c = 'NY',
Appraiser_Postal_Code__c = '10016',
Appraiser_Country__c = 'USA',
Property_Street__c = '245 Lexington Ave',
Property_City__c = 'New York',
Property_State_Province__c = 'NY',
Property_Postal_Code__c = '10016',
Property_Country__c = 'USA'
);
insert appraiserCase;
insert new Appraiser_Case_Deficiency__c(
Appraiser_Case__c = appraiserCase.Id,
Deficiency_Number__c = 1,
Description__c = 'Test deficiency',
Resolution__c = 'Test resolution',
Reference__c = 'VC-1'
);
Test.startTest();
CLMAdminService.DocGenPreview preview = CLMAdminService.getDocGenPreview(
appraiserCase.Id,
'DTC_CLM_Demo',
'NOD_LETTER'
);
Test.stopTest();
System.assertEquals('DTC_CLM_Demo', preview.accountCode);
System.assertEquals('NOD_LETTER', preview.letterCode);
System.assertEquals('NOD Letter', preview.letterDisplayName);
Appraiser_Case__c refreshedCase = [
SELECT Name
FROM Appraiser_Case__c
WHERE Id = :appraiserCase.Id
LIMIT 1
];
System.assertEquals('NOD_' + refreshedCase.Name + '.docx', preview.destinationDocName);
System.assert(preview.mergeTaskEndpointUrl.endsWith('/documentxmlmergetasks'));
System.assert(preview.payloadJson.contains('FHACaseNumber'));
System.assert(preview.dataXml.contains('\n <'));
System.assert(preview.dataXml.contains('<Reference>VC-1</Reference>'));
System.assert(preview.requestBodyJson.contains('"DestinationDocumentName"'));
}
@IsTest
static void persistsCaseTrackingOnGenerate() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today(),
Property_Street__c = '123 Main St',
Property_City__c = 'Denver',
Property_State_Province__c = 'CO',
Property_Postal_Code__c = '80202'
);
insert appraiserCase;
insert new Appraiser_Case_Deficiency__c(
Appraiser_Case__c = appraiserCase.Id,
Deficiency_Number__c = 1,
Description__c = 'Test deficiency',
Resolution__c = 'Test resolution',
Reference__c = 'VC-1'
);
Test.setMock(HttpCalloutMock.class, new FolderBrowseMock());
Test.startTest();
CLMDocGenCallout.CLMDocGenResponse generateResponse = CLMAdminService.generateDocument(
appraiserCase.Id,
'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/template-1',
'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/folders/root-folder',
'Review_AC-000001.docx',
'DTC_CLM_Demo'
);
CLMAdminService.CaseContext context = CLMAdminService.getCaseContext(appraiserCase.Id);
Test.stopTest();
System.assertEquals(true, generateResponse.success);
System.assertEquals('Queued', context.lastDocGenStatus);
System.assertEquals('DTC_CLM_Demo', context.lastClmAccountCode);
System.assertEquals('TASK-555', context.lastDocGenTaskId);
System.assert(context.lastDocGenTaskUrl.contains('/documentxmlmergetasks/TASK-555'));
System.assert(context.generatedDocumentUrl.contains('/documents/generated-555'));
System.assertNotEquals(null, context.lastDocGenRequestedAt);
System.assertEquals(null, context.lastDocGenCompletedAt);
System.assertEquals(1, context.deficiencies.size());
System.assertEquals('123 Main St, Denver, CO 80202', context.propertyAddress);
System.assertEquals('VC-1', context.deficiencies[0].reference);
}
@IsTest
static void persistsCaseTrackingOnStatusRefresh() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today(),
Property_Street__c = '123 Main St',
Property_City__c = 'Denver',
Property_State_Province__c = 'CO',
Property_Postal_Code__c = '80202',
Last_DocGen_Task_Id__c = 'TASK-555'
);
insert appraiserCase;
insert new Appraiser_Case_Deficiency__c(
Appraiser_Case__c = appraiserCase.Id,
Deficiency_Number__c = 1,
Description__c = 'Test deficiency',
Resolution__c = 'Test resolution',
Reference__c = 'VC-1'
);
Test.setMock(HttpCalloutMock.class, new FolderBrowseMock());
Test.startTest();
CLMDocGenCallout.CLMDocGenResponse statusResponse = CLMAdminService.getTaskStatus(appraiserCase.Id, 'TASK-555', 'DTC_CLM_Demo');
CLMAdminService.CaseContext context = CLMAdminService.getCaseContext(appraiserCase.Id);
Test.stopTest();
System.assertEquals(true, statusResponse.success);
System.assertEquals('Completed', context.lastDocGenStatus);
System.assertEquals('DTC_CLM_Demo', context.lastClmAccountCode);
System.assertEquals('TASK-555', context.lastDocGenTaskId);
System.assert(context.lastDocGenTaskUrl.contains('/documentxmlmergetasks/TASK-555'));
System.assert(context.generatedDocumentUrl.contains('/documents/generated-555'));
System.assertNotEquals(null, context.lastDocGenCompletedAt);
System.assertEquals(1, context.deficiencies.size());
}
@IsTest
static void attachesGeneratedDocumentToCaseAsSalesforceFile() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today(),
Property_Street__c = '245 Lexington Ave',
Property_City__c = 'New York',
Property_State_Province__c = 'NY',
Property_Postal_Code__c = '10016',
Generated_Document_Url__c = 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/generated-555',
Generated_Document_Id__c = 'generated-555'
);
insert appraiserCase;
Test.setMock(HttpCalloutMock.class, new FolderBrowseMock());
Test.startTest();
CLMAdminService.FileAttachmentResult result = CLMAdminService.attachGeneratedDocumentToCase(appraiserCase.Id, 'DTC_CLM_Demo');
Test.stopTest();
Appraiser_Case__c refreshedCase = [
SELECT Last_CLM_Account_Code__c, Attached_File_Content_Document_Id__c, Attached_File_Url__c
FROM Appraiser_Case__c
WHERE Id = :appraiserCase.Id
LIMIT 1
];
List<ContentDocumentLink> links = [
SELECT ContentDocumentId, LinkedEntityId
FROM ContentDocumentLink
WHERE LinkedEntityId = :appraiserCase.Id
];
System.assertEquals(true, result.success);
System.assertNotEquals(null, result.contentDocumentId);
System.assert(result.fileUrl.contains('/lightning/r/ContentDocument/'));
System.assertEquals('DTC_CLM_Demo', refreshedCase.Last_CLM_Account_Code__c);
System.assertEquals(result.contentDocumentId, refreshedCase.Attached_File_Content_Document_Id__c);
System.assertEquals(result.fileUrl, refreshedCase.Attached_File_Url__c);
System.assertEquals(1, links.size());
System.assertEquals(result.contentDocumentId, links[0].ContentDocumentId);
}
}

View File

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

View File

@ -1,18 +1,95 @@
public class CLMDocGenCallout {
// S1 demo environment
private static final String CLM_ACCOUNT_ID_S1 = '2371cf36-eb8a-43fe-9f28-b5bbe7644397';
private static final String CLM_BASE_S1 = 'callout:CLMNamedCred/v2/' + CLM_ACCOUNT_ID_S1;
private static final String CLM_ACCOUNT_ID_S1 = '2371cf36-eb8a-43fe-9f28-b5bbe7644397';
private static final String CLM_NAMED_CRED_S1 = 'callout:CLMs1NamedCreds';
private static final String CLM_DOWNLOAD_NAMED_CRED_S1 = 'callout:CLMs1Download';
// UAT demo environment
private static final String CLM_ACCOUNT_ID_UAT = 'bccae332-c7db-4892-ab85-257df0f70fea';
private static final String CLM_BASE_UAT = 'callout:CLMuatNamedCreds/v2/' + CLM_ACCOUNT_ID_UAT;
private static final String CLM_NAMED_CRED_UAT = 'callout:CLMuatNamedCreds';
private static final String CLM_DOWNLOAD_NAMED_CRED_UAT = 'callout:CLMuatDownload';
private static final Integer HTTP_TIMEOUT = 30000;
public static final Integer HTTP_TIMEOUT = 30000;
/** Resolve the correct CLM base URL. env: 'UAT' or 'S1'. */
private static String clmBase(String env) {
return env == 'S1' ? CLM_BASE_S1 : CLM_BASE_UAT;
/** Resolve the correct CLM Named Credential base. env: 'UAT' or 'S1'. */
private static String clmNamedCredential(String env) {
return env == 'S1' ? CLM_NAMED_CRED_S1 : CLM_NAMED_CRED_UAT;
}
private static String clmDownloadNamedCredential(String env) {
return env == 'S1' ? CLM_DOWNLOAD_NAMED_CRED_S1 : CLM_DOWNLOAD_NAMED_CRED_UAT;
}
@TestVisible
static String defaultAccountId(String env) {
return env == 'S1' ? CLM_ACCOUNT_ID_S1 : CLM_ACCOUNT_ID_UAT;
}
@TestVisible
static String extractAccountId(String resourceOrHref) {
if (String.isBlank(resourceOrHref)) {
return null;
}
Integer v2Index = resourceOrHref.indexOf('/v2/');
if (v2Index < 0) {
return null;
}
String afterV2 = resourceOrHref.substring(v2Index + 4);
List<String> parts = afterV2.split('/');
return parts.isEmpty() ? null : parts[0];
}
@TestVisible
static String normalizeResourcePath(String resourceOrHref, String env) {
return normalizeResourcePathWithAccountId(resourceOrHref, defaultAccountId(env));
}
@TestVisible
static String normalizeResourcePathWithAccountId(String resourceOrHref, String fallbackAccountId) {
if (String.isBlank(resourceOrHref)) {
throw new IllegalArgumentException('resourceOrHref is required');
}
if (resourceOrHref.startsWith('http://') || resourceOrHref.startsWith('https://')) {
Integer pathStart = resourceOrHref.indexOf('/', resourceOrHref.indexOf('//') + 2);
if (pathStart < 0) {
throw new IllegalArgumentException('Unable to determine resource path from href: ' + resourceOrHref);
}
return resourceOrHref.substring(pathStart);
}
if (resourceOrHref.startsWith('/v2/')) {
return resourceOrHref;
}
if (resourceOrHref.startsWith('v2/')) {
return '/' + resourceOrHref;
}
if (resourceOrHref.startsWith('/')) {
return '/v2/' + fallbackAccountId + resourceOrHref;
}
return '/v2/' + fallbackAccountId + '/' + resourceOrHref;
}
public static String buildEndpointForResource(String resourceOrHref, String env) {
return clmNamedCredential(env) + normalizeResourcePath(resourceOrHref, env);
}
public static String buildEndpointForResource(String resourceOrHref, String accountId, String apiNamedCredential) {
return 'callout:' + apiNamedCredential + normalizeResourcePathWithAccountId(resourceOrHref, accountId);
}
public static String buildDownloadEndpointForResource(String resourceOrHref, String env) {
return clmDownloadNamedCredential(env) + normalizeResourcePath(resourceOrHref, env);
}
public static String buildDownloadEndpointForResource(String resourceOrHref, String accountId, String downloadNamedCredential) {
return 'callout:' + downloadNamedCredential + normalizeResourcePathWithAccountId(resourceOrHref, accountId);
}
/** Defaults to UAT environment. */
@ -40,21 +117,42 @@ public class CLMDocGenCallout {
String destinationDocName,
String env
) {
Map<String, Object> casePayload = AppraiserCasePayloadBuilder.buildPayload(caseId);
return generateDocument(
caseId,
templateDocHref,
destinationFolderHref,
destinationDocName,
env,
defaultAccountId(env),
clmNamedCredential(env).substringAfter('callout:')
);
}
Map<String, Object> requestBody = new Map<String, Object>{
'TemplateDocument' => new Map<String, Object>{
'Href' => templateDocHref
},
'DataXML' => buildDataXml(casePayload),
'DestinationDocumentName' => destinationDocName,
'DestinationFolder' => new Map<String, Object>{
'Href' => destinationFolderHref
}
};
public static CLMDocGenResponse generateDocument(
String caseId,
String templateDocHref,
String destinationFolderHref,
String destinationDocName,
String env,
String configuredAccountId,
String apiNamedCredential
) {
Map<String, Object> casePayload = AppraiserCasePayloadBuilder.buildPayload(caseId);
String accountId = firstNonBlank(
extractAccountId(templateDocHref),
extractAccountId(destinationFolderHref),
configuredAccountId,
defaultAccountId(env)
);
Map<String, Object> requestBody = buildRequestBodyMap(
casePayload,
templateDocHref,
destinationFolderHref,
destinationDocName
);
HttpRequest req = new HttpRequest();
req.setEndpoint(clmBase(env) + '/documentxmlmergetasks');
req.setEndpoint('callout:' + apiNamedCredential + '/v2/' + accountId + '/documentxmlmergetasks');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(HTTP_TIMEOUT);
@ -69,15 +167,59 @@ public class CLMDocGenCallout {
}
}
/** Poll the status of a submitted merge task by its GUID. */
public static String buildDataXmlForCase(String caseId) {
return buildDataXml(AppraiserCasePayloadBuilder.buildPayload(caseId));
}
public static String buildRequestBodyJson(
String caseId,
String templateDocHref,
String destinationFolderHref,
String destinationDocName
) {
return JSON.serializePretty(
buildRequestBodyMap(
AppraiserCasePayloadBuilder.buildPayload(caseId),
templateDocHref,
destinationFolderHref,
destinationDocName
)
);
}
public static String buildDocumentXmlMergeTasksUrl(
String templateDocHref,
String destinationFolderHref,
String env,
String configuredAccountId
) {
String accountId = firstNonBlank(
extractAccountId(templateDocHref),
extractAccountId(destinationFolderHref),
configuredAccountId,
defaultAccountId(env)
);
String baseUrl = firstNonBlank(
extractBaseUrl(templateDocHref),
extractBaseUrl(destinationFolderHref),
null,
defaultBaseUrl(env)
);
return baseUrl + '/v2/' + accountId + '/documentxmlmergetasks';
}
/** Poll the status of a submitted merge task by its GUID (defaults to UAT). */
public static CLMDocGenResponse getTaskStatus(String taskId) {
return getTaskStatus(taskId, 'UAT');
}
public static CLMDocGenResponse getTaskStatus(String taskId, String env) {
return getTaskStatus(taskId, env, defaultAccountId(env), clmNamedCredential(env).substringAfter('callout:'));
}
public static CLMDocGenResponse getTaskStatus(String taskId, String env, String configuredAccountId, String apiNamedCredential) {
HttpRequest req = new HttpRequest();
req.setEndpoint(clmBase(env) + '/documentxmlmergetasks/' + taskId);
req.setEndpoint(buildEndpointForResource('/documentxmlmergetasks/' + taskId, configuredAccountId, apiNamedCredential));
req.setMethod('GET');
req.setTimeout(HTTP_TIMEOUT);
try {
@ -96,13 +238,41 @@ public class CLMDocGenCallout {
public static String probe(String resource, String env) {
HttpRequest req = new HttpRequest();
req.setEndpoint(clmBase(env) + '/' + resource);
req.setEndpoint(buildEndpointForResource(resource, env));
req.setMethod('GET');
req.setTimeout(HTTP_TIMEOUT);
HttpResponse res = new Http().send(req);
return 'HTTP ' + res.getStatusCode() + ': ' + res.getBody();
}
public static DownloadedDocument downloadDocument(String resourceOrHref, String env) {
return downloadDocument(
resourceOrHref,
env,
defaultAccountId(env),
clmDownloadNamedCredential(env).substringAfter('callout:')
);
}
public static DownloadedDocument downloadDocument(String resourceOrHref, String env, String configuredAccountId, String downloadNamedCredential) {
HttpRequest req = new HttpRequest();
req.setEndpoint(buildDownloadEndpointForResource(resourceOrHref, configuredAccountId, downloadNamedCredential));
req.setMethod('GET');
req.setTimeout(HTTP_TIMEOUT);
HttpResponse res = new Http().send(req);
Integer statusCode = res.getStatusCode();
if (statusCode < 200 || statusCode >= 300) {
throw new CalloutException('CLM download error (HTTP ' + statusCode + '): ' + res.getBody());
}
DownloadedDocument document = new DownloadedDocument();
document.body = res.getBodyAsBlob();
document.contentType = res.getHeader('Content-Type');
document.fileName = extractFileName(res, resourceOrHref, document.contentType);
return document;
}
/**
* Build the DataXML string from the case payload.
* Flat fields become direct child elements of <TemplateFieldData>.
@ -115,7 +285,7 @@ public class CLMDocGenCallout {
// Emit flat fields first
for (String key : payload.keySet()) {
if (key == 'DeficiencyList') continue;
xml += '<' + key + '>' + escapeXml(String.valueOf(payload.get(key))) + '</' + key + '>';
xml += '<' + key + '>' + escapeXml(safeValue(payload.get(key))) + '</' + key + '>';
}
// Emit DeficiencyList as a nested list so templates can iterate dynamically
@ -125,9 +295,10 @@ public class CLMDocGenCallout {
for (Integer i = 0; i < deficiencies.size(); i++) {
Map<String, Object> d = (Map<String, Object>) deficiencies[i];
xml += '<Deficiency>';
xml += '<Number>' + escapeXml(String.valueOf(d.get('deficiencyNumber'))) + '</Number>';
xml += '<Description>' + escapeXml(String.valueOf(d.get('description'))) + '</Description>';
xml += '<Resolution>' + escapeXml(String.valueOf(d.get('resolution'))) + '</Resolution>';
xml += '<Number>' + escapeXml(safeValue(d.get('deficiencyNumber'))) + '</Number>';
xml += '<Description>' + escapeXml(safeValue(d.get('description'))) + '</Description>';
xml += '<Resolution>' + escapeXml(safeValue(d.get('resolution'))) + '</Resolution>';
xml += '<Reference>' + escapeXml(safeValue(d.get('reference'))) + '</Reference>';
xml += '</Deficiency>';
}
xml += '</DeficiencyList>';
@ -138,6 +309,93 @@ public class CLMDocGenCallout {
return xml;
}
public static String prettyPrintXml(String xml) {
if (String.isBlank(xml)) {
return xml;
}
String normalized = xml
.replace('><', '>\n<')
.replace('\r\n', '\n')
.replace('\r', '\n');
List<String> lines = normalized.split('\n');
List<String> formatted = new List<String>();
Integer indent = 0;
for (String rawLine : lines) {
String line = rawLine == null ? '' : rawLine.trim();
if (line == '') {
continue;
}
Boolean isClosing = line.startsWith('</');
Boolean isDeclaration = line.startsWith('<?') || line.startsWith('<!');
Boolean isSelfClosing = line.endsWith('/>') || (line.contains('</') && line.indexOf('</') > 0);
if (isClosing && indent > 0) {
indent--;
}
formatted.add(repeatIndent(indent) + line);
if (!isClosing && !isSelfClosing && !isDeclaration) {
indent++;
}
}
return String.join(formatted, '\n');
}
private static Map<String, Object> buildRequestBodyMap(
Map<String, Object> casePayload,
String templateDocHref,
String destinationFolderHref,
String destinationDocName
) {
return new Map<String, Object>{
'TemplateDocument' => new Map<String, Object>{
'Href' => templateDocHref
},
'DataXML' => buildDataXml(casePayload),
'DestinationDocumentName' => destinationDocName,
'DestinationFolder' => new Map<String, Object>{
'Href' => destinationFolderHref
}
};
}
private static String extractBaseUrl(String resourceOrHref) {
if (String.isBlank(resourceOrHref)) {
return null;
}
if (!(resourceOrHref.startsWith('http://') || resourceOrHref.startsWith('https://'))) {
return null;
}
Integer schemeIndex = resourceOrHref.indexOf('//');
Integer pathStart = resourceOrHref.indexOf('/', schemeIndex + 2);
return pathStart > 0 ? resourceOrHref.substring(0, pathStart) : resourceOrHref;
}
private static String defaultBaseUrl(String env) {
return env == 'S1'
? 'https://api.s1.us.clm.demo.docusign.net'
: 'https://apiuatna11.springcm.com';
}
private static String repeatIndent(Integer indent) {
String value = '';
for (Integer i = 0; i < indent; i++) {
value += ' ';
}
return value;
}
private static String safeValue(Object val) {
return val != null ? String.valueOf(val) : '';
}
private static String escapeXml(String s) {
if (s == null) return '';
return s.replace('&', '&amp;')
@ -152,26 +410,160 @@ public class CLMDocGenCallout {
String body = res.getBody();
if (statusCode >= 200 && statusCode < 300) {
Map<String, Object> m = (Map<String, Object>) JSON.deserializeUntyped(body);
String href = (String) m.get('Href');
String status = (String) m.get('Status');
String href = firstString(m, new List<String>{ 'Href', 'Uri', 'Location' });
String status = firstString(m, new List<String>{ 'Status', 'State' });
String taskId = href != null ? href.substringAfterLast('/') : null;
return new CLMDocGenResponse(true, 'Task status: ' + status, href, taskId);
String generatedDocumentUrl = findFirstDocumentHref(m);
String generatedDocumentId = generatedDocumentUrl != null ? generatedDocumentUrl.substringAfterLast('/') : null;
String message = 'Task status: ' + (String.isNotBlank(status) ? status : 'Unknown');
return new CLMDocGenResponse(true, message, href, taskId, status, generatedDocumentUrl, generatedDocumentId, body);
} else {
return new CLMDocGenResponse(false, 'CLM API Error (HTTP ' + statusCode + '): ' + body, null, null);
return new CLMDocGenResponse(false, 'CLM API Error (HTTP ' + statusCode + '): ' + body, null, null, null, null, null, body);
}
}
private static String firstNonBlank(String firstValue, String secondValue, String thirdValue, String fallbackValue) {
if (String.isNotBlank(firstValue)) {
return firstValue;
}
if (String.isNotBlank(secondValue)) {
return secondValue;
}
if (String.isNotBlank(thirdValue)) {
return thirdValue;
}
return fallbackValue;
}
private static String firstString(Map<String, Object> source, List<String> keys) {
for (String key : keys) {
Object value = source.get(key);
if (value != null) {
String asString = String.valueOf(value);
if (String.isNotBlank(asString)) {
return asString;
}
}
}
return null;
}
private static String findFirstDocumentHref(Object node) {
if (node instanceof Map<String, Object>) {
Map<String, Object> mapNode = (Map<String, Object>) node;
String href = firstString(mapNode, new List<String>{ 'Href', 'Uri', 'Location' });
if (String.isNotBlank(href) && href.contains('/documents/') && !href.contains('/documentxmlmergetasks/')) {
return href;
}
for (Object value : mapNode.values()) {
String nestedHref = findFirstDocumentHref(value);
if (String.isNotBlank(nestedHref)) {
return nestedHref;
}
}
} else if (node instanceof List<Object>) {
for (Object item : (List<Object>) node) {
String nestedHref = findFirstDocumentHref(item);
if (String.isNotBlank(nestedHref)) {
return nestedHref;
}
}
}
return null;
}
private static String extractFileName(HttpResponse res, String resourceOrHref, String contentType) {
String disposition = res.getHeader('Content-Disposition');
if (String.isNotBlank(disposition)) {
Integer marker = disposition.toLowerCase().indexOf('filename=');
if (marker >= 0) {
String candidate = disposition.substring(marker + 9).trim();
if (candidate.startsWith('"') && candidate.endsWith('"') && candidate.length() >= 2) {
candidate = candidate.substring(1, candidate.length() - 1);
}
if (String.isNotBlank(candidate)) {
return candidate;
}
}
}
String baseName = String.isNotBlank(resourceOrHref) ? resourceOrHref.substringAfterLast('/') : 'generated-document';
String extension = inferExtension(contentType);
if (String.isNotBlank(extension) && !baseName.toLowerCase().endsWith(extension)) {
return baseName + extension;
}
return baseName;
}
private static String inferExtension(String contentType) {
if (String.isBlank(contentType)) {
return '.docx';
}
String normalizedType = contentType.toLowerCase();
if (normalizedType.contains('pdf')) {
return '.pdf';
}
if (normalizedType.contains('wordprocessingml') || normalizedType.contains('officedocument')) {
return '.docx';
}
if (normalizedType.contains('msword')) {
return '.doc';
}
if (normalizedType.contains('json')) {
return '.json';
}
return '';
}
public class CLMDocGenResponse {
@AuraEnabled
public Boolean success;
@AuraEnabled
public String message;
@AuraEnabled
public String documentUrl;
@AuraEnabled
public String documentId;
@AuraEnabled
public String taskStatus;
@AuraEnabled
public String generatedDocumentUrl;
@AuraEnabled
public String generatedDocumentId;
@AuraEnabled
public String taskDetailsJson;
public CLMDocGenResponse(Boolean success, String message, String documentUrl, String documentId) {
this.success = success;
this.message = message;
this(success, message, documentUrl, documentId, null, null, null, null);
}
public CLMDocGenResponse(
Boolean success,
String message,
String documentUrl,
String documentId,
String taskStatus,
String generatedDocumentUrl,
String generatedDocumentId,
String taskDetailsJson
) {
this.success = success;
this.message = message;
this.documentUrl = documentUrl;
this.documentId = documentId;
this.documentId = documentId;
this.taskStatus = taskStatus;
this.generatedDocumentUrl = generatedDocumentUrl;
this.generatedDocumentId = generatedDocumentId;
this.taskDetailsJson = taskDetailsJson;
}
}
public class DownloadedDocument {
public Blob body;
public String fileName;
public String contentType;
}
}

View File

@ -0,0 +1,155 @@
@IsTest
private class CLMDocGenCalloutTest {
private class CLMCalloutMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
if (req.getMethod() == 'POST' && req.getEndpoint().contains('/documentxmlmergetasks')) {
System.assert(req.getBody().contains('TemplateDocument'));
System.assert(req.getBody().contains('DestinationFolder'));
System.assert(req.getBody().contains('DeficiencyList'));
System.assert(req.getBody().contains('&amp;'));
res.setStatusCode(200);
res.setBody('{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documentxmlmergetasks/TASK-123","Status":"Queued"}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documentxmlmergetasks/TASK-123')) {
res.setStatusCode(200);
res.setBody('{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documentxmlmergetasks/TASK-123","Status":"Completed"}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documents/template-guid')) {
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="GeneratedReview.pdf"');
res.setBodyAsBlob(Blob.valueOf('pdf-bytes'));
return res;
}
res.setStatusCode(404);
res.setBody('{"Message":"Not Found"}');
return res;
}
}
@IsTest
static void generatesDocumentAndPollsTaskStatus() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.newInstance(2026, 4, 2),
Letter_Sent_Date__c = Date.newInstance(2026, 4, 9),
FHA_Case_Number__c = '123-4567890',
Appraiser_Name__c = 'Jamie Appraiser',
Appraiser_Last_Name__c = 'Appraiser',
Property_Street__c = '123 Main & Main <Suite 5>',
Property_City__c = 'Denver',
Property_State_Province__c = 'CO',
Property_Postal_Code__c = '80202'
);
insert appraiserCase;
insert new Appraiser_Case_Deficiency__c(
Appraiser_Case__c = appraiserCase.Id,
Deficiency_Number__c = 1,
Description__c = 'Missing comparable sale adjustment detail.',
Resolution__c = 'Added supporting calculations & notes.',
Reference__c = 'VC-1'
);
Test.setMock(HttpCalloutMock.class, new CLMCalloutMock());
Test.startTest();
CLMDocGenCallout.CLMDocGenResponse response = CLMDocGenCallout.generateDocument(
appraiserCase.Id,
'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documents/template-guid',
'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid',
'Review_AC-00001.docx',
'UAT'
);
CLMDocGenCallout.CLMDocGenResponse taskStatus = CLMDocGenCallout.getTaskStatus('TASK-123', 'S1');
Test.stopTest();
System.assertEquals(true, response.success);
System.assertEquals('TASK-123', response.documentId);
System.assert(response.documentUrl.contains('/documentxmlmergetasks/TASK-123'));
System.assertEquals(true, taskStatus.success);
System.assertEquals('Task status: Completed', taskStatus.message);
}
@IsTest
static void normalizesResourcePathsAndExtractsAccountIds() {
System.assertEquals(
'2371cf36-eb8a-43fe-9f28-b5bbe7644397',
CLMDocGenCallout.extractAccountId('https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid')
);
System.assertEquals(
'/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid',
CLMDocGenCallout.normalizeResourcePath(
'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid',
'UAT'
)
);
System.assertEquals(
'/v2/' + CLMDocGenCallout.defaultAccountId('UAT') + '/folders/folder-guid',
CLMDocGenCallout.normalizeResourcePath('/folders/folder-guid', 'UAT')
);
}
@IsTest
static void downloadsGeneratedDocumentThroughDownloadCredential() {
Test.setMock(HttpCalloutMock.class, new CLMCalloutMock());
Test.startTest();
CLMDocGenCallout.DownloadedDocument downloaded = CLMDocGenCallout.downloadDocument(
'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/template-guid',
'UAT'
);
Test.stopTest();
System.assertEquals('GeneratedReview.pdf', downloaded.fileName);
System.assertEquals('application/pdf', downloaded.contentType);
System.assertNotEquals(null, downloaded.body);
}
@IsTest
static void buildsPreviewXmlAndRequestBody() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.newInstance(2026, 4, 2),
Letter_Sent_Date__c = Date.newInstance(2026, 4, 9),
FHA_Case_Number__c = '123-4567890',
Appraiser_Name__c = 'Jamie Appraiser',
Appraiser_Last_Name__c = 'Appraiser',
Property_Street__c = '245 Lexington Ave',
Property_City__c = 'New York',
Property_State_Province__c = 'NY',
Property_Postal_Code__c = '10016',
Property_Country__c = 'USA'
);
insert appraiserCase;
insert new Appraiser_Case_Deficiency__c(
Appraiser_Case__c = appraiserCase.Id,
Deficiency_Number__c = 1,
Description__c = 'Missing comparable sale adjustment detail.',
Resolution__c = 'Added supporting calculations.',
Reference__c = 'VC-1'
);
String dataXml = CLMDocGenCallout.buildDataXmlForCase(appraiserCase.Id);
String requestBodyJson = CLMDocGenCallout.buildRequestBodyJson(
appraiserCase.Id,
'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/template-guid',
'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/folders/folder-guid',
'Review_AC-000002.docx'
);
System.assert(dataXml.contains('<LetterSentDate>'));
System.assert(dataXml.contains('<FHACaseNumber>123-4567890</FHACaseNumber>'));
System.assert(dataXml.contains('<Reference>VC-1</Reference>'));
System.assert(requestBodyJson.contains('"DataXML"'));
System.assert(requestBodyJson.contains('Review_AC-000002.docx'));
System.assert(requestBodyJson.contains('template-guid'));
}
}

View File

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

View File

@ -0,0 +1,339 @@
public with sharing class DocusignESignatureService {
public class ESignatureAccountConfig {
@AuraEnabled public String accountCode;
@AuraEnabled public String accountDisplayName;
@AuraEnabled public String environment;
@AuraEnabled public String eSignatureAuthNamedCredential;
@AuraEnabled public String eSignatureRestNamedCredential;
@AuraEnabled public String eSignatureAccountId;
}
public class ApiResponse {
@AuraEnabled public Boolean success;
@AuraEnabled public Integer statusCode;
@AuraEnabled public String message;
@AuraEnabled public String requestPath;
@AuraEnabled public String responseBody;
}
public class ESignatureAccountSummary {
@AuraEnabled public String accountId;
@AuraEnabled public String accountName;
@AuraEnabled public String baseUri;
@AuraEnabled public Boolean isDefault;
@AuraEnabled public String rawJson;
}
public class TemplateSummary {
@AuraEnabled public String templateId;
@AuraEnabled public String name;
@AuraEnabled public String description;
@AuraEnabled public String shared;
@AuraEnabled public String lastModified;
@AuraEnabled public String rawJson;
}
public class EnvelopeSummary {
@AuraEnabled public String envelopeId;
@AuraEnabled public String emailSubject;
@AuraEnabled public String status;
@AuraEnabled public String createdDateTime;
@AuraEnabled public String sentDateTime;
@AuraEnabled public String completedDateTime;
@AuraEnabled public String rawJson;
}
@AuraEnabled(cacheable=true)
public static ESignatureAccountConfig getAccountConfig(String accountCode) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
ESignatureAccountConfig config = new ESignatureAccountConfig();
config.accountCode = String.isNotBlank(row.Account_Code__c) ? row.Account_Code__c : row.DeveloperName;
config.accountDisplayName = String.isNotBlank(row.Account_Display_Name__c) ? row.Account_Display_Name__c : row.DeveloperName;
config.environment = row.Environment_Code__c;
config.eSignatureAuthNamedCredential = row.ESignature_Auth_Named_Credential__c;
config.eSignatureRestNamedCredential = row.ESignature_Rest_Named_Credential__c;
config.eSignatureAccountId = row.ESignature_Account_Id__c;
return config;
}
@AuraEnabled(cacheable=false)
public static ApiResponse probe(String accountCode, String relativePath) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
String normalizedPath = normalizePath(relativePath);
HttpRequest req = new HttpRequest();
req.setEndpoint(buildEndpoint(normalizedPath, row.ESignature_Rest_Named_Credential__c));
req.setMethod('GET');
req.setTimeout(30000);
HttpResponse res = new Http().send(req);
ApiResponse response = new ApiResponse();
response.success = res.getStatusCode() >= 200 && res.getStatusCode() < 300;
response.statusCode = res.getStatusCode();
response.message = response.success ? 'eSignature request succeeded.' : 'eSignature request failed.';
response.requestPath = normalizedPath;
response.responseBody = res.getBody();
return response;
}
@AuraEnabled(cacheable=false)
public static List<ESignatureAccountSummary> listAccounts(String accountCode) {
ApiResponse response = getLoginInformation(accountCode);
if (!response.success) {
throw new AuraHandledException('eSignature API Error (HTTP ' + response.statusCode + '): ' + response.responseBody);
}
return parseAccountList(response.responseBody);
}
@AuraEnabled(cacheable=false)
public static ApiResponse getLoginInformation(String accountCode) {
return probe(accountCode, '/v2.1/login_information');
}
@AuraEnabled(cacheable=false)
public static ApiResponse getUserInfo(String accountCode) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
HttpRequest req = new HttpRequest();
req.setEndpoint(buildEndpoint('/oauth/userinfo', authNamedCredential(row)));
req.setMethod('GET');
req.setTimeout(30000);
HttpResponse res = new Http().send(req);
ApiResponse response = new ApiResponse();
response.success = res.getStatusCode() >= 200 && res.getStatusCode() < 300;
response.statusCode = res.getStatusCode();
response.message = response.success ? 'eSignature user info request succeeded.' : 'eSignature user info request failed.';
response.requestPath = '/oauth/userinfo';
response.responseBody = res.getBody();
return response;
}
@AuraEnabled(cacheable=false)
public static ApiResponse getAccountInformation(String accountCode, String eSignatureAccountId) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
String targetAccountId = requireESignatureAccountId(row, accountCode, eSignatureAccountId);
return probe(accountCode, '/v2.1/accounts/' + targetAccountId);
}
@AuraEnabled(cacheable=false)
public static List<TemplateSummary> listTemplates(String accountCode) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
String targetAccountId = requireESignatureAccountId(row, accountCode, null);
ApiResponse response = probe(accountCode, '/v2.1/accounts/' + targetAccountId + '/templates');
if (!response.success) {
throw new AuraHandledException('eSignature API Error (HTTP ' + response.statusCode + '): ' + response.responseBody);
}
return parseTemplateList(response.responseBody);
}
@AuraEnabled(cacheable=false)
public static List<EnvelopeSummary> listEnvelopes(String accountCode, String fromDate) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
String targetAccountId = requireESignatureAccountId(row, accountCode, null);
String normalizedFromDate = String.isBlank(fromDate)
? DateTime.newInstanceGMT(Date.today().addDays(-30), Time.newInstance(0, 0, 0, 0)).formatGMT('yyyy-MM-dd')
: fromDate.trim();
ApiResponse response = probe(
accountCode,
'/v2.1/accounts/' + targetAccountId + '/envelopes?from_date=' + EncodingUtil.urlEncode(normalizedFromDate, 'UTF-8')
);
if (!response.success) {
throw new AuraHandledException('eSignature API Error (HTTP ' + response.statusCode + '): ' + response.responseBody);
}
return parseEnvelopeList(response.responseBody);
}
@TestVisible
private static List<ESignatureAccountSummary> parseAccountList(String body) {
Object root = JSON.deserializeUntyped(body);
List<Object> records = new List<Object>();
if (root instanceof List<Object>) {
records = (List<Object>) root;
} else if (root instanceof Map<String, Object>) {
Object loginAccounts = ((Map<String, Object>) root).get('loginAccounts');
if (loginAccounts instanceof List<Object>) {
records = (List<Object>) loginAccounts;
}
Object accounts = ((Map<String, Object>) root).get('accounts');
if (records.isEmpty() && accounts instanceof List<Object>) {
records = (List<Object>) accounts;
}
}
List<ESignatureAccountSummary> summaries = new List<ESignatureAccountSummary>();
for (Object record : records) {
if (!(record instanceof Map<String, Object>)) {
continue;
}
Map<String, Object> row = (Map<String, Object>) record;
ESignatureAccountSummary summary = new ESignatureAccountSummary();
summary.accountId = firstString(row, new List<String>{ 'accountId', 'account_id' });
summary.accountName = firstString(row, new List<String>{ 'accountName', 'account_name', 'name' });
summary.baseUri = firstString(row, new List<String>{ 'baseUri', 'base_uri', 'baseUrl', 'base_url' });
summary.isDefault = parseBoolean(row.get('isDefault'));
summary.rawJson = JSON.serialize(row);
summaries.add(summary);
}
return summaries;
}
@TestVisible
private static List<TemplateSummary> parseTemplateList(String body) {
List<Object> records = extractCollection(body, new List<String>{ 'envelopeTemplates', 'templates' });
List<TemplateSummary> summaries = new List<TemplateSummary>();
for (Object record : records) {
if (!(record instanceof Map<String, Object>)) {
continue;
}
Map<String, Object> row = (Map<String, Object>) record;
TemplateSummary summary = new TemplateSummary();
summary.templateId = firstString(row, new List<String>{ 'templateId', 'template_id' });
summary.name = firstString(row, new List<String>{ 'name' });
summary.description = firstString(row, new List<String>{ 'description' });
summary.shared = firstString(row, new List<String>{ 'shared' });
summary.lastModified = firstString(row, new List<String>{ 'lastModified', 'last_modified', 'lastModifiedDateTime' });
summary.rawJson = JSON.serialize(row);
summaries.add(summary);
}
return summaries;
}
@TestVisible
private static List<EnvelopeSummary> parseEnvelopeList(String body) {
List<Object> records = extractCollection(body, new List<String>{ 'envelopes' });
List<EnvelopeSummary> summaries = new List<EnvelopeSummary>();
for (Object record : records) {
if (!(record instanceof Map<String, Object>)) {
continue;
}
Map<String, Object> row = (Map<String, Object>) record;
EnvelopeSummary summary = new EnvelopeSummary();
summary.envelopeId = firstString(row, new List<String>{ 'envelopeId', 'envelope_id' });
summary.emailSubject = firstString(row, new List<String>{ 'emailSubject', 'email_subject' });
summary.status = firstString(row, new List<String>{ 'status' });
summary.createdDateTime = firstString(row, new List<String>{ 'createdDateTime', 'created_datetime' });
summary.sentDateTime = firstString(row, new List<String>{ 'sentDateTime', 'sent_datetime' });
summary.completedDateTime = firstString(row, new List<String>{ 'completedDateTime', 'completed_datetime' });
summary.rawJson = JSON.serialize(row);
summaries.add(summary);
}
return summaries;
}
@TestVisible
private static String buildEndpoint(String relativePath, String namedCredential) {
if (String.isBlank(namedCredential)) {
throw new AuraHandledException('No eSignature named credential is configured for this account.');
}
return 'callout:' + namedCredential + normalizePath(relativePath);
}
private static String normalizePath(String relativePath) {
if (String.isBlank(relativePath)) {
throw new AuraHandledException('A relative path is required.');
}
return relativePath.startsWith('/') ? relativePath : '/' + relativePath;
}
private static CLM_Account_Setting__mdt requireAccountSetting(String accountCode) {
String normalizedCode = String.isBlank(accountCode) ? null : accountCode.trim();
List<CLM_Account_Setting__mdt> rows = new List<CLM_Account_Setting__mdt>();
if (String.isNotBlank(normalizedCode)) {
rows = [
SELECT DeveloperName,
Account_Code__c,
Account_Display_Name__c,
Environment_Code__c,
ESignature_Auth_Named_Credential__c,
ESignature_Rest_Named_Credential__c,
ESignature_Account_Id__c,
Active__c
FROM CLM_Account_Setting__mdt
WHERE Active__c = true
AND DeveloperName = :normalizedCode
LIMIT 1
];
if (rows.isEmpty()) {
rows = [
SELECT DeveloperName,
Account_Code__c,
Account_Display_Name__c,
Environment_Code__c,
ESignature_Auth_Named_Credential__c,
ESignature_Rest_Named_Credential__c,
ESignature_Account_Id__c,
Active__c
FROM CLM_Account_Setting__mdt
WHERE Active__c = true
AND Account_Code__c = :normalizedCode
LIMIT 1
];
}
}
if (rows.isEmpty()) {
throw new AuraHandledException('No active CLM account setting was found for ' + accountCode + '.');
}
CLM_Account_Setting__mdt row = rows[0];
if (String.isBlank(row.ESignature_Rest_Named_Credential__c)) {
throw new AuraHandledException('No eSignature named credential is configured for ' + accountCode + '.');
}
return row;
}
private static String authNamedCredential(CLM_Account_Setting__mdt row) {
return String.isNotBlank(row.ESignature_Auth_Named_Credential__c)
? row.ESignature_Auth_Named_Credential__c
: row.ESignature_Rest_Named_Credential__c;
}
private static String requireESignatureAccountId(CLM_Account_Setting__mdt row, String accountCode, String eSignatureAccountId) {
String targetAccountId = String.isNotBlank(eSignatureAccountId) ? eSignatureAccountId : row.ESignature_Account_Id__c;
if (String.isBlank(targetAccountId)) {
throw new AuraHandledException('No eSignature account id is configured for ' + accountCode + '.');
}
return targetAccountId;
}
private static List<Object> extractCollection(String body, List<String> keys) {
Object root = JSON.deserializeUntyped(body);
if (root instanceof List<Object>) {
return (List<Object>) root;
}
if (root instanceof Map<String, Object>) {
Map<String, Object> source = (Map<String, Object>) root;
for (String key : keys) {
Object records = source.get(key);
if (records instanceof List<Object>) {
return (List<Object>) records;
}
}
}
return new List<Object>();
}
private static String firstString(Map<String, Object> source, List<String> keys) {
for (String key : keys) {
Object value = source.get(key);
if (value != null) {
String text = String.valueOf(value);
if (String.isNotBlank(text)) {
return text;
}
}
}
return null;
}
private static Boolean parseBoolean(Object value) {
if (value == null) {
return false;
}
if (value instanceof Boolean) {
return (Boolean) value;
}
return String.valueOf(value).toLowerCase() == 'true';
}
}

View File

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

View File

@ -0,0 +1,150 @@
@IsTest
private class DocusignESignatureServiceTest {
private class ESignatureMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/oauth/userinfo')) {
res.setStatusCode(200);
res.setBody('{"sub":"d9aab149-ff54-408c-a748-baa4b56e2fcd","accounts":[{"account_id":"12345678","account_name":"Demo eSignature Account","base_uri":"https://demo.docusign.net","is_default":true}]}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/templates')) {
res.setStatusCode(200);
res.setBody('{"envelopeTemplates":[{"templateId":"tmpl-001","name":"Review Letter","description":"Appraiser review letter template","shared":"true","lastModified":"2026-04-08T12:00:00Z"},{"templateId":"tmpl-002","name":"Alternate Review Letter","description":"Alternate version","shared":"false","lastModified":"2026-04-07T12:00:00Z"}]}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/envelopes?from_date=')) {
res.setStatusCode(200);
res.setBody('{"envelopes":[{"envelopeId":"env-001","emailSubject":"Appraiser Review","status":"completed","createdDateTime":"2026-04-01T10:00:00Z","sentDateTime":"2026-04-01T10:05:00Z","completedDateTime":"2026-04-01T10:15:00Z"},{"envelopeId":"env-002","emailSubject":"Second Review","status":"sent","createdDateTime":"2026-04-02T09:00:00Z","sentDateTime":"2026-04-02T09:05:00Z"}]}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/v2.1/accounts/12345678')) {
res.setStatusCode(200);
res.setBody('{"accountId":"12345678","accountName":"Demo eSignature Account","status":"active"}');
return res;
}
if (req.getMethod() == 'GET' && req.getEndpoint().contains('/v2.1/login_information')) {
res.setStatusCode(200);
res.setBody('{"loginAccounts":[{"accountId":"12345678","name":"Demo eSignature Account","baseUrl":"https://demo.docusign.net/restapi/v2.1/accounts/12345678","isDefault":"true"},{"accountId":"87654321","name":"Secondary Demo Account","baseUrl":"https://demo.docusign.net/restapi/v2.1/accounts/87654321","isDefault":"false"}]}');
return res;
}
res.setStatusCode(404);
res.setBody('{"message":"Not Found"}');
return res;
}
}
@IsTest
static void returnsConfiguredAccountInfo() {
Test.startTest();
DocusignESignatureService.ESignatureAccountConfig config = DocusignESignatureService.getAccountConfig('DTC_CLM_Demo');
Test.stopTest();
System.assertEquals('DTC_CLM_Demo', config.accountCode);
System.assertEquals('DTC CLM Demo', config.accountDisplayName);
System.assertEquals('AcctDemo_NamedCreds', config.eSignatureAuthNamedCredential);
System.assertEquals('Esignature_Demo_NamedCreds', config.eSignatureRestNamedCredential);
}
@IsTest
static void listsAccountsFromEsignatureApi() {
Test.setMock(HttpCalloutMock.class, new ESignatureMock());
Test.startTest();
List<DocusignESignatureService.ESignatureAccountSummary> accounts = DocusignESignatureService.listAccounts('DTC_CLM_Demo');
Test.stopTest();
System.assertEquals(2, accounts.size());
System.assertEquals('12345678', accounts[0].accountId);
System.assertEquals('Demo eSignature Account', accounts[0].accountName);
System.assertEquals('https://demo.docusign.net/restapi/v2.1/accounts/12345678', accounts[0].baseUri);
System.assertEquals(true, accounts[0].isDefault);
}
@IsTest
static void getsLoginInformationUsingWorkingEndpoint() {
Test.setMock(HttpCalloutMock.class, new ESignatureMock());
Test.startTest();
DocusignESignatureService.ApiResponse response = DocusignESignatureService.getLoginInformation('DTC_CLM_Demo');
Test.stopTest();
System.assertEquals(true, response.success);
System.assertEquals(200, response.statusCode);
System.assert(response.responseBody.contains('loginAccounts'));
}
@IsTest
static void getsUserInfoUsingAuthNamedCredential() {
Test.setMock(HttpCalloutMock.class, new ESignatureMock());
Test.startTest();
DocusignESignatureService.ApiResponse response = DocusignESignatureService.getUserInfo('DTC_CLM_Demo');
Test.stopTest();
System.assertEquals(true, response.success);
System.assertEquals(200, response.statusCode);
System.assertEquals('/oauth/userinfo', response.requestPath);
System.assert(response.responseBody.contains('"accounts"'));
}
@IsTest
static void getsAccountInformationUsingConfiguredAccountId() {
Test.setMock(HttpCalloutMock.class, new ESignatureMock());
Test.startTest();
DocusignESignatureService.ApiResponse response = DocusignESignatureService.getAccountInformation(
'DTC_CLM_Demo',
'12345678'
);
Test.stopTest();
System.assertEquals(true, response.success);
System.assertEquals(200, response.statusCode);
System.assert(response.responseBody.contains('Demo eSignature Account'));
}
@IsTest
static void listsTemplatesUsingConfiguredAccountId() {
Test.setMock(HttpCalloutMock.class, new ESignatureMock());
Test.startTest();
List<DocusignESignatureService.TemplateSummary> templates = DocusignESignatureService.listTemplates('DTC_CLM_Demo');
Test.stopTest();
System.assertEquals(2, templates.size());
System.assertEquals('tmpl-001', templates[0].templateId);
System.assertEquals('Review Letter', templates[0].name);
}
@IsTest
static void listsRecentEnvelopesUsingConfiguredAccountId() {
Test.setMock(HttpCalloutMock.class, new ESignatureMock());
Test.startTest();
List<DocusignESignatureService.EnvelopeSummary> envelopes = DocusignESignatureService.listEnvelopes(
'DTC_CLM_Demo',
'2026-04-01'
);
Test.stopTest();
System.assertEquals(2, envelopes.size());
System.assertEquals('env-001', envelopes[0].envelopeId);
System.assertEquals('completed', envelopes[0].status);
}
@IsTest
static void buildsEndpointWithNamedCredential() {
System.assertEquals(
'callout:Esignature_Demo_NamedCreds/v2.1/accounts',
DocusignESignatureService.buildEndpoint('/v2.1/accounts', 'Esignature_Demo_NamedCreds')
);
}
}

View File

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