From d26fbf056bb81c7a8ebc594951409b6b486b4a84 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 16:01:23 +0100 Subject: [PATCH 01/19] - Refactored image handling logic in DocGenService to utilize a new ImageRenderSpec class for improved clarity and maintainability. - Updated README to reflect new image size syntax options, including fixed and max constraints for image dimensions. - Improved documentation for image tag specifications to aid user understanding. --- README.md | 16 +- .../main/default/classes/DocGenService.cls | 393 ++++++++++++++---- 2 files changed, 335 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 5f6d340..adcc5aa 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,23 @@ QR codes support up to **255 characters** — enough for a full Salesforce text | Tag | What It Does | |-----|-------------| -| `{%Logo__c:200x60}` | Insert image at 200x60px from ContentVersion ID | -| `{%Photo__c}` | Insert image at default size (4" x 3") | +| `{%Logo__c}` | Insert image at original size (from image metadata when available) | +| `{%Logo__c:200x60}` | Fixed size: width 200px, height 60px | +| `{%Logo__c:100%x}` | Fixed width: 100% of page content width, keep aspect ratio | +| `{%Logo__c:x50%}` | Fixed height: 50% of page content height, keep aspect ratio | +| `{%Logo__c:m100%x}` | Max width: shrink to fit page width, never upscale | +| `{%Logo__c:xm50%}` | Max height: shrink to 50% page height, never upscale | +| `{%Logo__c:m100%xm50%}` | Max width + max height constraints (shrink-to-fit) | Store a ContentVersion ID (starts with `068`) in a text field. Works in Word templates — PDF and DOCX output. +Image size syntax is `:widthxheight` with optional tokens: + +- `300` or `300px` = pixels +- `100%` = percentage of current page content area +- `m` prefix = max constraint (shrink only, CSS-style max behavior) +- blank side is allowed: `100%x`, `x100%`, `m100%x`, `xm50%` + ### Rich Text Fields Rich text fields (`{Description}`, `{Notes__c}`) render with full formatting (bold, italic, lists) in both PDF and DOCX output. diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 07e9faa..4242144 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -1230,28 +1230,12 @@ public with sharing class DocGenService { cursor = tagClose + 1; continue; } - String imageSpec = tagContent.substring(1).trim(); - String imageField = imageSpec; - Integer imgWidthPx = null; - Integer imgHeightPx = null; - - // Parse optional size suffix :WxH (pixels) - if (imageSpec.contains(':')) { - List parts = imageSpec.split(':', 2); - imageField = parts[0].trim(); - String sizeStr = parts[1].trim().toLowerCase(); - if (sizeStr.contains('x')) { - List dims = sizeStr.split('x'); - if (dims.size() == 2 && dims[0].isNumeric() && dims[1].isNumeric()) { - imgWidthPx = Integer.valueOf(dims[0]); - imgHeightPx = Integer.valueOf(dims[1]); - } - } - } - - Object val = resolveValue(data, imageField); + String imageTagSpec = tagContent.substring(1).trim(); + ImageRenderSpec imageRenderSpec = parseImageTagSpec(imageTagSpec); + Object val = resolveValue(data, imageRenderSpec.imageField); if (val != null) { - String imageXml = buildImageXml(String.valueOf(val), imgWidthPx, imgHeightPx); + imageRenderSpec.fieldValue = String.valueOf(val); + String imageXml = buildImageXml(imageRenderSpec); if (imageXml != null) { output += imageXml; } @@ -2059,126 +2043,169 @@ public with sharing class DocGenService { return tag.substring(pos, end2); } + private class ImageDimensionSpec { + Boolean isMaxConstraint; + Integer percentValue; + Integer pixelValue; + } + + private class ImageRenderSpec { + String imageField; + String fieldValue; + String relationshipId; + String fileName; + String fileExtension; + String sourceUrl; + String contentVersionImageUrl; + Blob imageBlobData; + + Integer widthPx; + Integer heightPx; + Integer widthPercent; + Integer heightPercent; + Integer maxWidthPx; + Integer maxHeightPx; + Integer maxWidthPercent; + Integer maxHeightPercent; + } + private static String buildImageXml(String fieldValue) { - return buildImageXml(fieldValue, null, null); + ImageRenderSpec imageRenderSpec = new ImageRenderSpec(); + imageRenderSpec.fieldValue = fieldValue; + return buildImageXml(imageRenderSpec); } private static String buildImageXml(String fieldValue, Integer widthPx, Integer heightPx) { - if (String.isBlank(fieldValue)) { + ImageRenderSpec imageRenderSpec = new ImageRenderSpec(); + imageRenderSpec.fieldValue = fieldValue; + imageRenderSpec.widthPx = widthPx; + imageRenderSpec.heightPx = heightPx; + return buildImageXml(imageRenderSpec); + } + + private static String buildImageXml(ImageRenderSpec imageRenderSpec) { + if (imageRenderSpec == null || String.isBlank(imageRenderSpec.fieldValue)) { return null; } - Blob imageBlob = null; String ext = 'png'; - String cvImageUrl = null; try { // 1. Rich text HTML containing tags - if (fieldValue.containsIgnoreCase(' 100 && !fieldValue.contains('/') && !fieldValue.contains(' ')) { - imageBlob = EncodingUtil.base64Decode(fieldValue); + else if (imageRenderSpec.fieldValue.length() > 100 && !imageRenderSpec.fieldValue.contains('/') && !imageRenderSpec.fieldValue.contains(' ')) { + imageRenderSpec.imageBlobData = EncodingUtil.base64Decode(imageRenderSpec.fieldValue); } // 4. ContentVersion ID (starts with 068) — works for both DOCX (blob) and PDF (URL) - else if (fieldValue.startsWith('068') && fieldValue.length() >= 15) { + else if (imageRenderSpec.fieldValue.startsWith('068') && imageRenderSpec.fieldValue.length() >= 15) { if (currentOutputFormat == 'PDF') { // PDF path: only need the URL — Blob.toPdf() fetches images server-side. // Skip loading the blob entirely to save heap. - List cvs = [SELECT Id, FileExtension FROM ContentVersion WHERE Id = :fieldValue WITH SYSTEM_MODE LIMIT 1]; + List cvs = [SELECT Id, FileExtension FROM ContentVersion WHERE Id = :imageRenderSpec.fieldValue WITH SYSTEM_MODE LIMIT 1]; if (!cvs.isEmpty()) { if (cvs[0].FileExtension != null) { ext = cvs[0].FileExtension.toLowerCase(); } - cvImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; + imageRenderSpec.contentVersionImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; } } else { // DOCX path: need the blob for ZIP embedding - List cvs = [SELECT Id, VersionData, FileExtension FROM ContentVersion WHERE Id = :fieldValue WITH SYSTEM_MODE LIMIT 1]; + List cvs = [SELECT Id, VersionData, FileExtension FROM ContentVersion WHERE Id = :imageRenderSpec.fieldValue WITH SYSTEM_MODE LIMIT 1]; if (!cvs.isEmpty()) { - imageBlob = cvs[0].VersionData; + imageRenderSpec.imageBlobData = cvs[0].VersionData; if (cvs[0].FileExtension != null) { ext = cvs[0].FileExtension.toLowerCase(); } - cvImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; + imageRenderSpec.contentVersionImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; } } } // 5. Salesforce file URL containing refid (rich text image reference) - else if (fieldValue.containsIgnoreCase('refid=')) { - String refId = fieldValue.substringAfterLast('refid=').substringBefore('&'); - if (String.isBlank(refId)) { refId = fieldValue.substringAfterLast('refid='); } - imageBlob = fetchSalesforceImage(refId); + else if (imageRenderSpec.fieldValue.containsIgnoreCase('refid=')) { + String refId = imageRenderSpec.fieldValue.substringAfterLast('refid=').substringBefore('&'); + if (String.isBlank(refId)) { refId = imageRenderSpec.fieldValue.substringAfterLast('refid='); } + imageRenderSpec.imageBlobData = fetchSalesforceImage(refId); } // 6. Salesforce servlet/sfc URL - else if (fieldValue.containsIgnoreCase('/servlet/') || fieldValue.containsIgnoreCase('/sfc/')) { - String docId = fieldValue.substringAfterLast('/'); + else if (imageRenderSpec.fieldValue.containsIgnoreCase('/servlet/') || imageRenderSpec.fieldValue.containsIgnoreCase('/sfc/')) { + String docId = imageRenderSpec.fieldValue.substringAfterLast('/'); if (docId.contains('?')) { docId = docId.substringBefore('?'); } - imageBlob = fetchSalesforceImage(docId); + imageRenderSpec.imageBlobData = fetchSalesforceImage(docId); } // 7. Generic HTTPS URL — can't fetch without session ID (AppExchange safe). // For {%ImageField} tags, users should use ContentVersion IDs or data URIs. - else if (fieldValue.startsWith('https://')) { + else if (imageRenderSpec.fieldValue.startsWith('https://')) { // External URL images cannot be fetched without session ID — users should use ContentVersion IDs or data URIs } // If no blob but we have a URL, store it for PDF rendering // (Blob.toPdf resolves Salesforce URLs natively via the VF engine) - String sourceUrl = cvImageUrl; // Use CV URL if already resolved (PDF path) - if (imageBlob == null && sourceUrl == null) { + imageRenderSpec.sourceUrl = imageRenderSpec.contentVersionImageUrl; // Use CV URL if already resolved (PDF path) + if (imageRenderSpec.imageBlobData == null && imageRenderSpec.sourceUrl == null) { // Try to extract a URL from the field value for PDF - if (fieldValue.containsIgnoreCase(' imgEntry = new Map{ - 'relId' => relId, - 'fileName' => fileName + 'relId' => imageRenderSpec.relationshipId, + 'fileName' => imageRenderSpec.fileName }; - if (imageBlob != null) { - imgEntry.put('blob', imageBlob); + if (imageRenderSpec.imageBlobData != null) { + imgEntry.put('blob', imageRenderSpec.imageBlobData); } // Prefer CV URL (committed, works for PDF), fall back to extracted source URL - if (cvImageUrl != null) { - imgEntry.put('url', cvImageUrl); - } else if (sourceUrl != null) { - imgEntry.put('url', sourceUrl); + if (imageRenderSpec.contentVersionImageUrl != null) { + imgEntry.put('url', imageRenderSpec.contentVersionImageUrl); + } else if (imageRenderSpec.sourceUrl != null) { + imgEntry.put('url', imageRenderSpec.sourceUrl); } pendingImages.add(imgEntry); // Use DrawingML format () for broad compatibility. // VML () works in Word but docx-preview (used for client-side PDF) cannot render it. // DrawingML is the modern OOXML standard supported by Word, docx-preview, and Salesforce rendition API. - // EMU conversion: 1px at 96dpi = 9525 EMU, 1pt = 12700 EMU. Default: 4in x 3in - Integer widthEmu = (widthPx != null) ? (widthPx * 9525) : (288 * 12700); - Integer heightEmu = (heightPx != null) ? (heightPx * 9525) : (216 * 12700); + List intrinsicSizeEmu = getImageDimensionsInEmu(imageRenderSpec.imageBlobData, imageRenderSpec.fileExtension); + Integer baseWidthEmu = (intrinsicSizeEmu != null && intrinsicSizeEmu.size() == 2) ? intrinsicSizeEmu[0] : (288 * 12700); + Integer baseHeightEmu = (intrinsicSizeEmu != null && intrinsicSizeEmu.size() == 2) ? intrinsicSizeEmu[1] : (216 * 12700); + + Integer[] scaledDimensionsEmu = scaleImageDimensionsEmu( + baseWidthEmu, + baseHeightEmu, + imageRenderSpec + ); + Integer widthEmu = scaledDimensionsEmu[0]; + Integer heightEmu = scaledDimensionsEmu[1]; return '' + '' + @@ -2194,7 +2221,7 @@ public with sharing class DocGenService { '' + '' + '' + - '' + '' + '' + @@ -2217,6 +2244,228 @@ public with sharing class DocGenService { } } + /** + * Parses a size token for image specs: + * - 300, 300px, 100% + * - m300, m300px, m100% (max constraints) + */ + private static ImageRenderSpec parseImageTagSpec(String imageTagSpec) { + ImageRenderSpec imageRenderSpec = new ImageRenderSpec(); + imageRenderSpec.imageField = imageTagSpec; + if (String.isBlank(imageTagSpec)) { + return imageRenderSpec; + } + if (!imageTagSpec.contains(':')) { + return imageRenderSpec; + } + + List tagParts = imageTagSpec.split(':', 2); + imageRenderSpec.imageField = tagParts[0].trim(); + String sizeToken = tagParts[1].trim().toLowerCase(); + if (!sizeToken.contains('x')) { + return imageRenderSpec; + } + + Integer separatorIndex = sizeToken.indexOf('x'); + if (separatorIndex < 0) { + return imageRenderSpec; + } + String widthToken = sizeToken.substring(0, separatorIndex).trim(); + String heightToken = sizeToken.substring(separatorIndex + 1).trim(); + + ImageDimensionSpec widthSpec = parseImageDimensionSpec(widthToken); + ImageDimensionSpec heightSpec = parseImageDimensionSpec(heightToken); + if (widthSpec != null) { + if (widthSpec.isMaxConstraint) { + imageRenderSpec.maxWidthPx = widthSpec.pixelValue; + imageRenderSpec.maxWidthPercent = widthSpec.percentValue; + } else { + imageRenderSpec.widthPx = widthSpec.pixelValue; + imageRenderSpec.widthPercent = widthSpec.percentValue; + } + } + if (heightSpec != null) { + if (heightSpec.isMaxConstraint) { + imageRenderSpec.maxHeightPx = heightSpec.pixelValue; + imageRenderSpec.maxHeightPercent = heightSpec.percentValue; + } else { + imageRenderSpec.heightPx = heightSpec.pixelValue; + imageRenderSpec.heightPercent = heightSpec.percentValue; + } + } + return imageRenderSpec; + } + + private static ImageDimensionSpec parseImageDimensionSpec(String rawDimensionToken) { + if (String.isBlank(rawDimensionToken)) { + return null; + } + String normalizedToken = rawDimensionToken.trim().toLowerCase(); + Boolean isMaxConstraint = normalizedToken.startsWith('m'); + if (isMaxConstraint) { + normalizedToken = normalizedToken.substring(1).trim(); + } + if (String.isBlank(normalizedToken)) { + return null; + } + + Integer percentValue = null; + Integer pixelValue = null; + if (normalizedToken.endsWith('%')) { + String numericPart = normalizedToken.substring(0, normalizedToken.length() - 1).trim(); + if (numericPart.isNumeric()) { + percentValue = Integer.valueOf(numericPart); + } + } else if (normalizedToken.endsWith('px')) { + String numericPart = normalizedToken.substring(0, normalizedToken.length() - 2).trim(); + if (numericPart.isNumeric()) { + pixelValue = Integer.valueOf(numericPart); + } + } else if (normalizedToken.isNumeric()) { + // Bare numbers are interpreted as pixels. + pixelValue = Integer.valueOf(normalizedToken); + } + + if (percentValue == null && pixelValue == null) { + return null; + } + ImageDimensionSpec imageDimensionSpec = new ImageDimensionSpec(); + imageDimensionSpec.isMaxConstraint = isMaxConstraint; + imageDimensionSpec.percentValue = percentValue; + imageDimensionSpec.pixelValue = pixelValue; + return imageDimensionSpec; + } + + /** Parse hex string (e.g. "00A5") to Integer. */ + private static Integer parseHexToInt(String hexStr) { + if (String.isBlank(hexStr)) { + return 0; + } + String hexChars = '0123456789ABCDEF'; + Integer parsedValue = 0; + for (Integer positionIndex = 0; positionIndex < hexStr.length(); positionIndex++) { + Integer digitValue = hexChars.indexOf(hexStr.substring(positionIndex, positionIndex + 1).toUpperCase()); + if (digitValue < 0) { + return 0; + } + parsedValue = (parsedValue * 16) + digitValue; + } + return parsedValue; + } + + /** Read image dimensions from blob (JPEG or PNG header); return {cxEmu, cyEmu} or null. */ + private static List getImageDimensionsInEmu(Blob imageBlobValue, String fileExtension) { + if (imageBlobValue == null || imageBlobValue.size() < 24) { + return null; + } + String imageHex = EncodingUtil.convertToHex(imageBlobValue); + if (String.isBlank(imageHex) || imageHex.length() < 64) { + return null; + } + String imageHexUpper = imageHex.toUpperCase(); + String extensionLower = fileExtension != null ? fileExtension.toLowerCase() : ''; + Integer widthPx = null; + Integer heightPx = null; + try { + Boolean looksPng = extensionLower == 'png' || (imageHexUpper.length() >= 16 && imageHexUpper.substring(0, 16) == '89504E470D0A1A0A'); + if (looksPng) { + Integer ihdrIndex = imageHexUpper.indexOf('49484452'); + if (ihdrIndex >= 0 && ihdrIndex + 24 <= imageHexUpper.length()) { + Integer pngWidth = parseHexToInt(imageHexUpper.substring(ihdrIndex + 8, ihdrIndex + 16)); + Integer pngHeight = parseHexToInt(imageHexUpper.substring(ihdrIndex + 16, ihdrIndex + 24)); + if (pngWidth > 0 && pngHeight > 0) { + widthPx = pngWidth; + heightPx = pngHeight; + } + } + } + Boolean looksJpeg = extensionLower == 'jpg' || extensionLower == 'jpeg' || (imageHexUpper.length() >= 4 && imageHexUpper.substring(0, 4) == 'FFD8'); + if ((widthPx == null || heightPx == null) && looksJpeg) { + Integer sofIndex = imageHexUpper.indexOf('FFC0'); + if (sofIndex >= 0 && sofIndex + 18 <= imageHexUpper.length()) { + heightPx = parseHexToInt(imageHexUpper.substring(sofIndex + 10, sofIndex + 14)); + widthPx = parseHexToInt(imageHexUpper.substring(sofIndex + 14, sofIndex + 18)); + } + } + if (widthPx == null || heightPx == null || widthPx <= 0 || heightPx <= 0) { + return null; + } + Integer emuPerPixel = 9525; + return new List{widthPx * emuPerPixel, heightPx * emuPerPixel}; + } catch (Exception imageDimensionException) { + return null; + } + } + + /** + * Applies fixed and max sizing constraints to image dimensions in EMU. + * Fixed width/height may use px or %; max constraints only shrink. + */ + private static Integer[] scaleImageDimensionsEmu(Integer baseWidthEmu, Integer baseHeightEmu, ImageRenderSpec imageRenderSpec) { + Integer pageContentWidthEmu = 5943600; // 6.5in + Integer pageContentHeightEmu = 8229600; // 9in + + Integer resolvedWidthEmu = baseWidthEmu; + Integer resolvedHeightEmu = baseHeightEmu; + + Integer fixedWidthEmu = imageRenderSpec.widthPx != null ? (imageRenderSpec.widthPx * 9525) : null; + if (fixedWidthEmu == null && imageRenderSpec.widthPercent != null) { + fixedWidthEmu = (pageContentWidthEmu * imageRenderSpec.widthPercent) / 100; + } + Integer fixedHeightEmu = imageRenderSpec.heightPx != null ? (imageRenderSpec.heightPx * 9525) : null; + if (fixedHeightEmu == null && imageRenderSpec.heightPercent != null) { + fixedHeightEmu = (pageContentHeightEmu * imageRenderSpec.heightPercent) / 100; + } + + if (fixedWidthEmu != null && fixedWidthEmu > 0 && fixedHeightEmu != null && fixedHeightEmu > 0) { + resolvedWidthEmu = fixedWidthEmu; + resolvedHeightEmu = fixedHeightEmu; + } else if (fixedWidthEmu != null && fixedWidthEmu > 0 && baseWidthEmu > 0) { + Decimal widthScaleFactor = ((Decimal)fixedWidthEmu) / ((Decimal)baseWidthEmu); + resolvedWidthEmu = fixedWidthEmu; + resolvedHeightEmu = (((Decimal)baseHeightEmu) * widthScaleFactor).intValue(); + } else if (fixedHeightEmu != null && fixedHeightEmu > 0 && baseHeightEmu > 0) { + Decimal heightScaleFactor = ((Decimal)fixedHeightEmu) / ((Decimal)baseHeightEmu); + resolvedHeightEmu = fixedHeightEmu; + resolvedWidthEmu = (((Decimal)baseWidthEmu) * heightScaleFactor).intValue(); + } + + Integer maxWidthEmu = imageRenderSpec.maxWidthPx != null ? (imageRenderSpec.maxWidthPx * 9525) : null; + if (maxWidthEmu == null && imageRenderSpec.maxWidthPercent != null) { + maxWidthEmu = (pageContentWidthEmu * imageRenderSpec.maxWidthPercent) / 100; + } + Integer maxHeightEmu = imageRenderSpec.maxHeightPx != null ? (imageRenderSpec.maxHeightPx * 9525) : null; + if (maxHeightEmu == null && imageRenderSpec.maxHeightPercent != null) { + maxHeightEmu = (pageContentHeightEmu * imageRenderSpec.maxHeightPercent) / 100; + } + + Decimal maxScaleFactor = 1; + if (maxWidthEmu != null && maxWidthEmu > 0 && resolvedWidthEmu > maxWidthEmu && resolvedWidthEmu > 0) { + Decimal widthMaxScale = ((Decimal)maxWidthEmu) / ((Decimal)resolvedWidthEmu); + if (widthMaxScale < maxScaleFactor) { + maxScaleFactor = widthMaxScale; + } + } + if (maxHeightEmu != null && maxHeightEmu > 0 && resolvedHeightEmu > maxHeightEmu && resolvedHeightEmu > 0) { + Decimal heightMaxScale = ((Decimal)maxHeightEmu) / ((Decimal)resolvedHeightEmu); + if (heightMaxScale < maxScaleFactor) { + maxScaleFactor = heightMaxScale; + } + } + if (maxScaleFactor < 1) { + resolvedWidthEmu = (((Decimal)resolvedWidthEmu) * maxScaleFactor).intValue(); + resolvedHeightEmu = (((Decimal)resolvedHeightEmu) * maxScaleFactor).intValue(); + } + + if (resolvedWidthEmu == null || resolvedWidthEmu <= 0) { + resolvedWidthEmu = 288 * 12700; + } + if (resolvedHeightEmu == null || resolvedHeightEmu <= 0) { + resolvedHeightEmu = 216 * 12700; + } + return new Integer[]{resolvedWidthEmu, resolvedHeightEmu}; + } + /** * Extracts the first image from rich text HTML. * Handles data URIs, Salesforce servlet URLs (via session-based auth), and refid references. From 691d879eb882e508f0ead87cb02fbe796d8ed3cc Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 16:07:04 +0100 Subject: [PATCH 02/19] make aurahandledExceptions readable in dev console --- .../default/classes/DocGenBulkController.cls | 16 ++++---- .../main/default/classes/DocGenController.cls | 32 +++++++-------- .../default/classes/DocGenDataRetriever.cls | 20 +++++----- .../main/default/classes/DocGenService.cls | 39 ++++++++++++++++--- .../default/classes/DocGenSetupController.cls | 2 +- .../default/classes/DocGenTemplateManager.cls | 6 +-- 6 files changed, 72 insertions(+), 43 deletions(-) diff --git a/force-app/main/default/classes/DocGenBulkController.cls b/force-app/main/default/classes/DocGenBulkController.cls index 25a79fc..2592424 100644 --- a/force-app/main/default/classes/DocGenBulkController.cls +++ b/force-app/main/default/classes/DocGenBulkController.cls @@ -20,7 +20,7 @@ public with sharing class DocGenBulkController { if (String.isBlank(objectName)) { return 0; } // Validate object name against Schema to prevent injection if (Schema.getGlobalDescribe().get(objectName) == null) { - throw new AuraHandledException('Invalid or inaccessible object.'); + throw DocGenService.ahe('Invalid or inaccessible object.'); } String sanitizedCondition = sanitizeCondition(condition); String query = 'SELECT Count() FROM ' + objectName; @@ -40,7 +40,7 @@ public with sharing class DocGenBulkController { if (String.isBlank(condition)) { return condition; } // Reject dangerous characters (statement termination, comments) if (condition.contains(';') || condition.contains('--') || condition.contains('/*')) { - throw new AuraHandledException('Invalid filter condition.'); + throw DocGenService.ahe('Invalid filter condition.'); } // Normalize whitespace for keyword matching (tabs, newlines, multiple spaces → single space) String normalized = condition.replaceAll('\\s+', ' ').toUpperCase().trim(); @@ -52,7 +52,7 @@ public with sharing class DocGenBulkController { }; for (String keyword : disallowed) { if (normalized.contains(keyword)) { - throw new AuraHandledException('Invalid filter condition.'); + throw DocGenService.ahe('Invalid filter condition.'); } } return condition; @@ -312,7 +312,7 @@ public with sharing class DocGenBulkController { return job.Id; } catch (Exception e) { - throw new AuraHandledException('Error starting bulk generation job.'); + throw DocGenService.ahe('Error starting bulk generation job.'); } } @@ -358,7 +358,7 @@ public with sharing class DocGenBulkController { String baseObject = job.Template__r.Base_Object_API__c; if (Schema.getGlobalDescribe().get(baseObject) == null) { - throw new AuraHandledException('Invalid base object on job template.'); + throw DocGenService.ahe('Invalid base object on job template.'); } // Query the same records the batch processed @@ -435,7 +435,7 @@ public with sharing class DocGenBulkController { // Package-internal object — CRUD controlled by DocGen permission sets insert sq; // NOPMD ApexCRUDViolation - package-internal custom object; access controlled by DocGen permission sets } catch (Exception e) { - throw new AuraHandledException('Error saving query.'); + throw DocGenService.ahe('Error saving query.'); } } @@ -447,11 +447,11 @@ public with sharing class DocGenBulkController { public static void deleteQuery(Id queryId) { try { if (!Schema.sObjectType.DocGen_Saved_Query__c.isDeletable()) { - throw new AuraHandledException('Insufficient access.'); + throw DocGenService.ahe('Insufficient access.'); } delete new DocGen_Saved_Query__c(Id = queryId); } catch (Exception e) { - throw new AuraHandledException('Error deleting query.'); + throw DocGenService.ahe('Error deleting query.'); } } } \ No newline at end of file diff --git a/force-app/main/default/classes/DocGenController.cls b/force-app/main/default/classes/DocGenController.cls index 66ff6b7..3415102 100644 --- a/force-app/main/default/classes/DocGenController.cls +++ b/force-app/main/default/classes/DocGenController.cls @@ -171,7 +171,7 @@ public with sharing class DocGenController { 'templateType' => result.get('templateType') }; } catch (Exception e) { - throw new AuraHandledException('Error generating document. Please check the template configuration.'); + throw DocGenService.ahe('Error generating document. Please check the template configuration.'); } } @@ -188,7 +188,7 @@ public with sharing class DocGenController { try { return DocGenService.generateDocumentParts(templateId, recordId); } catch (Exception e) { - throw new AuraHandledException('Error generating document parts. Please check the template configuration.'); + throw DocGenService.ahe('Error generating document parts. Please check the template configuration.'); } } @@ -364,7 +364,7 @@ public with sharing class DocGenController { } } } catch (Exception e) { - throw new AuraHandledException('Error saving template. Please verify your inputs and permissions.'); + throw DocGenService.ahe('Error saving template. Please verify your inputs and permissions.'); } } @@ -376,11 +376,11 @@ public with sharing class DocGenController { public static void deleteTemplate(Id templateId) { try { if (!Schema.sObjectType.DocGen_Template__c.isDeletable()) { - throw new AuraHandledException('Insufficient access.'); + throw DocGenService.ahe('Insufficient access.'); } delete new DocGen_Template__c(Id = templateId); } catch (Exception e) { - throw new AuraHandledException('Error deleting template. Please check your permissions.'); + throw DocGenService.ahe('Error deleting template. Please check your permissions.'); } } @@ -479,7 +479,7 @@ public with sharing class DocGenController { }; } } catch (Exception e) { - throw new AuraHandledException('Error generating PDF document. Please check the template configuration.'); + throw DocGenService.ahe('Error generating PDF document. Please check the template configuration.'); } } @@ -776,7 +776,7 @@ public with sharing class DocGenController { SObjectAccessDecision shareDecision = Security.stripInaccessible(AccessType.CREATABLE, new List{ share }); insert shareDecision.getRecords(); } catch (DmlException e) { - throw new AuraHandledException('Error sharing template. Please check permissions.'); + throw DocGenService.ahe(e); } } @@ -788,11 +788,11 @@ public with sharing class DocGenController { public static void removeShare(Id shareId) { try { if (!Schema.sObjectType.DocGen_Template__c.isDeletable()) { - throw new AuraHandledException('Insufficient access.'); + throw DocGenService.ahe('Insufficient access.'); } delete new DocGen_Template__Share(Id = shareId); // NOPMD ApexCRUDViolation - isDeletable() checked above; package-internal share object } catch (DmlException e) { - throw new AuraHandledException('Error removing share. Please check permissions.'); + throw DocGenService.ahe(e); } } @@ -919,19 +919,19 @@ public with sharing class DocGenController { @AuraEnabled public static List> getChildRecordPdfs(Id parentRecordId, String childObject, String lookupField, String filterClause) { if (parentRecordId == null || String.isBlank(childObject) || String.isBlank(lookupField)) { - throw new AuraHandledException('Parent record ID, child object, and lookup field are required.'); + throw DocGenService.ahe('Parent record ID, child object, and lookup field are required.'); } // Validate child object exists in schema SObjectType childType = Schema.getGlobalDescribe().get(childObject); if (childType == null) { - throw new AuraHandledException('Invalid child object: ' + childObject); + throw DocGenService.ahe('Invalid child object: ' + childObject); } // Validate lookup field exists on child object Map fieldMap = childType.getDescribe().fields.getMap(); if (!fieldMap.containsKey(lookupField.toLowerCase())) { - throw new AuraHandledException('Invalid lookup field: ' + lookupField); + throw DocGenService.ahe('Invalid lookup field: ' + lookupField); } // Determine display field — use Name if available, else Id @@ -952,7 +952,7 @@ public with sharing class DocGenController { try { childRecords = Database.query(childQuery, AccessLevel.USER_MODE); // NOPMD ApexSOQLInjection - object/field names validated against Schema.getGlobalDescribe(); runs with USER_MODE } catch (Exception e) { - throw new AuraHandledException('Error querying child records: ' + e.getMessage()); + throw DocGenService.ahe('Error querying child records: ' + e.getMessage()); } if (childRecords.isEmpty()) { @@ -1042,7 +1042,7 @@ public with sharing class DocGenController { @AuraEnabled public static Map importReportConfig(Id reportId) { if (reportId == null) { - throw new AuraHandledException('Report ID is required.'); + throw DocGenService.ahe('Report ID is required.'); } Reports.ReportDescribeResult describe = Reports.ReportManager.describeReport(reportId); @@ -1054,13 +1054,13 @@ public with sharing class DocGenController { String reportTypeLabel = metadata.getReportType().getLabel(); String baseObject = resolveReportBaseObject(reportTypeName, reportTypeLabel); if (baseObject == null) { - throw new AuraHandledException('Could not determine the base object for this report. Report type: ' + reportTypeName); + throw DocGenService.ahe('Could not determine the base object for this report. Report type: ' + reportTypeName); } // Get the field map for normalization SObjectType objType = Schema.getGlobalDescribe().get(baseObject); if (objType == null) { - throw new AuraHandledException('Object not found: ' + baseObject); + throw DocGenService.ahe('Object not found: ' + baseObject); } Map fieldMap = objType.getDescribe().fields.getMap(); diff --git a/force-app/main/default/classes/DocGenDataRetriever.cls b/force-app/main/default/classes/DocGenDataRetriever.cls index e768769..44c1e56 100644 --- a/force-app/main/default/classes/DocGenDataRetriever.cls +++ b/force-app/main/default/classes/DocGenDataRetriever.cls @@ -66,7 +66,7 @@ public with sharing class DocGenDataRetriever { */ public static Map getRecordData(Id recordId, String baseObject, String fieldsConfig) { if (String.isBlank(baseObject) || String.isBlank(fieldsConfig) || recordId == null) { - throw new AuraHandledException('Invalid parameters for data retrieval.'); + throw DocGenService.ahe('Invalid parameters for data retrieval.'); } // JSON config: route by version @@ -87,7 +87,7 @@ public with sharing class DocGenDataRetriever { SObjectType objType = Schema.getGlobalDescribe().get(baseObject); if (objType == null) { - throw new AuraHandledException('Invalid or inaccessible object.'); + throw DocGenService.ahe('Invalid or inaccessible object.'); } Map fieldMap = objType.getDescribe().fields.getMap(); @@ -112,7 +112,7 @@ public with sharing class DocGenDataRetriever { } data = mapSObject(results[0]); } catch (Exception e) { - throw new AuraHandledException('Error executing query. Please verify your template configuration.'); + throw DocGenService.ahe('Error executing query. Please verify your template configuration.'); } // Stitch deeper levels (grandchildren, great-grandchildren) @@ -260,7 +260,7 @@ public with sharing class DocGenDataRetriever { } data = mapSObject(results[0]); } catch (Exception e) { - throw new AuraHandledException('Error executing query. Please verify your template configuration.'); + throw DocGenService.ahe('Error executing query. Please verify your template configuration.'); } // Stitch junction targets (two-hop: junction record → target object) @@ -397,7 +397,7 @@ public with sharing class DocGenDataRetriever { List nodesJson = (List) config.get('nodes'); if (nodesJson == null || nodesJson.isEmpty()) { - throw new AuraHandledException('No query nodes defined.'); + throw DocGenService.ahe('No query nodes defined.'); } for (Object nodeObj : nodesJson) { @@ -442,7 +442,7 @@ public with sharing class DocGenDataRetriever { } } if (root == null) { - throw new AuraHandledException('No root node found in query tree.'); + throw DocGenService.ahe('No root node found in query tree.'); } // Query root: SELECT fields, parentFields FROM object WHERE Id = :recordId @@ -465,7 +465,7 @@ public with sharing class DocGenDataRetriever { } rootData = mapSObject(results[0]); } catch (Exception e) { - throw new AuraHandledException('Error querying root record: ' + e.getMessage()); + throw DocGenService.ahe('Error querying root record: ' + e.getMessage()); } // Walk the tree: process children of each node @@ -490,7 +490,7 @@ public with sharing class DocGenDataRetriever { List nodesJson = (List) config.get('nodes'); if (nodesJson == null || nodesJson.isEmpty()) { - throw new AuraHandledException('No query nodes defined.'); + throw DocGenService.ahe('No query nodes defined.'); } for (Object nodeObj : nodesJson) { @@ -535,7 +535,7 @@ public with sharing class DocGenDataRetriever { } } if (root == null) { - throw new AuraHandledException('No root node found in query tree.'); + throw DocGenService.ahe('No root node found in query tree.'); } // Bulk root query: SELECT fields FROM object WHERE Id IN :recordIds @@ -560,7 +560,7 @@ public with sharing class DocGenDataRetriever { allData.put(rec.Id, mapSObject(rec)); } } catch (Exception e) { - throw new AuraHandledException('Error bulk-querying records: ' + e.getMessage()); + throw DocGenService.ahe('Error bulk-querying records: ' + e.getMessage()); } // Walk the tree in bulk: process children for all root records at once diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 4242144..4e70615 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -1111,8 +1111,10 @@ public with sharing class DocGenService { Integer tagClose = xml.indexOf('}', cursor); if (tagClose == -1) { - output += xml.substring(cursor); - break; + throw new DocGenException( + 'Malformed merge tag: missing closing "}" near position ' + cursor + + '. Check for an unclosed tag in the template.' + ); } String rawTag = xml.substring(cursor + 1, tagClose); @@ -1127,8 +1129,9 @@ public with sharing class DocGenService { String key = tagContent.substring(1).trim(); Integer sectionEnd = findBalancedEnd(xml, tagClose + 1, key); if (sectionEnd == -1) { - output += '{' + rawTag + '}'; - cursor = tagClose + 1; + throw new DocGenException( + 'Malformed loop tag: missing closing "{/' + key + '}" for "{#' + key + '}".' + ); } else { // Default: Content between tags Integer contentStart = tagClose + 1; @@ -1179,7 +1182,15 @@ public with sharing class DocGenService { // Backtrack output to remove the TR prefix already appended Integer overlap = cursor - contentStart; if (overlap > 0) { - output = output.substring(0, output.length() - overlap); + Integer outputLength = output.length(); + if (overlap > outputLength) { + throw new DocGenException( + 'Template loop parsing error for "{#' + key + '}": internal overlap (' + overlap + + ') exceeds output length (' + outputLength + '). ' + + 'This usually indicates malformed or mismatched loop tags (missing "{/' + key + '}").' + ); + } + output = output.substring(0, outputLength - overlap); } cursor = contentEnd; @@ -2644,4 +2655,22 @@ public with sharing class DocGenService { public static Map mergeTemplateForSignature(Id templateId, String recordIdStr) { throw new DocGenException('Signatures have been removed from DocGen. Please delete the old signature classes from your org.'); } + + // Method to create an AuraHandledException with an Exception message + public static AuraHandledException ahe(Exception exc) { + if (exc instanceof AuraHandledException) { + return (AuraHandledException) exc; + } + System.debug(LoggingLevel.ERROR, 'AuraHandledException created from Exception type: ' + exc.getTypeName()); + System.debug(LoggingLevel.ERROR, 'Message: ' + exc.getMessage()); + System.debug(LoggingLevel.ERROR, 'StackTrace: ' + exc.getStackTraceString()); + return ahe(exc.getMessage()); + } + + // Method to create an AuraHandledException with a custom message + public static AuraHandledException ahe(String message) { + AuraHandledException ahe = new AuraHandledException(message); + ahe.setMessage(message); + return ahe; + } } \ No newline at end of file diff --git a/force-app/main/default/classes/DocGenSetupController.cls b/force-app/main/default/classes/DocGenSetupController.cls index 29c9bfa..1b02636 100644 --- a/force-app/main/default/classes/DocGenSetupController.cls +++ b/force-app/main/default/classes/DocGenSetupController.cls @@ -27,7 +27,7 @@ public with sharing class DocGenSetupController { // Only allow users with Customize Application or DocGen_Admin permission set if (!FeatureManagement.checkPermission('DocGen_Admin_Access') && !Schema.sObjectType.DocGen_Settings__c.isUpdateable()) { - throw new AuraHandledException('Insufficient privileges to modify DocGen settings.'); + throw DocGenService.ahe('Insufficient privileges to modify DocGen settings.'); } DocGen_Settings__c settings = DocGen_Settings__c.getOrgDefaults(); settings.Experience_Site_Url__c = experienceSiteUrl; diff --git a/force-app/main/default/classes/DocGenTemplateManager.cls b/force-app/main/default/classes/DocGenTemplateManager.cls index 51b4c63..0a05461 100644 --- a/force-app/main/default/classes/DocGenTemplateManager.cls +++ b/force-app/main/default/classes/DocGenTemplateManager.cls @@ -7,7 +7,7 @@ public with sharing class DocGenTemplateManager { */ public static String getTemplateFileContent(Id templateId) { if (templateId == null) { - throw new AuraHandledException('Template ID is required.'); + throw DocGenService.ahe('Template ID is required.'); } // 1. Check for Active Version first @@ -54,7 +54,7 @@ public with sharing class DocGenTemplateManager { } if (targetCvId == null) { - throw new AuraHandledException('No template file found (active or attached).'); + throw DocGenService.ahe('No template file found (active or attached).'); } // Get the specific ContentVersion @@ -67,7 +67,7 @@ public with sharing class DocGenTemplateManager { ]; if (cvs.isEmpty()) { - throw new AuraHandledException('ContentVersion not found.'); + throw DocGenService.ahe('ContentVersion not found.'); } return EncodingUtil.base64Encode(cvs[0].VersionData); From 061a326fdec7186f4c72f43ea27d9fee28940f75 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 16:54:13 +0100 Subject: [PATCH 03/19] Enhance error handling in DocGenController and DocGenService by including exception details in thrown messages for better debugging. This improves the clarity of error messages related to document generation, template saving, and deletion operations. --- .../main/default/classes/DocGenController.cls | 8 +- .../main/default/classes/DocGenService.cls | 132 ++++++++++-------- 2 files changed, 78 insertions(+), 62 deletions(-) diff --git a/force-app/main/default/classes/DocGenController.cls b/force-app/main/default/classes/DocGenController.cls index 3415102..90360da 100644 --- a/force-app/main/default/classes/DocGenController.cls +++ b/force-app/main/default/classes/DocGenController.cls @@ -171,7 +171,7 @@ public with sharing class DocGenController { 'templateType' => result.get('templateType') }; } catch (Exception e) { - throw DocGenService.ahe('Error generating document. Please check the template configuration.'); + throw DocGenService.ahe(e,'Error generating document. Please check the template configuration.'); } } @@ -188,7 +188,7 @@ public with sharing class DocGenController { try { return DocGenService.generateDocumentParts(templateId, recordId); } catch (Exception e) { - throw DocGenService.ahe('Error generating document parts. Please check the template configuration.'); + throw DocGenService.ahe(e,'Error generating document parts. Please check the template configuration.'); } } @@ -364,7 +364,7 @@ public with sharing class DocGenController { } } } catch (Exception e) { - throw DocGenService.ahe('Error saving template. Please verify your inputs and permissions.'); + throw DocGenService.ahe(e,'Error saving template. Please verify your inputs and permissions.'); } } @@ -380,7 +380,7 @@ public with sharing class DocGenController { } delete new DocGen_Template__c(Id = templateId); } catch (Exception e) { - throw DocGenService.ahe('Error deleting template. Please check your permissions.'); + throw DocGenService.ahe(e,'Error deleting template. Please check your permissions.'); } } diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 4e70615..6526ac5 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -1138,69 +1138,71 @@ public with sharing class DocGenService { Integer contentEnd = sectionEnd; String innerXml = xml.substring(contentStart, contentEnd); - // Smart Table Row Expansion - // Check if we are inside a TR and if the loop is fully contained in it - Integer trStart = xml.lastIndexOf('', cursor); + // Smart container expansion: repeat the nearest parent container when possible. + // Candidate containers: + // 1) Table row (...) + // 2) Numbered/bulleted paragraph (nearest containing ) + Integer closeTagEnd = xml.indexOf('}', sectionEnd); + Integer chosenContainerStart = -1; + Integer chosenContainerEnd = -1; + + // Candidate: row container + Integer rowContainerStart = lastIndexOfTagStart(xml, 'w:tr', cursor); + if (rowContainerStart != -1) { + Integer rowContainerEnd = xml.indexOf('', rowContainerStart); + Boolean rowClosedBeforeCursor = xml.substring(rowContainerStart, cursor).contains(''); + if (!rowClosedBeforeCursor && rowContainerEnd != -1 && rowContainerEnd > closeTagEnd) { + chosenContainerStart = rowContainerStart; + chosenContainerEnd = rowContainerEnd + 7; // + } } - // Find matching TR End - Integer trEnd = -1; - if (trStart != -1) { - // Verify trStart is actually an ancestor of our cursor (no between trStart and cursor) - Boolean trClosesBefore = xml.substring(trStart, cursor).contains(''); - - trEnd = xml.indexOf('', trStart); - Integer closeTagEnd = xml.indexOf('}', sectionEnd); - - if (trClosesBefore) { - // The we found already closed before our tag - NOT our ancestor - cursor = xml.indexOf('}', sectionEnd) + 1; - } else if (trEnd > closeTagEnd) { - // Yes, the loop is fully inside this TR. - // Now, checking if we should expand. - // Expand if the start tag and end tag are in the SAME TR (implied by above) - // AND if we think it's intended (always true for better UX?) - - // CAUTION: If user meant cell-repetition, valid, but rare. - // We assume Row Repetition is preferred. - - // Redefine Inner XML to include the TR - // And strip the tags from it. - - contentStart = trStart; - contentEnd = trEnd + 7; // Length of - innerXml = xml.substring(contentStart, contentEnd); - - String startTagFull = '{' + rawTag + '}'; - innerXml = innerXml.replace(startTagFull, ''); - - String endTagFull = xml.substring(sectionEnd, closeTagEnd + 1); - innerXml = innerXml.replace(endTagFull, ''); - - // Backtrack output to remove the TR prefix already appended - Integer overlap = cursor - contentStart; - if (overlap > 0) { - Integer outputLength = output.length(); - if (overlap > outputLength) { - throw new DocGenException( - 'Template loop parsing error for "{#' + key + '}": internal overlap (' + overlap + - ') exceeds output length (' + outputLength + '). ' + - 'This usually indicates malformed or mismatched loop tags (missing "{/' + key + '}").' - ); + // Candidate: list paragraph container (w:p with w:numPr) + Integer numPropsStart = xml.lastIndexOf('', paragraphStart); + Boolean paragraphClosedBeforeCursor = xml.substring(paragraphStart, cursor).contains(''); + if (!paragraphClosedBeforeCursor && paragraphEndStart != -1 && paragraphEndStart > closeTagEnd && numPropsStart < paragraphEndStart) { + Integer paragraphContainerEnd = paragraphEndStart + 6; // + // Prefer the closest valid parent. + if (chosenContainerStart == -1 || paragraphStart > chosenContainerStart) { + chosenContainerStart = paragraphStart; + chosenContainerEnd = paragraphContainerEnd; } - output = output.substring(0, outputLength - overlap); - } + } + } + } - cursor = contentEnd; - } else { - // Loop spans across TRs or is outside TR? - // Default behavior (keep tags inside, repeat content) - cursor = xml.indexOf('}', sectionEnd) + 1; + if (chosenContainerStart != -1 && chosenContainerEnd != -1) { + contentStart = chosenContainerStart; + contentEnd = chosenContainerEnd; + innerXml = xml.substring(contentStart, contentEnd); + + String startTagFull = '{' + rawTag + '}'; + innerXml = innerXml.replace(startTagFull, ''); + + String endTagFull = xml.substring(sectionEnd, closeTagEnd + 1); + innerXml = innerXml.replace(endTagFull, ''); + + // Backtrack output to remove already-emitted prefix for chosen container. + Integer overlap = cursor - contentStart; + if (overlap > 0) { + Integer outputLength = output.length(); + if (overlap > outputLength) { + throw new DocGenException( + 'Template loop parsing error for "{#' + key + '}": internal overlap (' + overlap + + ') exceeds output length (' + outputLength + '). ' + + 'This usually indicates malformed or mismatched loop tags (missing "{/' + key + '}").' + ); + } + output = output.substring(0, outputLength - overlap); } + cursor = contentEnd; } else { - cursor = xml.indexOf('}', sectionEnd) + 1; + // Fallback behavior: keep default content-only repetition. + cursor = closeTagEnd + 1; } // Process Data @@ -1349,6 +1351,16 @@ public with sharing class DocGenService { } return -1; } + + private static Integer lastIndexOfTagStart(String xml, String tagName, Integer fromIndex) { + if (String.isBlank(xml) || String.isBlank(tagName) || fromIndex == null || fromIndex < 0) { + return -1; + } + Integer searchIndex = Math.min(fromIndex, xml.length()); + Integer withAttributesIndex = xml.lastIndexOf('<' + tagName + ' ', searchIndex); + Integer bareTagIndex = xml.lastIndexOf('<' + tagName + '>', searchIndex); + return Math.max(withAttributesIndex, bareTagIndex); + } private static Object resolveValue(Map data, String path) { if (data == null) { @@ -2658,13 +2670,17 @@ public with sharing class DocGenService { // Method to create an AuraHandledException with an Exception message public static AuraHandledException ahe(Exception exc) { + return ahe(exc, exc.getMessage()); + } + + public static AuraHandledException ahe(Exception exc, String message) { if (exc instanceof AuraHandledException) { return (AuraHandledException) exc; } System.debug(LoggingLevel.ERROR, 'AuraHandledException created from Exception type: ' + exc.getTypeName()); System.debug(LoggingLevel.ERROR, 'Message: ' + exc.getMessage()); System.debug(LoggingLevel.ERROR, 'StackTrace: ' + exc.getStackTraceString()); - return ahe(exc.getMessage()); + return ahe(message); } // Method to create an AuraHandledException with a custom message From c1135dbfb000e64975bf937c452a007e6f3096b8 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 16:57:22 +0100 Subject: [PATCH 04/19] multi line string support (long text and text area) --- .../main/default/classes/DocGenService.cls | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 6526ac5..878005c 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -1316,7 +1316,11 @@ public with sharing class DocGenService { } } else { - output += strVal.escapeXml(); + if (currentTemplateType == 'PowerPoint') { + output += strVal.escapeXml(); + } else { + output += escapeXmlPreservingDocxLineBreaks(strVal); + } } } cursor = tagClose + 1; @@ -1361,6 +1365,26 @@ public with sharing class DocGenService { Integer bareTagIndex = xml.lastIndexOf('<' + tagName + '>', searchIndex); return Math.max(withAttributesIndex, bareTagIndex); } + + private static String escapeXmlPreservingDocxLineBreaks(String inputText) { + if (inputText == null) { + return ''; + } + String normalizedText = inputText.replace('\r\n', '\n').replace('\r', '\n'); + if (!normalizedText.contains('\n')) { + return normalizedText.escapeXml(); + } + List lineParts = normalizedText.split('\n'); + String joinedText = ''; + String lineBreakXml = ''; + for (Integer partIndex = 0; partIndex < lineParts.size(); partIndex++) { + if (partIndex > 0) { + joinedText += lineBreakXml; + } + joinedText += lineParts[partIndex].escapeXml(); + } + return joinedText; + } private static Object resolveValue(Map data, String path) { if (data == null) { From 713251db56d7cac2cdef5ee65bccfe3d40d5669e Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 18:36:45 +0100 Subject: [PATCH 05/19] merge text check, tags should be closed correctly --- .../main/default/classes/DocGenMiscTests.cls | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index a3414e4..fb92d78 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -553,11 +553,16 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Account acc = [SELECT Id FROM Account LIMIT 1]; + Map result; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + try { + result = DocGenService.processDocument(tpl.Id, acc.Id); + } catch (DocGenException e) { + System.assert(e.getMessage().contains('Malformed loop tag'), 'Exception message should contain "Malformed loop tag"'); + } Test.stopTest(); - System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for unmatched section test'); + System.assertEquals(null, result.get('blob'), 'Blob should not be null for unmatched section test'); } @IsTest @@ -1577,11 +1582,16 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Account acc = [SELECT Id FROM Account LIMIT 1]; + Map result; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + try { + result = DocGenService.processDocument(tpl.Id, acc.Id); + } catch (DocGenException e) { + System.assert(e.getMessage().contains('Malformed merge tag'), 'Exception message should contain "Malformed merge tag"'); + } Test.stopTest(); - System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for unclosed brace test'); + System.assertEquals(null, result.get('blob'), 'Blob should not be null for unclosed brace test'); } @IsTest From 69ada3d2ed05b960d9da4091f0bd61ed1464babb Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 18:36:45 +0100 Subject: [PATCH 06/19] merge text check, tags should be closed correctly --- .../main/default/classes/DocGenMiscTests.cls | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index a3414e4..f1c0246 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -553,11 +553,16 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Account acc = [SELECT Id FROM Account LIMIT 1]; + Map result; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + try { + result = DocGenService.processDocument(tpl.Id, acc.Id); + } catch (DocGenException e) { + System.assert(e.getMessage().contains('Malformed loop tag'), 'Exception message should contain "Malformed loop tag"'); + } Test.stopTest(); - System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for unmatched section test'); + System.assertEquals(null, result, 'result should be null for unmatched section test'); } @IsTest @@ -1577,11 +1582,16 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Account acc = [SELECT Id FROM Account LIMIT 1]; + Map result; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + try { + result = DocGenService.processDocument(tpl.Id, acc.Id); + } catch (DocGenException e) { + System.assert(e.getMessage().contains('Malformed merge tag'), 'Exception message should contain "Malformed merge tag"'); + } Test.stopTest(); - System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for unclosed brace test'); + System.assertEquals(null, result, 'result should be null for unclosed brace test'); } @IsTest From 0e978083c61f7e4ed60576159efdb6c08f127b83 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 27 Mar 2026 14:03:47 +0100 Subject: [PATCH 07/19] Refactor DocGen tests to use User object instead of Account. Updated test setups and assertions across multiple test classes to ensure compatibility with User-based templates and queries. This change enhances the relevance of tests to the current data model and improves overall test coverage. --- .../classes/DocGenBulkControllerTest.cls | 16 +- .../classes/DocGenBulkFlowActionTest.cls | 4 +- .../main/default/classes/DocGenBulkTests.cls | 137 +- .../default/classes/DocGenControllerTests.cls | 698 +++---- .../main/default/classes/DocGenMiscTests.cls | 1751 +++++++---------- .../default/classes/DocGenSharingTests.cls | 56 +- .../main/default/classes/DocGenTests.cls | 20 +- 7 files changed, 1155 insertions(+), 1527 deletions(-) diff --git a/force-app/main/default/classes/DocGenBulkControllerTest.cls b/force-app/main/default/classes/DocGenBulkControllerTest.cls index 4dc15b7..9621719 100644 --- a/force-app/main/default/classes/DocGenBulkControllerTest.cls +++ b/force-app/main/default/classes/DocGenBulkControllerTest.cls @@ -3,17 +3,13 @@ public with sharing class DocGenBulkControllerTest { @TestSetup static void setup() { - // Create an Account for testing - Account acc = new Account(Name = 'Heap Test Account'); - insert acc; - // Create a Template DocGen_Template__c temp = new DocGen_Template__c( Name = 'Heap Test Template', - Base_Object_API__c = 'Account', - Test_Record_Id__c = acc.Id, + Base_Object_API__c = 'User', + Test_Record_Id__c = UserInfo.getUserId(), Output_Format__c = 'PDF', - Query_Config__c = '{"v":3,"root":"Account","nodes":[{"id":"n0","object":"Account","fields":["Name"],"parentNode":null,"lookupField":null,"relationshipName":null}]}' + Query_Config__c = '{"v":3,"root":"User","nodes":[{"id":"n0","object":"User","fields":["Name"],"parentNode":null,"lookupField":null,"relationshipName":null}]}' ); insert temp; @@ -99,7 +95,7 @@ public with sharing class DocGenBulkControllerTest { @IsTest static void testValidateFilter() { Test.startTest(); - Integer count = DocGenBulkController.validateFilter('Account', 'Name LIKE \'Heap%\''); + Integer count = DocGenBulkController.validateFilter('User', 'UserName = \'' + UserInfo.getUserName() + '\''); Test.stopTest(); System.assertEquals(1, count, 'Should find 1 record'); @@ -108,7 +104,7 @@ public with sharing class DocGenBulkControllerTest { @IsTest static void testValidateFilterEmpty() { Test.startTest(); - Integer count = DocGenBulkController.validateFilter('Account', ''); + Integer count = DocGenBulkController.validateFilter('User', ''); Test.stopTest(); System.assert(count >= 1, 'Should count at least 1 account when no condition is provided'); @@ -185,7 +181,7 @@ public with sharing class DocGenBulkControllerTest { DocGen_Template__c temp = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); - Id jobId = DocGenBulkController.submitJob(temp.Id, 'Name != null', 'Test Job', true, 10, false); + Id jobId = DocGenBulkController.submitJob(temp.Id, 'Id = \'' + UserInfo.getUserId() + '\'', 'Test Job', true, 10, false); Test.stopTest(); System.assertNotEquals(null, jobId, 'Job ID should be returned'); diff --git a/force-app/main/default/classes/DocGenBulkFlowActionTest.cls b/force-app/main/default/classes/DocGenBulkFlowActionTest.cls index 9df5f77..92e3660 100644 --- a/force-app/main/default/classes/DocGenBulkFlowActionTest.cls +++ b/force-app/main/default/classes/DocGenBulkFlowActionTest.cls @@ -5,13 +5,11 @@ private class DocGenBulkFlowActionTest { static void setup() { DocGen_Template__c temp = new DocGen_Template__c( Name = 'Test Bulk Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Type__c = 'Word' ); insert temp; - insert new Account(Name = 'Test Account 1', Website = 'https://test1.com'); - insert new Account(Name = 'Test Account 2', Website = 'https://test2.com'); } @IsTest diff --git a/force-app/main/default/classes/DocGenBulkTests.cls b/force-app/main/default/classes/DocGenBulkTests.cls index 567ec70..639abc2 100644 --- a/force-app/main/default/classes/DocGenBulkTests.cls +++ b/force-app/main/default/classes/DocGenBulkTests.cls @@ -5,17 +5,20 @@ private class DocGenBulkTests { static void setup() { DocGen_Template__c t = new DocGen_Template__c( Name = 'Test Template', - Base_Object_API__c = 'Account' + Base_Object_API__c = 'User' ); insert t; - - Account a = new Account( - Name = 'Test Account', - Website = 'https://www.example.com' - ); - insert a; } + private static DocGen_Template__c createOrgTemplate() { + DocGen_Template__c t = new DocGen_Template__c( + Name = 'Test Template', + Base_Object_API__c = 'Organization' + ); + insert t; + return t; + } + @IsTest static void testGetBulkTemplates() { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; @@ -32,7 +35,7 @@ private class DocGenBulkTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { Test.startTest(); - Integer count = DocGenBulkController.validateFilter('Account', 'Name = \'Test Account\''); + Integer count = DocGenBulkController.validateFilter('User', 'UserName = \'' + UserInfo.getUserName() + '\''); Test.stopTest(); System.assertEquals(1, count, 'Should find 1 record'); } @@ -111,7 +114,7 @@ private class DocGenBulkTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { Test.startTest(); - Integer count = DocGenBulkController.validateFilter('Account', null); + Integer count = DocGenBulkController.validateFilter('User', null); Test.stopTest(); System.assert(count >= 1, 'Should count all accounts'); } @@ -124,7 +127,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null; DELETE Account'); + DocGenBulkController.validateFilter('User', 'Name != null; DELETE Account'); } catch (AuraHandledException e) { caught = true; } @@ -140,7 +143,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name IN (SELECT Name FROM Account)'); + DocGenBulkController.validateFilter('User', 'Name IN (SELECT Name FROM User)'); } catch (AuraHandledException e) { caught = true; } @@ -345,13 +348,13 @@ private class DocGenBulkTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { // Create a template with V3 query config - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null}' + ']}'; DocGen_Template__c t = new DocGen_Template__c( Name = 'V3 Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = v3Config, Type__c = 'Word', Output_Format__c = 'PDF' @@ -362,29 +365,28 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); // Create multiple accounts as bulk targets - List accs = new List(); - for (Integer i = 0; i < 3; i++) { - accs.add(new Account(Name = 'V3 Acct ' + i)); + Set usrIds = new Set(); + for (User usr : [select Id from User limit 3]) { + usrIds.add(usr.Id); } - insert accs; DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, - Query_Condition__c = 'Name LIKE \'V3 Acct%\'', + Query_Condition__c = 'Id IN (\'' + String.join(usrIds, '\',\'') + '\')', Status__c = 'Queued' ); insert job; Test.startTest(); DocGenBatch batch = new DocGenBatch(job.Id); - Database.executeBatch(batch, 3); + Database.executeBatch(batch, usrIds.size()); Test.stopTest(); // Verify job completed (cache may or may not have been created depending on // whether getRecordDataV3Bulk succeeds, but the batch should finish gracefully) DocGen_Job__c updated = [SELECT Status__c, Total_Records__c, Data_Cache_CV__c FROM DocGen_Job__c WHERE Id = :job.Id]; - System.assertEquals(3, updated.Total_Records__c, 'Should have counted 3 records'); + System.assertEquals(usrIds.size(), updated.Total_Records__c, 'Should have counted ' + usrIds.size() + ' records'); System.assertNotEquals('Queued', updated.Status__c, 'Status should have progressed'); } } @@ -400,7 +402,7 @@ private class DocGenBulkTests { // Create a template with V1 query config and a valid DOCX DocGen_Template__c t = new DocGen_Template__c( Name = 'Success Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -409,12 +411,9 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - Account a = new Account(Name = 'Batch Success Account'); - insert a; - DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, - Query_Condition__c = 'Name = \'Batch Success Account\'', + Query_Condition__c = 'UserName = \'' + UserInfo.getUserName() + '\'', Status__c = 'Queued' ); insert job; @@ -444,7 +443,7 @@ private class DocGenBulkTests { System.runAs(u) { DocGen_Template__c t = new DocGen_Template__c( Name = 'Merge Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -453,11 +452,11 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - List accs = new List(); + List usrs = new List(); + User usr = [SELECT Id, Email FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; for (Integer i = 0; i < 2; i++) { - accs.add(new Account(Name = 'BulkAcct ' + i)); + usrs.add(usr); } - insert accs; DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, @@ -490,7 +489,7 @@ private class DocGenBulkTests { System.runAs(u) { DocGen_Template__c t = new DocGen_Template__c( Name = 'MergeOnly Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -499,9 +498,6 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - Account a = new Account(Name = 'MergeOnly Account'); - insert a; - DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, Query_Condition__c = 'Name = \'MergeOnly Account\'', @@ -578,11 +574,10 @@ private class DocGenBulkTests { System.runAs(u) { // Use a template with no DOCX file — all records will fail DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account a = [SELECT Id FROM Account WHERE Name = 'Test Account' LIMIT 1]; DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, - Query_Condition__c = 'Name = \'Test Account\'', + Query_Condition__c = 'UserName = \'' + UserInfo.getUserName() + '\'', Status__c = 'Queued' ); insert job; @@ -639,13 +634,13 @@ private class DocGenBulkTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { // Create a V3 config template to trigger buildDataCache - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null}' + ']}'; DocGen_Template__c t = new DocGen_Template__c( Name = 'Cache Cleanup Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = v3Config, Type__c = 'Word', Output_Format__c = 'PDF' @@ -654,9 +649,6 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - Account a = new Account(Name = 'Cache Cleanup Account'); - insert a; - DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, Query_Condition__c = 'Name = \'Cache Cleanup Account\'', @@ -722,7 +714,7 @@ private class DocGenBulkTests { System.runAs(u) { DocGen_Template__c t = new DocGen_Template__c( Name = 'Merge Chain Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -731,9 +723,6 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - Account a = new Account(Name = 'ChainTestAcct'); - insert a; - // Submit via controller with mergePdf=true Test.startTest(); Id jobId = DocGenBulkController.submitJob(t.Id, 'Name = \'ChainTestAcct\'', 'Chain Job', true, 1, false); @@ -793,11 +782,10 @@ private class DocGenBulkTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account a = [SELECT Id FROM Account WHERE Name = 'Test Account' LIMIT 1]; DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, - Query_Condition__c = 'Name = \'Test Account\'', + Query_Condition__c = 'UserName = \'' + UserInfo.getUserName() + '\'', Status__c = 'Completed' ); insert job; @@ -807,7 +795,7 @@ private class DocGenBulkTests { Title = 'Generated PDF', PathOnClient = 'Generated PDF.pdf', VersionData = Blob.valueOf('fake pdf content'), - FirstPublishLocationId = a.Id + FirstPublishLocationId = UserInfo.getUserId() ); insert cv; @@ -860,7 +848,7 @@ private class DocGenBulkTests { System.runAs(u) { DocGen_Template__c t = new DocGen_Template__c( Name = 'Merge Submit Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -891,7 +879,7 @@ private class DocGenBulkTests { System.runAs(u) { DocGen_Template__c t = new DocGen_Template__c( Name = 'MergeOnly Submit Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -938,9 +926,9 @@ private class DocGenBulkTests { @IsTest static void testSubmitJobNullBatchSize() { + DocGen_Template__c t = createOrgTemplate(); User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { - DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); // null batchSize should default to 1 @@ -958,9 +946,9 @@ private class DocGenBulkTests { @IsTest static void testBatchNoCondition() { + DocGen_Template__c t = createOrgTemplate(); User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { - DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, @@ -1030,7 +1018,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null /* hack */'); + DocGenBulkController.validateFilter('User', 'Name != null /* hack */'); } catch (AuraHandledException e) { caught = true; } @@ -1046,7 +1034,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null -- hack'); + DocGenBulkController.validateFilter('User', 'Name != null -- hack'); } catch (AuraHandledException e) { caught = true; } @@ -1066,7 +1054,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null UPDATE Account'); + DocGenBulkController.validateFilter('User', 'Name != null UPDATE Account'); } catch (AuraHandledException e) { caught = true; } @@ -1082,7 +1070,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null DELETE Account'); + DocGenBulkController.validateFilter('User', 'Name != null DELETE Account'); } catch (AuraHandledException e) { caught = true; } @@ -1100,13 +1088,13 @@ private class DocGenBulkTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { // Create a V3 template so buildDataCache creates a cache CV - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null}' + ']}'; DocGen_Template__c t = new DocGen_Template__c( Name = 'LazyLoad Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = v3Config, Type__c = 'Word', Output_Format__c = 'PDF' @@ -1115,9 +1103,6 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - Account a = new Account(Name = 'LazyLoad Account'); - insert a; - DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, Query_Condition__c = 'Name = \'LazyLoad Account\'', @@ -1148,7 +1133,7 @@ private class DocGenBulkTests { // 'DocGen_Job_Complete' does not exist — should return gracefully DocGen_Template__c t = new DocGen_Template__c( Name = 'Notify Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -1157,9 +1142,6 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - Account a = new Account(Name = 'Notify Account'); - insert a; - DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, Query_Condition__c = 'Name = \'Notify Account\'', @@ -1189,7 +1171,7 @@ private class DocGenBulkTests { System.runAs(u) { DocGen_Template__c t = new DocGen_Template__c( Name = 'MergeOnly Submit Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -1214,9 +1196,9 @@ private class DocGenBulkTests { @IsTest static void testSubmitJobDefaultBatchSize() { + DocGen_Template__c t = createOrgTemplate(); User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { - DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); Id jobId = DocGenBulkController.submitJob(t.Id, null, null, false, null, false); @@ -1238,7 +1220,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null -- drop'); + DocGenBulkController.validateFilter('User', 'Name != null -- drop'); } catch (AuraHandledException e) { caught = true; } @@ -1254,7 +1236,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null /* evil */'); + DocGenBulkController.validateFilter('User', 'Name != null /* evil */'); } catch (AuraHandledException e) { caught = true; } @@ -1271,7 +1253,7 @@ private class DocGenBulkTests { Boolean caught = false; Test.startTest(); try { - DocGenBulkController.validateFilter('Account', 'Name != null ORDER BY Name'); + DocGenBulkController.validateFilter('User', 'Name != null ORDER BY Name'); } catch (AuraHandledException e) { caught = true; } @@ -1407,7 +1389,7 @@ private class DocGenBulkTests { // Create template with valid DOCX so at least some records succeed DocGen_Template__c t = new DocGen_Template__c( Name = 'Partial Fail Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word', Output_Format__c = 'PDF' @@ -1416,12 +1398,9 @@ private class DocGenBulkTests { createValidDocxForTemplate(t.Id, buildSimpleDocXml()); - // Create accounts that should be processed - insert new Account(Name = 'PartialFail Account'); - DocGen_Job__c job = new DocGen_Job__c( Template__c = t.Id, - Query_Condition__c = 'Name = \'PartialFail Account\'', + Query_Condition__c = 'Name = \'PartialFail User\'', Status__c = 'Queued' ); insert job; @@ -1443,9 +1422,9 @@ private class DocGenBulkTests { @IsTest static void testSubmitJobWithLabel() { + DocGen_Template__c t = createOrgTemplate(); User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { - DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); Id jobId = DocGenBulkController.submitJob(t.Id, null, 'My Custom Label', false, 5, false); @@ -1462,9 +1441,9 @@ private class DocGenBulkTests { @IsTest static void testSubmitJobWithBlankLabel() { + DocGen_Template__c t = createOrgTemplate(); User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { - DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); Id jobId = DocGenBulkController.submitJob(t.Id, null, '', false, 1, false); diff --git a/force-app/main/default/classes/DocGenControllerTests.cls b/force-app/main/default/classes/DocGenControllerTests.cls index fce1d2d..7c3ee13 100644 --- a/force-app/main/default/classes/DocGenControllerTests.cls +++ b/force-app/main/default/classes/DocGenControllerTests.cls @@ -1,23 +1,44 @@ @IsTest private class DocGenControllerTests { - + private static String CHATTER_FREE_USER_PROFILE_NAME = 'Chatter Free User'; + private static String UNIQUE_IDENTIFIER = 'DocGenControllerTests'; + private static String UNIQUE_USERNAME = String.format('manageduser+{0}@test.com{1}', new List{UNIQUE_IDENTIFIER, UserInfo.getUserId()}); @TestSetup static void setup() { DocGen_Template__c tpl = new DocGen_Template__c( Name = 'Controller Test Template', - Base_Object_API__c = 'Account', - Query_Config__c = 'Name, AccountSource', + Base_Object_API__c = 'User', + Query_Config__c = 'Email, CreatedBy.Email', Type__c = 'Word' ); insert tpl; - Account acc = new Account( - Name = 'Controller Test Account', - AccountSource = 'Web', - Website = 'https://test.com' - ); - insert acc; - } + createManagedUser(UNIQUE_IDENTIFIER); + } + + private static void createManagedUser(String uniqueIdentifier) { + System.runAs(new User(Id = UserInfo.getUserId())) { + // this should create a new chatter free user and set the manager to the current user + User usr = new User( + FirstName = 'Managed', + LastName = 'User', + Email = 'manageduser@test.com', + Username = UNIQUE_USERNAME, + Alias = 'muser', + LanguageLocaleKey = 'en_US', + LocaleSidKey = 'en_US', + EmailEncodingKey = 'UTF-8', + ManagerId = UserInfo.getUserId(), + ProfileId = [SELECT Id FROM Profile WHERE Name = 'Chatter Free User' LIMIT 1].Id, + TimeZoneSidKey = 'America/Los_Angeles' + ); + insert usr; + } + } + + private static User getManagedUser() { + return [SELECT Id, Name, FirstName, LastName, CreatedBy.Name FROM User WHERE Username = :UNIQUE_USERNAME LIMIT 1]; + } private static ContentVersion createTestFile() { ContentVersion cv = new ContentVersion( @@ -41,14 +62,13 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveGeneratedDocument() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); String dummyContent = EncodingUtil.base64Encode(Blob.valueOf('Dummy PDF content')); Test.startTest(); Id contentDocId = DocGenController.saveGeneratedDocument( - acc.Id, 'TestOutput', dummyContent, '.pdf' + usr.Id, 'TestOutput', dummyContent, '.pdf' ); Test.stopTest(); @@ -67,14 +87,13 @@ private class DocGenControllerTests { @IsTest static void testSaveGeneratedDocumentExtensionWithoutDot() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); String dummyContent = EncodingUtil.base64Encode(Blob.valueOf('Dummy DOCX content')); Test.startTest(); Id contentDocId = DocGenController.saveGeneratedDocument( - acc.Id, 'TestOutputNoDot', dummyContent, 'docx' + usr.Id, 'TestOutputNoDot', dummyContent, 'docx' ); Test.stopTest(); @@ -94,16 +113,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testProcessAndReturnDocumentThrows() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { createTestFile(); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.processAndReturnDocument(tpl.Id, acc.Id); + DocGenController.processAndReturnDocument(tpl.Id, usr.Id); } catch (AuraHandledException e) { caughtException = true; } @@ -118,26 +136,24 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testPreviewRecordData() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Map result = DocGenController.previewRecordData( - acc.Id, 'Account', 'Name, AccountSource' + usr.Id, 'User', 'Name, Email' ); Test.stopTest(); System.assertNotEquals(null, result, 'Preview data should not be null'); - System.assertEquals('Controller Test Account', result.get('Name'), - 'Name field should match the test account'); + System.assertNotEquals(null, result.get('Name'), + 'Name field should be returned'); } } @IsTest static void testPreviewRecordDataNullParams() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Map result = DocGenController.previewRecordData(null, null, null); Test.stopTest(); @@ -151,8 +167,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetAllTemplates() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List templates = DocGenController.getAllTemplates(); Test.stopTest(); @@ -174,8 +189,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveTemplateUpdateOnly() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Map fields = new Map{ @@ -183,7 +197,7 @@ private class DocGenControllerTests { 'Name' => 'Renamed Template', 'Category__c' => 'Legal', 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => 'Updated description', 'Query_Config__c' => 'Name', 'Test_Record_Id__c' => null, @@ -207,8 +221,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveTemplateWithVersion() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { createTestFile(); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; @@ -217,9 +230,9 @@ private class DocGenControllerTests { 'Name' => 'Versioned Template', 'Category__c' => 'Finance', 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => 'Version test', - 'Query_Config__c' => 'Name, AccountSource', + 'Query_Config__c' => 'Name, Email', 'Test_Record_Id__c' => null, 'Document_Title_Format__c' => null }; @@ -253,12 +266,11 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testDeleteTemplate() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Create a disposable template to delete DocGen_Template__c disposable = new DocGen_Template__c( Name = 'To Be Deleted', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word' ); @@ -280,8 +292,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetTemplateVersions() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; // Create a version record @@ -289,7 +300,7 @@ private class DocGenControllerTests { Template__c = tpl.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Category__c = 'General', Description__c = 'Test version' ); @@ -309,8 +320,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetObjectOptions() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> options = DocGenController.getObjectOptions(); Test.stopTest(); @@ -320,7 +330,7 @@ private class DocGenControllerTests { // Verify Account is in the list Boolean foundAccount = false; for (Map opt : options) { - if (opt.get('value') == 'Account') { + if (opt.get('value') == 'User') { foundAccount = true; break; } @@ -334,10 +344,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetObjectFields() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - List> fields = DocGenController.getObjectFields('Account'); + List> fields = DocGenController.getObjectFields('User'); Test.stopTest(); System.assert(!fields.isEmpty(), 'Account should have fields'); @@ -356,8 +365,7 @@ private class DocGenControllerTests { @IsTest static void testGetObjectFieldsInvalidObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> fields = DocGenController.getObjectFields('NonExistentObject__x'); Test.stopTest(); @@ -371,10 +379,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetChildRelationships() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - List> rels = DocGenController.getChildRelationships('Account'); + List> rels = DocGenController.getChildRelationships('User'); Test.stopTest(); System.assert(!rels.isEmpty(), 'Account should have child relationships'); @@ -390,8 +397,7 @@ private class DocGenControllerTests { @IsTest static void testGetChildRelationshipsInvalidObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> rels = DocGenController.getChildRelationships('NonExistentObject__x'); Test.stopTest(); @@ -405,10 +411,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetParentRelationships() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - List> parents = DocGenController.getParentRelationships('Account'); + List> parents = DocGenController.getParentRelationships('User'); Test.stopTest(); System.assert(!parents.isEmpty(), 'Account should have parent/lookup relationships'); @@ -423,8 +428,7 @@ private class DocGenControllerTests { @IsTest static void testGetParentRelationshipsInvalidObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> parents = DocGenController.getParentRelationships('NonExistentObject__x'); Test.stopTest(); @@ -438,8 +442,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSearchUsersAndGroups() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // The running user should always exist and be matchable String currentUserName = UserInfo.getLastName(); @@ -459,8 +462,7 @@ private class DocGenControllerTests { @IsTest static void testSearchUsersAndGroupsNoResults() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> results = DocGenController.searchUsersAndGroups('ZZZZNONEXISTENT99999'); Test.stopTest(); @@ -474,8 +476,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetTemplateSharesEmpty() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); @@ -493,8 +494,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testShareAndRemoveTemplate() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id, OwnerId FROM DocGen_Template__c LIMIT 1]; // We need a different user or group to share with. @@ -544,8 +544,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testOptionCompareTo() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGenController.Option optA = new DocGenController.Option('Alpha', 'a'); DocGenController.Option optB = new DocGenController.Option('Beta', 'b'); DocGenController.Option optA2 = new DocGenController.Option('Alpha', 'a2'); @@ -560,13 +559,12 @@ private class DocGenControllerTests { @IsTest static void testOptionThreeArgConstructor() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - DocGenController.Option opt = new DocGenController.Option('Contacts', 'Contacts', 'Contact'); + System.runAs(new User(Id = UserInfo.getUserId())) { + DocGenController.Option opt = new DocGenController.Option('Contacts', 'Contacts', 'User'); System.assertEquals('Contacts', opt.label, 'Label should match'); System.assertEquals('Contacts', opt.value, 'Value should match'); - System.assertEquals('Contact', opt.childObjectApiName, 'Child object API name should match'); + System.assertEquals('User', opt.childObjectApiName, 'Child object API name should match'); } } @@ -575,14 +573,13 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataNullTemplateId() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.generateDocumentData(null, acc.Id); + DocGenController.generateDocumentData(null, usr.Id); } catch (DocGenException e) { caughtException = true; System.assert(e.getMessage().contains('Template ID is missing'), @@ -599,16 +596,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataTemplateNotFound() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; // Use a fake but valid-format Id for a template that does not exist Id fakeTemplateId = DocGen_Template__c.SObjectType.getDescribe(SObjectDescribeOptions.DEFERRED).getKeyPrefix() + '000000000000'; Test.startTest(); Boolean caughtException = false; try { - DocGenController.generateDocumentData(fakeTemplateId, acc.Id); + DocGenController.generateDocumentData(fakeTemplateId, usr.Id); } catch (DocGenException e) { caughtException = true; System.assert(e.getMessage().contains('Unable to access template'), @@ -625,10 +621,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataSuccess() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; // Build a minimal valid DOCX ZIP Compression.ZipWriter zw = new Compression.ZipWriter(); @@ -664,7 +659,7 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null'); @@ -680,10 +675,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetTemplatesForObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - List templates = DocGenController.getTemplatesForObject('Account'); + List templates = DocGenController.getTemplatesForObject('User'); Test.stopTest(); System.assert(!templates.isEmpty(), 'Should return the test template for Account'); @@ -693,8 +687,7 @@ private class DocGenControllerTests { @IsTest static void testGetTemplatesForObjectNoResults() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List templates = DocGenController.getTemplatesForObject('NonExistentObject__c'); Test.stopTest(); @@ -708,10 +701,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testProcessAndReturnDocumentSuccess() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; // Build valid DOCX Compression.ZipWriter zw = new Compression.ZipWriter(); @@ -747,7 +739,7 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.processAndReturnDocument(tpl.Id, acc.Id); + Map result = DocGenController.processAndReturnDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null'); @@ -763,16 +755,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGeneratePdfDownloadThrows() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { createTestFile(); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.generatePdf(tpl.Id, acc.Id, false); + DocGenController.generatePdf(tpl.Id, usr.Id, false); } catch (AuraHandledException e) { caughtException = true; } @@ -787,10 +778,9 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGeneratePdfDownloadSuccess() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -825,7 +815,7 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.generatePdf(tpl.Id, acc.Id, false); + Map result = DocGenController.generatePdf(tpl.Id, usr.Id, false); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null'); @@ -839,12 +829,11 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGeneratePdfSaveSuccess() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; tpl.Output_Format__c = 'PDF'; update tpl; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -879,7 +868,7 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.generatePdf(tpl.Id, acc.Id, true); + Map result = DocGenController.generatePdf(tpl.Id, usr.Id, true); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null'); @@ -892,8 +881,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testActivateVersion() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; // Create two versions, both active initially @@ -901,7 +889,7 @@ private class DocGenControllerTests { Template__c = tpl.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Category__c = 'General', Description__c = 'Version 1', Query_Config__c = 'Name' @@ -910,10 +898,10 @@ private class DocGenControllerTests { Template__c = tpl.Id, Is_Active__c = false, Type__c = 'Word', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Category__c = 'Legal', Description__c = 'Version 2', - Query_Config__c = 'Name, AccountSource' + Query_Config__c = 'Email, CreatedBy.Email' ); insert new List{ v1, v2 }; @@ -939,8 +927,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveTemplateWithDefaultFlag() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Map fields = new Map{ @@ -948,7 +935,7 @@ private class DocGenControllerTests { 'Name' => 'Default Template', 'Category__c' => 'Sales', 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => 'Default test', 'Query_Config__c' => 'Name', 'Test_Record_Id__c' => null, @@ -970,14 +957,13 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveTemplateDefaultEnforcement() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl1 = [SELECT Id FROM DocGen_Template__c LIMIT 1]; // Create a second template for the same object DocGen_Template__c tpl2 = new DocGen_Template__c( Name = 'Second Account Template', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Query_Config__c = 'Name', Type__c = 'Word' ); @@ -989,7 +975,7 @@ private class DocGenControllerTests { 'Name' => 'Controller Test Template', 'Category__c' => null, 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => null, 'Query_Config__c' => 'Name', 'Test_Record_Id__c' => null, @@ -1004,7 +990,7 @@ private class DocGenControllerTests { 'Name' => 'Second Account Template', 'Category__c' => null, 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => null, 'Query_Config__c' => 'Name', 'Test_Record_Id__c' => null, @@ -1028,14 +1014,13 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testPreviewRecordDataException() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.previewRecordData(acc.Id, 'InvalidObject__xyz', 'Name'); + DocGenController.previewRecordData(usr.Id, 'InvalidObject__xyz', 'Name'); } catch (DocGenException e) { caughtException = true; System.assert(e.getMessage().contains('Error fetching preview data'), @@ -1052,8 +1037,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testDeleteTemplateThrows() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Use a fake ID that looks valid but does not exist — will throw Id fakeId = DocGen_Template__c.SObjectType.getDescribe(SObjectDescribeOptions.DEFERRED).getKeyPrefix() + '000000000000'; @@ -1075,8 +1059,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveTemplateThrows() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Id fakeId = DocGen_Template__c.SObjectType.getDescribe(SObjectDescribeOptions.DEFERRED).getKeyPrefix() + '000000000000'; Map fields = new Map{ @@ -1084,7 +1067,7 @@ private class DocGenControllerTests { 'Name' => 'Will Fail', 'Category__c' => null, 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => null, 'Query_Config__c' => 'Name', 'Test_Record_Id__c' => null, @@ -1110,16 +1093,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataWithRichTextRefid() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; - // Set Description to contain HTML with an img tag using refid (simulates rich text) - acc.Description = '

See image: test

'; - update acc; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + // Set AboutMe to contain HTML with an img tag using refid (simulates rich text) + usr.AboutMe = '

See image: test

'; + update usr; // Build valid DOCX Compression.ZipWriter zw = new Compression.ZipWriter(); @@ -1158,7 +1140,7 @@ private class DocGenControllerTests { // but the code handles exceptions gracefully. This exercises the code path. Test.startTest(); try { - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); // If no exception, result should still be valid System.assertNotEquals(null, result, 'Result should not be null'); } catch (Exception e) { @@ -1175,16 +1157,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataWithServletImages() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; - // Set Description with servlet URL (without refid) to exercise resolveServletImageUrls - acc.Description = '

img

'; - update acc; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + // Set AboutMe with servlet URL (without refid) to exercise resolveServletImageUrls + usr.AboutMe = '

img

'; + update usr; Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -1220,7 +1201,7 @@ private class DocGenControllerTests { Test.startTest(); try { - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); System.assertNotEquals(null, result, 'Result should not be null'); } catch (Exception e) { // Callout exceptions are acceptable in test context @@ -1236,15 +1217,14 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataWithSfcImages() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; - acc.Description = '

img

'; - update acc; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + usr.AboutMe = '

img

'; + update usr; Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -1280,7 +1260,7 @@ private class DocGenControllerTests { Test.startTest(); try { - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); System.assertNotEquals(null, result, 'Result should not be null'); } catch (Exception e) { System.assert(true, 'Exception during callout is expected in test context'); @@ -1294,14 +1274,13 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataWithOutputFormatAndTitle() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; tpl.Output_Format__c = 'PDF'; tpl.Document_Title_Format__c = 'Invoice_{Name}'; update tpl; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -1336,7 +1315,7 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null'); @@ -1350,14 +1329,12 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataWithChildRelationship() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, (SELECT LastName FROM Contacts)'; + tpl.Query_Config__c = 'Name, (SELECT LastName FROM ManagedUsers)'; update tpl; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; - insert new Contact(FirstName = 'Test', LastName = 'Child', AccountId = acc.Id); + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -1392,12 +1369,13 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null'); Map data = (Map) result.get('data'); - System.assert(data.containsKey('Contacts'), 'Data should contain Contacts child relationship'); + System.debug('data: ' + data); + System.assert(data.containsKey('ManagedUsers'), 'Data should contain ManagedUsers child relationship'); } } @@ -1406,8 +1384,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testSaveTemplateVersionDeactivatesPrevious() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { createTestFile(); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; @@ -1416,7 +1393,7 @@ private class DocGenControllerTests { Template__c = tpl.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Category__c = 'Old', Description__c = 'Old version' ); @@ -1427,7 +1404,7 @@ private class DocGenControllerTests { 'Name' => 'Template With New Version', 'Category__c' => 'New', 'Type__c' => 'Word', - 'Base_Object_API__c' => 'Account', + 'Base_Object_API__c' => 'User', 'Description__c' => 'New version test', 'Query_Config__c' => 'Name', 'Test_Record_Id__c' => null, @@ -1459,8 +1436,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetAllTemplatesWithFile() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { createTestFile(); Test.startTest(); @@ -1485,8 +1461,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetContentVersionBase64() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { ContentVersion cv = new ContentVersion( Title = 'Base64Test', PathOnClient = 'Base64Test.txt', @@ -1507,8 +1482,7 @@ private class DocGenControllerTests { @IsTest static void testGetContentVersionBase64NotFound() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Use a fake but valid-format ContentVersion ID Id fakeId = '068000000000000AAA'; @@ -1525,16 +1499,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentPartsError() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Use a fake template ID to trigger an exception inside DocGenService Id fakeTemplateId = DocGen_Template__c.SObjectType.getDescribe(SObjectDescribeOptions.DEFERRED).getKeyPrefix() + '000000000000'; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.generateDocumentParts(fakeTemplateId, acc.Id); + DocGenController.generateDocumentParts(fakeTemplateId, usr.Id); } catch (AuraHandledException e) { caughtException = true; } @@ -1546,10 +1519,9 @@ private class DocGenControllerTests { @IsTest static void testGenerateDocumentPartsSuccess() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; // Build a minimal valid DOCX and attach to template Compression.ZipWriter zw = new Compression.ZipWriter(); @@ -1585,7 +1557,7 @@ private class DocGenControllerTests { ); Test.startTest(); - Map result = DocGenController.generateDocumentParts(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentParts(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result, 'Should return non-null result'); @@ -1598,9 +1570,8 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetRecordPdfs() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // Create a PDF ContentVersion linked to the account ContentVersion pdfCv = new ContentVersion( @@ -1611,13 +1582,13 @@ private class DocGenControllerTests { insert pdfCv; Id pdfDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :pdfCv.Id].ContentDocumentId; insert new ContentDocumentLink( - LinkedEntityId = acc.Id, + LinkedEntityId = usr.Id, ContentDocumentId = pdfDocId, ShareType = 'V' ); Test.startTest(); - List> pdfs = DocGenController.getRecordPdfs(acc.Id); + List> pdfs = DocGenController.getRecordPdfs(usr.Id); Test.stopTest(); System.assert(!pdfs.isEmpty(), 'Should return at least one PDF'); @@ -1635,12 +1606,11 @@ private class DocGenControllerTests { @IsTest static void testGetRecordPdfsEmpty() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); - List> pdfs = DocGenController.getRecordPdfs(acc.Id); + List> pdfs = DocGenController.getRecordPdfs(usr.Id); Test.stopTest(); System.assertEquals(0, pdfs.size(), 'Should return empty list when no PDFs attached'); @@ -1652,14 +1622,8 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetChildRecordPdfs() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; - - // Create a child contact - Contact con = new Contact(FirstName = 'Pdf', LastName = 'Child', AccountId = acc.Id); - insert con; - + System.runAs(new User(Id = UserInfo.getUserId())) { + User managedUser = getManagedUser(); // Attach a PDF to the contact ContentVersion pdfCv = new ContentVersion( Title = 'ChildPdf', @@ -1669,21 +1633,21 @@ private class DocGenControllerTests { insert pdfCv; Id pdfDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :pdfCv.Id].ContentDocumentId; insert new ContentDocumentLink( - LinkedEntityId = con.Id, + LinkedEntityId = managedUser.Id, ContentDocumentId = pdfDocId, ShareType = 'V' ); Test.startTest(); List> result = DocGenController.getChildRecordPdfs( - acc.Id, 'Contact', 'AccountId', null + UserInfo.getUserId(), 'User', 'ManagerId', null ); Test.stopTest(); System.assert(!result.isEmpty(), 'Should return at least one child record group'); Boolean foundChild = false; for (Map grp : result) { - if (grp.get('childRecordName') == 'Pdf Child') { + if (grp.get('childRecordName') == ManagedUser.Name) { foundChild = true; List> pdfs = (List>) grp.get('pdfs'); System.assert(!pdfs.isEmpty(), 'Child should have at least one PDF'); @@ -1691,18 +1655,17 @@ private class DocGenControllerTests { break; } } - System.assert(foundChild, 'Should find the child contact in results'); + System.assert(foundChild, 'Should find the child users in results'); } } @IsTest static void testGetChildRecordPdfsNullParams() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Boolean caughtException = false; try { - DocGenController.getChildRecordPdfs(null, 'Contact', 'AccountId', null); + DocGenController.getChildRecordPdfs(null, 'User', 'CreatedById', null); } catch (AuraHandledException e) { caughtException = true; } @@ -1714,14 +1677,13 @@ private class DocGenControllerTests { @IsTest static void testGetChildRecordPdfsInvalidObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.getChildRecordPdfs(acc.Id, 'NonExistentObject__xyz', 'AccountId', null); + DocGenController.getChildRecordPdfs(usr.Id, 'NonExistentObject__xyz', 'CreatedById', null); } catch (AuraHandledException e) { caughtException = true; } @@ -1733,14 +1695,13 @@ private class DocGenControllerTests { @IsTest static void testGetChildRecordPdfsInvalidLookup() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Test.startTest(); Boolean caughtException = false; try { - DocGenController.getChildRecordPdfs(acc.Id, 'Contact', 'FakeField__xyz', null); + DocGenController.getChildRecordPdfs(usr.Id, 'User', 'FakeField__xyz', null); } catch (AuraHandledException e) { caughtException = true; } @@ -1752,15 +1713,12 @@ private class DocGenControllerTests { @IsTest static void testGetChildRecordPdfsNoChildren() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - // Create a fresh account with no child contacts - Account freshAcc = new Account(Name = 'No Children Account'); - insert freshAcc; + System.runAs(new User(Id = UserInfo.getUserId())) { + User managedUser = getManagedUser(); Test.startTest(); List> result = DocGenController.getChildRecordPdfs( - freshAcc.Id, 'Contact', 'AccountId', null + managedUser.Id, 'User', 'ManagerId', null ); Test.stopTest(); @@ -1773,8 +1731,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetAvailableReportsNoTerm() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> reports = DocGenController.getAvailableReports(null); Test.stopTest(); @@ -1786,8 +1743,7 @@ private class DocGenControllerTests { @IsTest static void testGetAvailableReportsWithTerm() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> reports = DocGenController.getAvailableReports('ZZZNONEXISTENT999'); Test.stopTest(); @@ -1800,8 +1756,7 @@ private class DocGenControllerTests { @IsTest static void testGetAvailableReportsBlankTerm() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> reports = DocGenController.getAvailableReports(''); Test.stopTest(); @@ -1815,8 +1770,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testCreateSampleTemplates() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Integer count = DocGenController.createSampleTemplates(); Test.stopTest(); @@ -1847,16 +1801,15 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testOptionFourArgConstructor() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGenController.Option opt = new DocGenController.Option( - 'Contacts', 'Contacts', 'Contact', 'AccountId' + 'ManagedUsers', 'ManagedUsers', 'User', 'CreatedById' ); - System.assertEquals('Contacts', opt.label, 'Label should match'); - System.assertEquals('Contacts', opt.value, 'Value should match'); - System.assertEquals('Contact', opt.childObjectApiName, 'Child object API name should match'); - System.assertEquals('AccountId', opt.lookupField, 'Lookup field should match'); + System.assertEquals('ManagedUsers', opt.label, 'Label should match'); + System.assertEquals('ManagedUsers', opt.value, 'Value should match'); + System.assertEquals('User', opt.childObjectApiName, 'Child object API name should match'); + System.assertEquals('CreatedById', opt.lookupField, 'Lookup field should match'); } } @@ -1865,8 +1818,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testImportReportConfigNullId() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Boolean caughtException = false; try { @@ -1882,8 +1834,7 @@ private class DocGenControllerTests { @IsTest static void testImportReportConfigInvalidId() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Use a fake report ID — will fail at Reports.ReportManager.describeReport Id fakeReportId = '00O000000000000AAA'; @@ -1907,8 +1858,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testImportReportConfigWithRealReport() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { List reports = [SELECT Id FROM Report LIMIT 1]; if (reports.isEmpty()) { // No reports in org — skip gracefully @@ -1940,8 +1890,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testNormalizeImageUrlsServletPath() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { String html = ''; Test.startTest(); @@ -1957,8 +1906,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeImageUrlsSfcPath() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { String html = ''; Test.startTest(); @@ -1972,8 +1920,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeImageUrlsNoMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { String html = ''; Test.startTest(); @@ -1989,8 +1936,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testResolveRichTextImagesWithSfcUrl() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map data = new Map{ 'Description' => '

test

', 'Name' => 'Test Account' @@ -2010,8 +1956,7 @@ private class DocGenControllerTests { @IsTest static void testResolveRichTextImagesWithServletUrl() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map data = new Map{ 'RichField' => '

test

' }; @@ -2028,8 +1973,7 @@ private class DocGenControllerTests { @IsTest static void testResolveRichTextImagesNestedList() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Test the List recursion path Map childRecord = new Map{ 'Notes' => '

child

' @@ -2052,8 +1996,7 @@ private class DocGenControllerTests { @IsTest static void testResolveRichTextImagesNoImageTags() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map data = new Map{ 'Description' => '

Just plain text with no images

' }; @@ -2072,21 +2015,19 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testResolveReportBaseObjectDirectMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - // Strategy 1: Direct match — 'Account' is a real object - String result = DocGenController.resolveReportBaseObject('Account'); + // Strategy 1: Direct match — 'User' is a real object + String result = DocGenController.resolveReportBaseObject('User'); Test.stopTest(); - System.assertEquals('Account', result, 'Direct object name should resolve'); + System.assertEquals('User', result, 'Direct object name should resolve'); } } @IsTest static void testResolveReportBaseObjectListSuffix() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); // Strategy 2: Strip 'List' suffix String result = DocGenController.resolveReportBaseObject('AccountList'); @@ -2098,8 +2039,7 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectLabelMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); // Strategy 4: Label match — 'Accounts' is the plural label for Account String result = DocGenController.resolveReportBaseObject('UnknownType', 'Accounts'); @@ -2111,21 +2051,19 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectPrefixMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - // Strategy 5: Prefix match — OpportunityLead starts with Opportunity - String result = DocGenController.resolveReportBaseObject('OpportunityLead'); + // Strategy 5: Prefix match — UserLead starts with User + String result = DocGenController.resolveReportBaseObject('UserLead'); Test.stopTest(); - System.assertEquals('Opportunity', result, 'OpportunityLead should resolve to Opportunity'); + System.assertEquals('User', result, 'UserLead should resolve to User'); } } @IsTest static void testResolveReportBaseObjectCustomWithCommonName() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); // Strategy 6: Custom report type containing a known object name String result = DocGenController.resolveReportBaseObject('MyAccount__c'); @@ -2137,8 +2075,7 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectNull() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String result = DocGenController.resolveReportBaseObject(null); Test.stopTest(); @@ -2149,8 +2086,7 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectBlank() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String result = DocGenController.resolveReportBaseObject(''); Test.stopTest(); @@ -2161,8 +2097,7 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectUnresolvable() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String result = DocGenController.resolveReportBaseObject('ZZZNonExistent999'); Test.stopTest(); @@ -2173,8 +2108,7 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectAccountContact() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String result = DocGenController.resolveReportBaseObject('AccountContact'); Test.stopTest(); @@ -2185,8 +2119,7 @@ private class DocGenControllerTests { @IsTest static void testResolveReportBaseObjectCaseContact() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String result = DocGenController.resolveReportBaseObject('CaseContact'); Test.stopTest(); @@ -2200,8 +2133,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testNormalizeReportColumnDirectApiMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2222,8 +2154,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnLabelMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2235,7 +2166,7 @@ private class DocGenControllerTests { Test.startTest(); // Label match — "Account Name" is the label for the Name field on Account - String result = DocGenController.normalizeReportColumn('SOMETHING_UNKNOWN', 'Account Name', fieldMap, labelToApiName, properCaseMap); + String result = DocGenController.normalizeReportColumn('SOMETHING_UNKNOWN', Account.Name.getDescribe().getLabel(), fieldMap, labelToApiName, properCaseMap); Test.stopTest(); System.assertEquals('Name', result, 'Label "Account Name" should resolve to Name'); @@ -2244,8 +2175,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnHardcodedMapping() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Opportunity.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2266,8 +2196,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnCreatedDate() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2287,8 +2216,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnWithObjectPrefix() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2309,8 +2237,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnBlank() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2323,8 +2250,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnNull() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2337,8 +2263,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnUsersPrefix() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2352,8 +2277,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnNoMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2366,8 +2290,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnStageName() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Opportunity.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2387,8 +2310,7 @@ private class DocGenControllerTests { @IsTest static void testNormalizeReportColumnAmount() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map fieldMap = Schema.SObjectType.Opportunity.fields.getMap(); Map labelToApiName = new Map(); Map properCaseMap = new Map(); @@ -2411,8 +2333,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testMapReportOperatorEquals() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); System.assertEquals('=', DocGenController.mapReportOperator('equals'), 'equals should map to ='); System.assertEquals('!=', DocGenController.mapReportOperator('notEqual'), 'notEqual should map to !='); @@ -2430,8 +2351,7 @@ private class DocGenControllerTests { @IsTest static void testMapReportOperatorNull() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); System.assertEquals('=', DocGenController.mapReportOperator(null), 'Null should default to ='); System.assertEquals('=', DocGenController.mapReportOperator(''), 'Blank should default to ='); @@ -2441,8 +2361,7 @@ private class DocGenControllerTests { @IsTest static void testMapReportOperatorUnknown() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); System.assertEquals('=', DocGenController.mapReportOperator('unknownOp'), 'Unknown operator should default to ='); Test.stopTest(); @@ -2454,51 +2373,46 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testFindParentObjectDirectChild() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Contact is a direct child of Account — should return Account as parent Map childRelNameByObject = new Map{ - 'Contact' => 'Contacts' + 'User' => 'Contacts' }; Test.startTest(); - String result = DocGenController.findParentObject('Contact', 'Account', childRelNameByObject); + String result = DocGenController.findParentObject('User', 'User', childRelNameByObject); Test.stopTest(); - System.assertEquals('Account', result, 'Contact with only Account in hierarchy should return Account'); + System.assertEquals('User', result, 'Contact with only Account in hierarchy should return Account'); } } @IsTest static void testFindParentObjectGrandchild() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - // OpportunityLineItem has a reference to Opportunity - // If Opportunity is also a known child, it should be the parent + System.runAs(new User(Id = UserInfo.getUserId())) { + // ManagedUsers are child Users, and User has CreatedBy lookup to User. Map childRelNameByObject = new Map{ - 'Opportunity' => 'Opportunities', - 'OpportunityLineItem' => 'OpportunityLineItems' + 'User' => 'ManagedUsers' }; Test.startTest(); - String result = DocGenController.findParentObject('OpportunityLineItem', 'Account', childRelNameByObject); + String result = DocGenController.findParentObject('User', 'User', childRelNameByObject); Test.stopTest(); - System.assertEquals('Opportunity', result, 'OpportunityLineItem should resolve parent as Opportunity when Opportunity is a known child'); + System.assertEquals('User', result, 'User child should resolve parent as User via CreatedBy'); } } @IsTest static void testFindParentObjectInvalidObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map childRelNameByObject = new Map(); Test.startTest(); - String result = DocGenController.findParentObject('NonExistentObj__xyz', 'Account', childRelNameByObject); + String result = DocGenController.findParentObject('NonExistentObj__xyz', 'User', childRelNameByObject); Test.stopTest(); - System.assertEquals('Account', result, 'Invalid object should fall back to base object'); + System.assertEquals('User', result, 'Invalid object should fall back to base object'); } } @@ -2507,8 +2421,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testFindJunctionRelationshipKnown() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { SObjectType oppType = Schema.getGlobalDescribe().get('Opportunity'); Test.startTest(); @@ -2522,8 +2435,7 @@ private class DocGenControllerTests { @IsTest static void testFindJunctionRelationshipCampaignContact() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { SObjectType campType = Schema.getGlobalDescribe().get('Campaign'); Test.startTest(); @@ -2536,13 +2448,12 @@ private class DocGenControllerTests { @IsTest static void testFindJunctionRelationshipNoMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - SObjectType accType = Schema.getGlobalDescribe().get('Account'); + System.runAs(new User(Id = UserInfo.getUserId())) { + SObjectType usrType = Schema.getGlobalDescribe().get('User'); Test.startTest(); // Account has no junction to Product2 - String result = DocGenController.findJunctionRelationship('Account', 'Product2', accType); + String result = DocGenController.findJunctionRelationship('User', 'Product2', usrType); Test.stopTest(); // May return null or a dynamically discovered junction @@ -2556,31 +2467,29 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testHasReferenceToObjectTrue() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - // OpportunityContactRole has a reference to Contact - SObjectType ocrType = Schema.getGlobalDescribe().get('OpportunityContactRole'); + System.runAs(new User(Id = UserInfo.getUserId())) { + // User has a reference to Contact + SObjectType ocrType = Schema.getGlobalDescribe().get('User'); Test.startTest(); - Boolean result = DocGenController.hasReferenceToObject(ocrType, 'Contact'); + Boolean result = DocGenController.hasReferenceToObject(ocrType, 'User'); Test.stopTest(); - System.assertEquals(true, result, 'OpportunityContactRole should have a reference to Contact'); + System.assertEquals(true, result, 'User should have a reference to Contact'); } } @IsTest static void testHasReferenceToObjectFalse() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - // Account does not have a reference to OpportunityLineItem - SObjectType accType = Schema.getGlobalDescribe().get('Account'); + System.runAs(new User(Id = UserInfo.getUserId())) { + // Account does not have a reference to User + SObjectType usrType = Schema.getGlobalDescribe().get('User'); Test.startTest(); - Boolean result = DocGenController.hasReferenceToObject(accType, 'OpportunityLineItem'); + Boolean result = DocGenController.hasReferenceToObject(usrType, 'Organization'); Test.stopTest(); - System.assertEquals(false, result, 'Account should not have a reference to OpportunityLineItem'); + System.assertEquals(false, result, 'User should not have a reference to Organization'); } } @@ -2589,8 +2498,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testResolveParentFieldNameDirect() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map accFieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2604,8 +2512,7 @@ private class DocGenControllerTests { @IsTest static void testResolveParentFieldNameCaseInsensitive() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map accFieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2619,13 +2526,12 @@ private class DocGenControllerTests { @IsTest static void testResolveParentFieldNameLabelMatch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map accFieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); // Label-based match — "Email" is the label for Email on User - String result = DocGenController.resolveParentFieldName('Owner', 'EMAIL_FIELD', 'Email', accFieldMap); + String result = DocGenController.resolveParentFieldName('Owner', 'EMAIL_FIELD', User.Email.getDescribe().getLabel(), accFieldMap); Test.stopTest(); System.assertEquals('Email', result, 'Label "Email" should resolve to Email on User'); @@ -2634,8 +2540,7 @@ private class DocGenControllerTests { @IsTest static void testResolveParentFieldNameNullRelationship() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map accFieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2649,8 +2554,7 @@ private class DocGenControllerTests { @IsTest static void testResolveParentFieldNameNoMatchingField() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Map accFieldMap = Schema.SObjectType.Account.fields.getMap(); Test.startTest(); @@ -2667,8 +2571,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGenerateDocumentDataFromCacheNullTemplateId() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Boolean caughtException = false; try { @@ -2686,8 +2589,7 @@ private class DocGenControllerTests { @IsTest static void testGenerateDocumentDataFromCacheTemplateNotFound() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Id fakeTemplateId = DocGen_Template__c.SObjectType.getDescribe(SObjectDescribeOptions.DEFERRED).getKeyPrefix() + '000000000000'; Test.startTest(); @@ -2707,8 +2609,7 @@ private class DocGenControllerTests { @IsTest static void testGenerateDocumentDataFromCacheSuccess() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; // Build a minimal valid DOCX and attach to template @@ -2763,8 +2664,7 @@ private class DocGenControllerTests { @IsTest static void testGenerateDocumentDataFromCacheReusesCache() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; // Attach a valid DOCX @@ -2826,14 +2726,10 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testGetChildRecordPdfsWithFilter() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; - insert new Contact(FirstName = 'FilterTest', LastName = 'Contact', AccountId = acc.Id); - + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List> result = DocGenController.getChildRecordPdfs( - acc.Id, 'Contact', 'AccountId', 'LastName = \'Contact\'' + UserInfo.getUserId(), 'User', 'CreatedById', 'LastName != null' ); Test.stopTest(); @@ -2847,11 +2743,10 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testProcessAndReturnDocumentWithImagesParam() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { createTestFile(); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account WHERE Name = 'Controller Test Account' LIMIT 1]; + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; Map resolvedImages = new Map{ 'https://example.com/image.png' => 'data:image/png;base64,iVBOR...' @@ -2862,7 +2757,7 @@ private class DocGenControllerTests { try { // Will likely fail because the test template is not a real DOCX, // but exercises the method signature with resolvedImages - DocGenController.processAndReturnDocumentWithImages(tpl.Id, acc.Id, resolvedImages); + DocGenController.processAndReturnDocumentWithImages(tpl.Id, usr.Id, resolvedImages); } catch (AuraHandledException e) { caughtException = true; } @@ -2877,8 +2772,7 @@ private class DocGenControllerTests { // ----------------------------------------------------------------------- @IsTest static void testResolveReportBaseObjectContactList() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String result = DocGenController.resolveReportBaseObject('ContactList'); Test.stopTest(); @@ -2888,33 +2782,30 @@ private class DocGenControllerTests { } @IsTest - static void testResolveReportBaseObjectOpportunityProduct() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + static void testResolveReportBaseObjectUserProduct() { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - String result = DocGenController.resolveReportBaseObject('OpportunityProduct'); + String result = DocGenController.resolveReportBaseObject('UserProduct'); Test.stopTest(); - System.assertEquals('Opportunity', result, 'OpportunityProduct should resolve to Opportunity'); + System.assertEquals('User', result, 'UserProduct should resolve to User'); } } @IsTest - static void testResolveReportBaseObjectOpportunityContactProduct() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + static void testResolveReportBaseObjectUserContactProduct() { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - String result = DocGenController.resolveReportBaseObject('OpportunityContactProduct'); + String result = DocGenController.resolveReportBaseObject('UserContactProduct'); Test.stopTest(); - System.assertEquals('Opportunity', result, 'OpportunityContactProduct should resolve to Opportunity'); + System.assertEquals('User', result, 'UserContactProduct should resolve to User'); } } @IsTest static void testResolveReportBaseObjectLabelWithSuffix() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); // Label "Opportunities with Products" should match Opportunity plural label "Opportunities" String result = DocGenController.resolveReportBaseObject('UnknownXYZ', 'Opportunities with Products'); @@ -2930,8 +2821,7 @@ private class DocGenControllerTests { @IsTest static void testSearchDataProvidersNoFilter() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List providers = DocGenController.searchDataProviders(null); Test.stopTest(); @@ -2944,8 +2834,7 @@ private class DocGenControllerTests { @IsTest static void testSearchDataProvidersWithSearchTerm() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List providers = DocGenController.searchDataProviders('NonExistentClassName12345'); Test.stopTest(); @@ -2957,8 +2846,7 @@ private class DocGenControllerTests { @IsTest static void testSearchDataProvidersEmptyString() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); List providers = DocGenController.searchDataProviders(''); Test.stopTest(); @@ -2973,8 +2861,7 @@ private class DocGenControllerTests { @IsTest static void testValidateDataProviderBlankClassName() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Map result = DocGenController.validateDataProvider(''); Test.stopTest(); @@ -2986,8 +2873,7 @@ private class DocGenControllerTests { @IsTest static void testValidateDataProviderNullClassName() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Map result = DocGenController.validateDataProvider(null); Test.stopTest(); @@ -2999,8 +2885,7 @@ private class DocGenControllerTests { @IsTest static void testValidateDataProviderNonExistentClass() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Map result = DocGenController.validateDataProvider('CompletelyFakeClassName99999'); Test.stopTest(); @@ -3013,11 +2898,10 @@ private class DocGenControllerTests { @IsTest static void testValidateDataProviderNonImplementingClass() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // Account is a real class/type but does not implement DocGenDataProvider Test.startTest(); - Map result = DocGenController.validateDataProvider('Account'); + Map result = DocGenController.validateDataProvider('User'); Test.stopTest(); System.assertEquals(false, result.get('valid'), 'Should be invalid for class that does not implement DocGenDataProvider'); diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index f1c0246..919c266 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -1,19 +1,52 @@ -@IsTest +@IsTest(IsParallel=false) private class DocGenMiscTests { - + private static String CHATTER_FREE_USER_PROFILE_NAME = 'Chatter Free User'; + private static String UNIQUE_IDENTIFIER = 'DocGenMiscTests'; + private static String UNIQUE_USERNAME = String.format('manageduser+{0}@test.com{1}', new List{UNIQUE_IDENTIFIER, UserInfo.getUserId()}); @TestSetup static void setup() { - Account acc = new Account(Name = 'Misc Test Account', AccountSource = 'Web', Website = 'https://test.com'); - insert acc; - DocGen_Template__c tpl = new DocGen_Template__c( Name = 'Misc Test Template', - Base_Object_API__c = 'Account', - Query_Config__c = 'Name, AccountSource', + Base_Object_API__c = 'User', + Query_Config__c = 'Email, Name, CreatedBy.Email, LastName', Type__c = 'Word' ); insert tpl; - } + + + createManagedUser(UNIQUE_IDENTIFIER); + } + + private static void updateUser(User usr) { + // avoid mixed dml + System.runAs(new User(Id = UserInfo.getUserId())) { + update usr; + } + } + + private static void createManagedUser(String uniqueIdentifier) { + System.runAs(new User(Id = UserInfo.getUserId())) { + // this should create a new chatter free user and set the manager to the current user + User usr = new User( + FirstName = 'Managed', + LastName = 'User', + Email = 'manageduser@test.com', + Username = UNIQUE_USERNAME, + Alias = 'muser', + LanguageLocaleKey = 'en_US', + LocaleSidKey = 'en_US', + EmailEncodingKey = 'UTF-8', + ManagerId = UserInfo.getUserId(), + ProfileId = [SELECT Id FROM Profile WHERE Name = 'Chatter Free User' LIMIT 1].Id, + TimeZoneSidKey = 'America/Los_Angeles' + ); + insert usr; + } + } + + private static User getManagedUser() { + return [SELECT Id, Name, FirstName, LastName, CreatedBy.Name FROM User WHERE Username = :UNIQUE_USERNAME LIMIT 1]; + } /** * Creates a ContentVersion linked to the test template (fake content). @@ -85,8 +118,7 @@ private class DocGenMiscTests { @IsTest static void testGetOrgUrl() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); String orgUrl = DocGenSetupController.getOrgUrl(); Test.stopTest(); @@ -98,8 +130,7 @@ private class DocGenMiscTests { @IsTest static void testGetSettings() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); DocGen_Settings__c settings = DocGenSetupController.getSettings(); Test.stopTest(); @@ -110,8 +141,7 @@ private class DocGenMiscTests { @IsTest static void testSaveSettings() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); DocGenSetupController.saveSettings('https://example.my.site.com'); Test.stopTest(); @@ -123,8 +153,7 @@ private class DocGenMiscTests { @IsTest static void testSaveSettingsUpdatesExisting() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGenSetupController.saveSettings('https://first.my.site.com'); Test.startTest(); @@ -142,30 +171,27 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithValidFields() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, AccountSource'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, Id'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for valid fields'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match test account'); - System.assertEquals('Web', result.get('AccountSource'), 'AccountSource should match test account'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match test User'); + System.assertEquals(UserInfo.getUserId(), result.get('Id'), 'Id should match test User'); } } @IsTest static void testGetRecordDataWithInvalidObject() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Boolean caught = false; Test.startTest(); try { - DocGenDataRetriever.getRecordData(acc.Id, 'NotARealObject__c', 'Name'); + DocGenDataRetriever.getRecordData(usr.Id, 'NotARealObject__c', 'Name'); } catch (AuraHandledException e) { caught = true; } @@ -177,8 +203,7 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithBlankParams() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Boolean caught = false; Test.startTest(); @@ -195,51 +220,46 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithRelationshipField() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, Owner.Name'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, CreatedBy.Name'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for relationship field query'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match test account'); - System.assert(result.containsKey('Owner'), 'Should contain Owner relationship data'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match test User'); + System.assert(result.containsKey('CreatedBy'), 'Should contain CreatedBy relationship data'); } } @IsTest static void testGetRecordDataWithNonExistentField() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, TotallyFakeField__xyz'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, TotallyFakeField__xyz'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null even with non-existent field'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should still be returned with non-existent field'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should still be returned with non-existent field'); } } @IsTest static void testGetRecordDataWithBlankObjectOrFields() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Boolean caught1 = false; Boolean caught2 = false; Test.startTest(); try { - DocGenDataRetriever.getRecordData(acc.Id, '', 'Name'); + DocGenDataRetriever.getRecordData(usr.Id, '', 'Name'); } catch (AuraHandledException e) { caught1 = true; } try { - DocGenDataRetriever.getRecordData(acc.Id, 'Account', ''); + DocGenDataRetriever.getRecordData(usr.Id, 'User', ''); } catch (AuraHandledException e) { caught2 = true; } @@ -257,11 +277,11 @@ private class DocGenMiscTests { @IsTest static void testGenerateDocumentFlowActionErrorPath() { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); DocGenFlowAction.Request req = new DocGenFlowAction.Request(); req.templateId = tpl.Id; - req.recordId = acc.Id; + req.recordId = usr.Id; Test.startTest(); List responses = DocGenFlowAction.generateDocument( @@ -308,11 +328,11 @@ private class DocGenMiscTests { static void testProcessDocumentThrowsOnInvalidDocx() { createTestFile(); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); try { - DocGenService.processDocument(tpl.Id, acc.Id); + DocGenService.processDocument(tpl.Id, usr.Id); System.assert(false, 'Expected exception for invalid DOCX'); } catch (DocGenException e) { System.assert(e.getMessage().length() > 0, 'DocGenException message should not be empty'); @@ -324,11 +344,11 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentNullTemplate() { - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); try { - DocGenService.processDocument(null, acc.Id); + DocGenService.processDocument(null, usr.Id); System.assert(false, 'Expected exception for null template'); } catch (Exception e) { System.assert(e.getMessage().length() > 0, 'Exception message should not be empty for null template'); @@ -365,10 +385,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result, 'processDocument result should not be null'); @@ -386,10 +406,9 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; Test.startTest(); - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Should return ContentDocumentId'); @@ -408,10 +427,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Merged runs document blob should not be null'); @@ -419,7 +438,7 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentWithSectionTag() { - // Boolean-true section (Account always truthy via Owner) + // Boolean-true section (User always truthy via Owner) String docXml = '' + '' + @@ -428,10 +447,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for section tag test'); @@ -439,23 +458,23 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentWithHtmlFieldValue() { - // Account Website field is plain text, but we test that HTML stripping works - // by using a field config that includes Website + // User Signature field is plain text, but we test that HTML stripping works + // by using a field config that includes Signature String docXml = '' + '' + - '{Name} - {Website}'; + '{Name} - {Signature}'; createValidDocxFile(docXml); - // Update template query config to include Website + // Update template query config to include Signature DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, AccountSource, Website'; + tpl.Query_Config__c = 'Name, AccountSource, Signature'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for HTML field value test'); @@ -470,18 +489,16 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Document_Title_Format__c = 'Doc_{Name}_{AccountSource}'; + tpl.Document_Title_Format__c = 'Doc_{Name}_{Id}'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); String title = (String) result.get('title'); - System.assert(title.contains('Misc Test Account'), 'Title should contain account name'); - System.assert(title.contains('Web'), 'Title should contain account source'); + System.assert(title.contains(UserInfo.getName()), 'Title should contain User name'); + System.assert(title.contains(UserInfo.getUserId()), 'Title should contain User source'); } @IsTest @@ -494,10 +511,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for image tag test'); @@ -513,10 +530,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for image size tag test'); @@ -532,10 +549,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for closing tag only test'); @@ -551,12 +568,12 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Map result; Test.startTest(); try { - result = DocGenService.processDocument(tpl.Id, acc.Id); + result = DocGenService.processDocument(tpl.Id, usr.Id); } catch (DocGenException e) { System.assert(e.getMessage().contains('Malformed loop tag'), 'Exception message should contain "Malformed loop tag"'); } @@ -599,10 +616,10 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for header and footer test'); @@ -620,10 +637,8 @@ private class DocGenMiscTests { tpl.Output_Format__c = 'PDF'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Document ID should not be null for PDF format test'); @@ -637,13 +652,12 @@ private class DocGenMiscTests { static void testProcessDocumentWithChildSubquery() { // Use Contacts subquery to exercise loop processing with records list DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, (SELECT FirstName, LastName FROM Contacts)'; - tpl.Base_Object_API__c = 'Account'; + tpl.Query_Config__c = 'Name, (SELECT Email FROM ManagedUsers)'; + tpl.Base_Object_API__c = 'User'; update tpl; - // Add a contact to the account - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new Contact(FirstName = 'Loop', LastName = 'Tester', AccountId = acc.Id); + // Add a contact to the User + User usr = getManagedUser(); String docXml = '' + @@ -654,7 +668,7 @@ private class DocGenMiscTests { createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for child subquery test'); @@ -664,11 +678,10 @@ private class DocGenMiscTests { static void testProcessDocumentWithTableRowLoop() { // Section inside a — exercises table row expansion code DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, (SELECT FirstName, LastName FROM Contacts)'; + tpl.Query_Config__c = 'Name, (SELECT Email FROM ManagedUsers)'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new Contact(FirstName = 'Row', LastName = 'Expand', AccountId = acc.Id); + User usr = getManagedUser(); String docXml = '' + @@ -680,7 +693,7 @@ private class DocGenMiscTests { createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for table row loop test'); @@ -699,10 +712,10 @@ private class DocGenMiscTests { '{Owner.Name}'; createValidDocxFile(docXml); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for nested dot path test'); @@ -723,23 +736,23 @@ private class DocGenMiscTests { // Template with {%ImageField} — field value will be a CV Id // We need the query to return a field containing the CV Id - // Use Description field on Account to hold the CV Id - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = imgCv.Id; - update acc; + // Use AboutMe field on User to hold the CV Id + User usr = getManagedUser(); + usr.AboutMe = imgCv.Id; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description:100x80}'; + '{%AboutMe:100x80}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for base64 image field test'); @@ -748,22 +761,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentWithHtmlRichText() { // Field value contains

/

HTML that should be stripped - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Hello

World
'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Hello

World
'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for HTML rich text test'); @@ -800,10 +813,10 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for PowerPoint test'); @@ -823,14 +836,12 @@ private class DocGenMiscTests { tpl.Document_Title_Format__c = null; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); String title = (String) result.get('title'); - System.assertEquals('Misc Test Account', title, 'Should fall back to record Name'); + System.assertEquals(UserInfo.getName(), title, 'Should fall back to record Name'); } // ========================================================================= @@ -840,52 +851,47 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithSubquery() { // Exercises the subquery (parenthesized SELECT) branch in field parsing - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new Contact(FirstName = 'Sub', LastName = 'Query', AccountId = acc.Id); - Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, (SELECT FirstName, LastName FROM Contacts)'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, (SELECT Email FROM ManagedUsers)'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for subquery test'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match for subquery test'); - System.assert(result.containsKey('Contacts'), 'Should contain Contacts subquery'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match for subquery test'); + System.assert(result.containsKey('ManagedUsers'), 'Should contain ManagedUsers subquery'); } @IsTest static void testGetRecordDataWithAutoAppendC() { // Exercises the __c auto-append branch — AccountSource exists, but try without __c - // "Website" is a standard field so this tests the field-not-found skip path - Account acc = [SELECT Id FROM Account LIMIT 1]; + // "Signature" is a standard field so this tests the field-not-found skip path Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, ZZZInvalidField'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, ZZZInvalidField'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for auto-append test'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match for auto-append test'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match for auto-append test'); } @IsTest static void testGetRecordDataWithInvalidRelationship() { // Exercises the invalid relationship field branch (relFound = false) - Account acc = [SELECT Id FROM Account LIMIT 1]; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, FakeRel__r.Field'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, FakeRel__r.Field'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for invalid relationship test'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match for invalid relationship test'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match for invalid relationship test'); } @IsTest static void testGetRecordDataAllFieldsInvalid() { // Exercises the cleanSelect == '' fallback to 'Id' - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'ZZZFake1, ZZZFake2'); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', 'ZZZFake1, ZZZFake2'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for all-fields-invalid test'); @@ -895,33 +901,30 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithBlankFieldPart() { // Exercises the blank field part skip (trailing comma) - Account acc = [SELECT Id FROM Account LIMIT 1]; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, , AccountSource'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, , Id'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for blank field part test'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match for blank field part test'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match for blank field part test'); } @IsTest static void testGetRecordDataWithAddressField() { // Exercises the Address type branch in mapSObject - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.BillingStreet = '123 Main St'; - acc.BillingCity = 'TestCity'; - acc.BillingState = 'CA'; - acc.BillingPostalCode = '90210'; - acc.BillingCountry = 'US'; - update acc; + User usr = getManagedUser(); + usr.Street = '123 Main St'; + usr.City = 'TestCity'; + usr.PostalCode = '90210'; + updateUser(usr); Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, BillingAddress'); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', 'Name, Address'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for address field test'); - System.assert(result.containsKey('BillingAddress'), 'Should contain BillingAddress compound field'); + System.assert(result.containsKey('Address'), 'Should contain BillingAddress compound field'); } // ========================================================================= @@ -942,11 +945,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; DocGenFlowAction.Request req = new DocGenFlowAction.Request(); req.templateId = tpl.Id; - req.recordId = acc.Id; + req.recordId = UserInfo.getUserId(); Test.startTest(); List responses = DocGenFlowAction.generateDocument( @@ -1004,10 +1006,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Should return a PDF blob'); @@ -1041,12 +1043,12 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); Boolean caught = false; try { - DocGenService.generatePdfBlob(tpl.Id, acc.Id); + DocGenService.generatePdfBlob(tpl.Id, usr.Id); } catch (DocGenException e) { caught = true; System.assert(e.getMessage().contains('only supported for Word'), 'Exception should mention Word-only support'); @@ -1060,44 +1062,42 @@ private class DocGenMiscTests { String docXml = '' + '' + - 'Account: {Name}, Source: {AccountSource}'; + 'User: {Name}, Source: {Id}'; createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; tpl.Document_Title_Format__c = 'PDF_{Name}'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for PDF blob with title format'); String title = (String) result.get('title'); - System.assert(title.contains('Misc Test Account'), 'PDF title should contain account name'); + System.assert(title.contains(UserInfo.getName()), 'PDF title should contain User name'); } @IsTest static void testGeneratePdfBlobWithImage() { // PDF path with image — exercises imageBase64ByRelId collection String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('PNGFAKE')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'data:image/png;base64,' + tinyBase64; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'data:image/png;base64,' + tinyBase64; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for PDF blob with image'); @@ -1119,10 +1119,8 @@ private class DocGenMiscTests { tpl.Output_Format__c = 'PDF'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Document ID should not be null for PDF save test'); @@ -1161,10 +1159,8 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Document ID should not be null for PPTX save test'); @@ -1179,22 +1175,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentConditionalSectionTruthyString() { // Section where key resolves to a non-null string — exercises the non-null fallback path - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'Has description'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'Has AboutMe'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'UserName, AboutMe'; update tpl; String docXml = '' + '' + - '{#Description}Has Desc: {Description}{/Description}'; + '{#AboutMe}Has Desc: {AboutMe}{/AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for truthy conditional section'); @@ -1204,19 +1200,19 @@ private class DocGenMiscTests { static void testProcessDocumentConditionalSectionNullValue() { // Section where key resolves to null — produces empty output for section DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{#Description}This should not appear{/Description}'; + '{#AboutMe}This should not appear{/AboutMe}'; createValidDocxFile(docXml); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for null conditional section'); @@ -1239,10 +1235,10 @@ private class DocGenMiscTests { '{CreatedDate:MM/dd/yyyy}'; createValidDocxFile(docXml); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for date format tag test'); @@ -1259,10 +1255,10 @@ private class DocGenMiscTests { '{Name:uppercase}'; createValidDocxFile(docXml); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for colon in non-date field'); @@ -1275,22 +1271,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentHtmlSpanAndBrStrip() { // Field value with and
tags — exercises stripHtmlTags path - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Line one


Line two'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Line one


Line two'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for HTML span/br strip test'); @@ -1305,22 +1301,22 @@ private class DocGenMiscTests { // Field value contains — exercises // the rich text image detection and extractImageFromHtml data URI path String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('PNGDATA')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = ''; - update acc; + User usr = getManagedUser(); + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'UserName, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for field with img tag test'); @@ -1328,24 +1324,24 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagDataUri() { - // {%Description} where Description is a data:image/jpeg URI — exercises data URI branch in buildImageXml + // {%AboutMe} where AboutMe is a data:image/jpeg URI — exercises data URI branch in buildImageXml String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('JPEGFAKE')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'data:image/jpeg;base64,' + tinyBase64; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'data:image/jpeg;base64,' + tinyBase64; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for image tag data URI test'); @@ -1353,23 +1349,23 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagBlankValue() { - // {%Description} where Description is blank — exercises null return from buildImageXml - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = ''; - update acc; + // {%AboutMe} where AboutMe is blank — exercises null return from buildImageXml + User usr = getManagedUser(); + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for blank image value test'); @@ -1377,24 +1373,24 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagWithDimensions() { - // {%Description:200x150} with a data URI — exercises size parsing in image tag + // {%AboutMe:200x150} with a data URI — exercises size parsing in image tag String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('SIZED')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'data:image/png;base64,' + tinyBase64; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'data:image/png;base64,' + tinyBase64; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description:200x150}'; + '{%AboutMe:200x150}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for image tag with dimensions'); @@ -1406,29 +1402,29 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagRawBase64() { - // {%Description} where Description is a long raw base64 string (no data URI prefix) + // {%AboutMe} where AboutMe is a long raw base64 string (no data URI prefix) // Exercises the "raw base64" branch (length > 100, no / or space) // Generate a base64 string > 100 chars String longBase64 = EncodingUtil.base64Encode(Blob.valueOf( 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' )); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = longBase64; - update acc; + User usr = getManagedUser(); + usr.AboutMe = longBase64; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for raw base64 image test'); @@ -1436,7 +1432,7 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagContentVersionId() { - // {%Description} where Description holds a ContentVersion ID (starts with 068) + // {%AboutMe} where AboutMe holds a ContentVersion ID (starts with 068) ContentVersion imgCv = new ContentVersion( Title = 'TestImage', PathOnClient = 'TestImage.png', @@ -1445,22 +1441,22 @@ private class DocGenMiscTests { insert imgCv; imgCv = [SELECT Id FROM ContentVersion WHERE Id = :imgCv.Id]; - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = imgCv.Id; - update acc; + User usr = getManagedUser(); + usr.AboutMe = imgCv.Id; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for ContentVersion ID image test'); @@ -1472,24 +1468,24 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagRefidUrl() { - // {%Description} where Description contains a refid URL — exercises fetchSalesforceImage with refid + // {%AboutMe} where AboutMe contains a refid URL — exercises fetchSalesforceImage with refid // Will fail to find image (no matching record) but exercises the code path - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'https://example.com/servlet/rtaImage?refid=0EM000000000001'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'https://example.com/servlet/rtaImage?refid=0EM000000000001'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); // Image could not be resolved, but document should still generate @@ -1498,23 +1494,23 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagServletUrl() { - // {%Description} with /servlet/ URL — exercises the servlet/sfc branch - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '/servlet/filedownload?file=015000000000001'; - update acc; + // {%AboutMe} with /servlet/ URL — exercises the servlet/sfc branch + User usr = getManagedUser(); + usr.AboutMe = '/servlet/filedownload?file=015000000000001'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for servlet URL image test'); @@ -1538,10 +1534,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for three-segment merge test'); @@ -1557,10 +1553,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for no-tags document'); @@ -1580,12 +1576,12 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Map result; Test.startTest(); try { - result = DocGenService.processDocument(tpl.Id, acc.Id); + result = DocGenService.processDocument(tpl.Id, usr.Id); } catch (DocGenException e) { System.assert(e.getMessage().contains('Malformed merge tag'), 'Exception message should contain "Malformed merge tag"'); } @@ -1603,10 +1599,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for empty body test'); @@ -1622,10 +1618,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for null field value test'); @@ -1633,21 +1629,21 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentXmlSpecialChars() { - // Account name with & < > — exercises escapeXml - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Name = 'Acme & Co '; - update acc; + // User name with & < > — exercises escapeXml + User usr = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + usr.AboutMe = 'Acme & Co '; + updateUser(usr); String docXml = '' + '' + - '{Name}'; + '{AboutMe}'; createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for XML special chars test'); @@ -1667,10 +1663,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for case-insensitive field test'); @@ -1689,10 +1685,10 @@ private class DocGenMiscTests { '{Owner.Manager.Name}'; createValidDocxFile(docXml); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for deep nested null path'); @@ -1711,18 +1707,16 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Document_Title_Format__c = 'Doc-{Name}-{MissingField}-{AccountSource}'; + tpl.Document_Title_Format__c = 'Doc-{LastName}-{MissingField}-{Id}'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); String title = (String) result.get('title'); - System.assert(title.contains('Misc Test Account'), 'Title should contain account name for mixed fields'); - System.assert(title.contains('Web'), 'Title should contain account source for mixed fields'); + System.assert(title.contains(UserInfo.getLastName()), 'Title should contain User name for mixed fields'); + System.assert(title.contains(UserInfo.getUserId()), 'Title should contain User source for mixed fields'); System.assert(title.contains('--'), 'Missing field should be replaced with empty string'); } @@ -1733,14 +1727,10 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentTableRowExpansionMultipleRows() { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, (SELECT FirstName, LastName FROM Contacts)'; + tpl.Query_Config__c = 'Name, (SELECT Email FROM ManagedUsers)'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new List{ - new Contact(FirstName = 'Alice', LastName = 'Smith', AccountId = acc.Id), - new Contact(FirstName = 'Bob', LastName = 'Jones', AccountId = acc.Id) - }; + User usr = getManagedUser(); String docXml = '' + @@ -1752,7 +1742,7 @@ private class DocGenMiscTests { createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for multi-row table expansion'); @@ -1764,22 +1754,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentNestedSections() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'Present'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'Present'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description, AccountSource'; + tpl.Query_Config__c = 'Name, AboutMe, AccountSource'; update tpl; String docXml = '' + '' + - '{#Description}{#AccountSource}Both present{/AccountSource}{/Description}'; + '{#AboutMe}{#AccountSource}Both present{/AccountSource}{/AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for nested sections test'); @@ -1793,12 +1783,9 @@ private class DocGenMiscTests { static void testProcessDocumentSectionOutsideTr() { // {#Contacts} before TR, {/Contacts} after TR — exercises trClosesBefore path DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, (SELECT FirstName FROM Contacts)'; + tpl.Query_Config__c = 'Name, (SELECT FirstName FROM ManagedUsers)'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new Contact(FirstName = 'Multi', LastName = 'Row', AccountId = acc.Id); - String docXml = '' + '' + @@ -1808,7 +1795,7 @@ private class DocGenMiscTests { createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for section outside TR test'); @@ -1821,12 +1808,12 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageWithExistingContentTypes() { String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('IMG')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'data:image/png;base64,' + tinyBase64; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'data:image/png;base64,' + tinyBase64; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; Compression.ZipWriter zw = new Compression.ZipWriter(); @@ -1853,7 +1840,7 @@ private class DocGenMiscTests { zw.addEntry('word/document.xml', Blob.valueOf( '' + '' + - '{%Description}' + '{%AboutMe}' )); ContentVersion cv = new ContentVersion(Title='ImgExist', PathOnClient='ImgExist.docx', VersionData=zw.getArchive()); @@ -1862,7 +1849,7 @@ private class DocGenMiscTests { insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for existing content types test'); @@ -1876,23 +1863,23 @@ private class DocGenMiscTests { static void testProcessDocumentMultipleImageTags() { String pngBase64 = EncodingUtil.base64Encode(Blob.valueOf('PNGDATA')); String jpgBase64 = EncodingUtil.base64Encode(Blob.valueOf('JPGDATA')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'data:image/png;base64,' + pngBase64; - acc.Website = 'data:image/jpeg;base64,' + jpgBase64; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'data:image/png;base64,' + pngBase64; + usr.Signature = 'data:image/jpeg;base64,' + jpgBase64; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description, Website'; + tpl.Query_Config__c = 'Name, AboutMe, Signature'; update tpl; String docXml = '' + '' + - '{%Description} {%Website}'; + '{%AboutMe} {%Signature}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for multiple image tags test'); @@ -1937,10 +1924,10 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for multiple slides test'); @@ -1964,10 +1951,8 @@ private class DocGenMiscTests { tpl.Document_Title_Format__c = null; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Document ID should not be null for null title fallback'); @@ -1980,22 +1965,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImgTagWithRefid() { // with refid= attribute — exercises extractImageFromHtml refid branch - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = ''; - update acc; + User usr = getManagedUser(); + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for img tag with refid'); @@ -2004,22 +1989,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImgTagWithSfcUrl() { // with /sfc/ URL — exercises extractImageFromHtml servlet/sfc branch - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = ''; - update acc; + User usr = getManagedUser(); + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for img tag with sfc URL'); @@ -2031,7 +2016,7 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageContentDocumentId() { - // {%Description} where Description holds a ContentDocument ID (069...) + // {%AboutMe} where AboutMe holds a ContentDocument ID (069...) ContentVersion imgCv = new ContentVersion( Title = 'TestImg069', PathOnClient = 'TestImg069.png', @@ -2040,22 +2025,22 @@ private class DocGenMiscTests { insert imgCv; Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :imgCv.Id].ContentDocumentId; - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = String.valueOf(conDocId); - update acc; + User usr = getManagedUser(); + usr.AboutMe = String.valueOf(conDocId); + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for ContentDocument ID test'); @@ -2068,19 +2053,19 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentWithResolvedImages() { // Exercises the resolvedImages map (line 54-56) — applyResolvedImages with tag - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('IMGDATA')); - acc.Description = ''; - update acc; + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Map resolvedImages = new Map{ @@ -2088,7 +2073,7 @@ private class DocGenMiscTests { }; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id, resolvedImages); + Map result = DocGenService.processDocument(tpl.Id, usr.Id, resolvedImages); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for resolved images test'); @@ -2107,10 +2092,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for valid PDF blob test'); @@ -2134,10 +2119,8 @@ private class DocGenMiscTests { tpl.Output_Format__c = 'PDF'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Document ID should not be null for PDF save test'); @@ -2156,14 +2139,14 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Map imageMap = new Map{ 'rIdTest' => 'https://example.com/image.png' }; Test.startTest(); - Map result = DocGenService.processDocumentAsHtmlWithImageMap(tpl.Id, acc.Id, imageMap); + Map result = DocGenService.processDocumentAsHtmlWithImageMap(tpl.Id, usr.Id, imageMap); Test.stopTest(); System.assertNotEquals(null, result.get('html'), 'HTML should not be null for image map test'); @@ -2210,7 +2193,7 @@ private class DocGenMiscTests { Content_Version_Id__c = cv.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account' + Base_Object_API__c = 'User' ); insert ver; @@ -2229,22 +2212,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentRichTextWithFormattingTags() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Bold Italic Underline

'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Bold Italic Underline

'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for rich text formatting tags'); @@ -2252,22 +2235,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentRichTextWithStrongAndEm() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'Strong and Emphasis'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'Strong and Emphasis'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for strong/em tags test'); @@ -2279,22 +2262,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentRichTextImgWithSalesforceUrl() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Text more text

'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Text more text

'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for rich text Salesforce URL img'); @@ -2306,22 +2289,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentImageTagHttpsUrl() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'https://example.com/some-image.png'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = 'https://example.com/some-image.png'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for HTTPS URL image tag'); @@ -2349,21 +2332,21 @@ private class DocGenMiscTests { zw.addEntry('ppt/slides/slide1.xml', Blob.valueOf( '' + '' + - '{%Description}' + '{%AboutMe}' )); ContentVersion cv = new ContentVersion(Title='PPTImg', PathOnClient='PPTImg.pptx', VersionData=zw.getArchive()); insert cv; Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = 'data:image/png;base64,' + EncodingUtil.base64Encode(Blob.valueOf('IMG')); - update acc; - tpl.Query_Config__c = 'Name, Description'; + User usr = getManagedUser(); + usr.AboutMe = 'data:image/png;base64,' + EncodingUtil.base64Encode(Blob.valueOf('IMG')); + updateUser(usr); + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for PowerPoint image tag skip'); @@ -2377,12 +2360,12 @@ private class DocGenMiscTests { static void testProcessDocumentPowerPointHtmlStripped() { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; tpl.Type__c = 'PowerPoint'; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Paragraph bold

'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Paragraph bold

'; + updateUser(usr); Compression.ZipWriter zw = new Compression.ZipWriter(); zw.addEntry('[Content_Types].xml', Blob.valueOf( @@ -2396,7 +2379,7 @@ private class DocGenMiscTests { zw.addEntry('ppt/slides/slide1.xml', Blob.valueOf( '' + '' + - '{Description}' + '{AboutMe}' )); ContentVersion cv = new ContentVersion(Title='PPTHtml2', PathOnClient='PPTHtml2.pptx', VersionData=zw.getArchive()); insert cv; @@ -2404,7 +2387,7 @@ private class DocGenMiscTests { insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for PowerPoint HTML stripped'); @@ -2423,10 +2406,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for PDF download test'); @@ -2449,10 +2432,10 @@ private class DocGenMiscTests { '{CreatedDate:yyyy-MM-dd HH:mm}'; createValidDocxFile(docXml); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for alternate DateTime format'); @@ -2494,10 +2477,10 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for existing template image'); @@ -2527,77 +2510,40 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV2BasicFields() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - // Create Opportunity linked to Account - Opportunity opp = new Opportunity( - Name = 'V2 Test Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) - ); - insert opp; - - String v2Config = '{"v":2,"baseObject":"Opportunity","baseFields":["Name","StageName"],' + - '"parentFields":["Account.Name"],"children":[],"junctions":[]}'; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name","Email"],' + + '"parentFields":["CreatedBy.Name"],"children":[],"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(opp.Id, 'Opportunity', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'V2 result should not be null'); - System.assertEquals('V2 Test Opp', result.get('Name'), 'V2 should return Opp Name'); - System.assert(result.containsKey('Account'), 'V2 should include parent Account data'); - Map accData = (Map) result.get('Account'); - System.assertEquals('Misc Test Account', accData.get('Name'), 'V2 parent Account.Name should match'); + System.assertEquals(usr.Name, result.get('Name'), 'V2 should return User Name'); + System.assert(result.containsKey('CreatedBy'), 'V2 should include parent User data'); + Map CreatedByData = (Map) result.get('CreatedBy'); + System.assertEquals(usr.CreatedBy.Name, CreatedByData.get('Name'), 'V2 parent User.Name should match'); } } @IsTest static void testGetRecordDataV2WithChildren() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Opportunity opp = new Opportunity( - Name = 'V2 Child Test Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) - ); - insert opp; - - // Create Product + PricebookEntry + OpportunityLineItem - Product2 prod = new Product2(Name = 'V2 Test Product', IsActive = true); - insert prod; - Id stdPbId = Test.getStandardPricebookId(); - PricebookEntry pbe = new PricebookEntry( - Pricebook2Id = stdPbId, - Product2Id = prod.Id, - UnitPrice = 100, - IsActive = true - ); - insert pbe; - OpportunityLineItem oli = new OpportunityLineItem( - OpportunityId = opp.Id, - PricebookEntryId = pbe.Id, - Quantity = 5, - UnitPrice = 100 - ); - insert oli; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); - String v2Config = '{"v":2,"baseObject":"Opportunity","baseFields":["Name"],' + - '"parentFields":["Account.Name"],' + - '"children":[{"rel":"OpportunityLineItems","fields":["Quantity","UnitPrice"]}],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + + '"parentFields":["CreatedBy.Name"],' + + '"children":[{"rel":"ManagedUsers","fields":["Email"]}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(opp.Id, 'Opportunity', v2Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'V2 with children should not be null'); - System.assert(result.containsKey('OpportunityLineItems'), 'V2 should contain OpportunityLineItems'); - Map oliData = (Map) result.get('OpportunityLineItems'); + System.assert(result.containsKey('ManagedUsers'), 'V2 should contain ManagedUsers'); + Map oliData = (Map) result.get('ManagedUsers'); Integer totalSize = (Integer) oliData.get('totalSize'); System.assertEquals(1, totalSize, 'V2 should have 1 line item'); } @@ -2605,48 +2551,32 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV2WithJunction() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con = new Contact(FirstName = 'Junction', LastName = 'TestContact', AccountId = acc.Id); - insert con; - - Opportunity opp = new Opportunity( - Name = 'V2 Junction Test Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) - ); - insert opp; - - OpportunityContactRole ocr = new OpportunityContactRole( - OpportunityId = opp.Id, - ContactId = con.Id, - Role = 'Decision Maker' - ); - insert ocr; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + ContentVersion cv = new ContentVersion(Title='Junction Test', PathOnClient='Junction Test.docx', VersionData=Blob.valueOf('FAKEPNGBYTES'), FirstPublishLocationId=UserInfo.getUserId()); + insert cv; - String v2Config = '{"v":2,"baseObject":"Opportunity","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + '"children":[],' + - '"junctions":[{"junctionRel":"OpportunityContactRoles","junctionFields":["Role"],' + - '"targetObject":"Contact","targetIdField":"ContactId","targetFields":["FirstName","LastName"]}]}'; + '"junctions":[{"junctionRel":"ContentDocumentLinks","junctionFields":["ShareType"],' + + '"targetObject":"ContentDocument","targetIdField":"ContentDocumentId","targetFields":["Id","Title"]}]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(opp.Id, 'Opportunity', v2Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'V2 junction result should not be null'); - System.assert(result.containsKey('OpportunityContactRoles'), 'V2 should contain junction relationship'); - Map ocrData = (Map) result.get('OpportunityContactRoles'); - List ocrRecords = (List) ocrData.get('records'); - System.assertEquals(1, ocrRecords.size(), 'V2 should have 1 OCR record'); + System.assert(result.containsKey('ContentDocumentLinks'), 'V2 should contain junction relationship'); + Map ocrData = (Map) result.get('ContentDocumentLinks'); + List cdlRecords = (List) ocrData.get('records'); + System.assert(cdlRecords.size() >= 1, 'V2 should have at least 1 CDL record'); // Verify junction stitching: Contact data should be embedded in OCR record - Map ocrRec = (Map) ocrRecords[0]; - System.assert(ocrRec.containsKey('Contact'), 'Junction should stitch Contact into OCR record'); - Map contactData = (Map) ocrRec.get('Contact'); - System.assertEquals('Junction', contactData.get('FirstName'), 'Stitched Contact FirstName should match'); + Map cdlRec = (Map) cdlRecords[0]; + System.assert(cdlRec.containsKey('ContentDocument'), 'Junction should stitch ContentDocument into CDL record'); + Map contentDocumentData = (Map) cdlRec.get('ContentDocument'); + System.assertEquals('Junction Test', contentDocumentData.get('Title'), 'Stitched ContentDocument Title should match'); } } @@ -2656,106 +2586,77 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV3BasicTree() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con = new Contact(FirstName = 'TreeWalker', LastName = 'TestV3', AccountId = acc.Id); - insert con; - - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":["Owner.Name"],' + + System.runAs(new User(Id = UserInfo.getUserId())) { + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":["CreatedBy.Name"],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName","LastName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts"}]}'; + '{"id":"n1","object":"User","fields":["FirstName","LastName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers"}]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'V3 result should not be null'); - System.assertEquals('Misc Test Account', result.get('Name'), 'V3 root Name should match'); - System.assert(result.containsKey('Owner'), 'V3 should include parent Owner'); - System.assert(result.containsKey('Contacts'), 'V3 should contain Contacts child'); - Map contactsData = (Map) result.get('Contacts'); - Integer totalSize = (Integer) contactsData.get('totalSize'); - System.assert(totalSize >= 1, 'V3 should have at least 1 Contact'); + System.assertEquals(UserInfo.getName(), result.get('Name'), 'V3 root Name should match'); + System.assert(result.containsKey('CreatedBy'), 'V3 should include parent CreatedBy'); + System.assert(result.containsKey('ManagedUsers'), 'V3 should contain ManagedUsers child'); + Map managedUsersData = (Map) result.get('ManagedUsers'); + Integer totalSize = (Integer) managedUsersData.get('totalSize'); + System.assert(totalSize >= 1, 'V3 should have at least 1 ManagedUser'); } } @IsTest static void testGetRecordDataV3MultiLevel() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - - Opportunity opp = new Opportunity( - Name = 'V3 Multi Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) - ); - insert opp; - - Product2 prod = new Product2(Name = 'V3 Product', IsActive = true); - insert prod; - Id stdPbId = Test.getStandardPricebookId(); - PricebookEntry pbe = new PricebookEntry( - Pricebook2Id = stdPbId, - Product2Id = prod.Id, - UnitPrice = 50, - IsActive = true - ); - insert pbe; - OpportunityLineItem oli = new OpportunityLineItem( - OpportunityId = opp.Id, - PricebookEntryId = pbe.Id, - Quantity = 3, - UnitPrice = 50 + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + ContentVersion cv = new ContentVersion( + Title = 'V3 Multi Junction', + PathOnClient = 'V3 Multi Junction.docx', + VersionData = Blob.valueOf('FAKEPNGBYTES'), + FirstPublishLocationId = UserInfo.getUserId() ); - insert oli; + insert cv; - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Opportunity","fields":["Name","Amount"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Opportunities"},' + - '{"id":"n2","object":"OpportunityLineItem","fields":["Quantity"],"parentFields":["Product2.Name"],' + - '"parentNode":"n1","lookupField":"OpportunityId","relationshipName":"OpportunityLineItems"}]}'; + '{"id":"n1","object":"User","fields":["Name","Email"],"parentFields":["CreatedBy.Email"],' + + '"parentNode":"n0","lookupField":"ManagerId","relationshipName":"ManagedUsers"},' + + '{"id":"n2","object":"ContentDocumentLink","fields":["ShareType"],"parentFields":[],' + + '"parentNode":"n1","lookupField":"LinkedEntityId","relationshipName":"ContentDocumentLinks",' + + '"junction":{"targetObject":"ContentDocument","targetIdField":"ContentDocumentId","targetFields":["Id","Title"]}}]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'V3 multi-level should not be null'); - System.assert(result.containsKey('Opportunities'), 'V3 should contain Opportunities'); - Map oppData = (Map) result.get('Opportunities'); - List oppRecords = (List) oppData.get('records'); - System.assertEquals(1, oppRecords.size(), 'V3 should have 1 Opportunity'); - - Map oppRec = (Map) oppRecords[0]; - System.assert(oppRec.containsKey('OpportunityLineItems'), 'V3 Opp should contain OLI children'); - Map oliData = (Map) oppRec.get('OpportunityLineItems'); - List oliRecords = (List) oliData.get('records'); - System.assertEquals(1, oliRecords.size(), 'V3 should have 1 OLI'); - - // Verify parent field on OLI (Product2.Name) - Map oliRec = (Map) oliRecords[0]; - System.assert(oliRec.containsKey('Product2'), 'V3 OLI should have Product2 parent'); - Map prodData = (Map) oliRec.get('Product2'); - System.assertEquals('V3 Product', prodData.get('Name'), 'V3 Product2.Name should match'); + System.assert(result.containsKey('ManagedUsers'), 'V3 should contain ManagedUsers'); + Map managedUsersData = (Map) result.get('ManagedUsers'); + List managedUserRecords = (List) managedUsersData.get('records'); + System.assert(managedUserRecords.size() >= 1, 'V3 should have at least 1 managed user'); + + Map managedUserRecord = (Map) managedUserRecords[0]; + System.assert(managedUserRecord.containsKey('ContentDocumentLinks'), + 'V3 managed user should contain ContentDocumentLinks children'); + Map cdlContainer = (Map) managedUserRecord.get('ContentDocumentLinks'); + List cdlRecords = (List) cdlContainer.get('records'); + System.assertEquals(0, cdlRecords.size(), 'V3 should have 0 ContentDocumentLink'); + } } @IsTest static void testGetRecordDataV3NoNodesThrows() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Boolean caught = false; Test.startTest(); try { - DocGenDataRetriever.getRecordData(acc.Id, 'Account', '{"v":3,"root":"Account","nodes":[]}'); + DocGenDataRetriever.getRecordData(usr.Id, 'User', '{"v":3,"root":"User","nodes":[]}'); } catch (AuraHandledException e) { caught = true; } @@ -2767,55 +2668,38 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV3WithJunction() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con = new Contact(FirstName = 'V3Junction', LastName = 'Test', AccountId = acc.Id); - insert con; - - Opportunity opp = new Opportunity( - Name = 'V3 Junction Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) - ); - insert opp; - - OpportunityContactRole ocr = new OpportunityContactRole( - OpportunityId = opp.Id, - ContactId = con.Id, - Role = 'Influencer' + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + ContentVersion cv = new ContentVersion( + Title = 'V3 Junction Test', + PathOnClient = 'V3 Junction Test.docx', + VersionData = Blob.valueOf('FAKEPNGBYTES'), + FirstPublishLocationId = UserInfo.getUserId() ); - insert ocr; + insert cv; - // V3 config with junction node: OCR as child of Opportunity with junction to Contact - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + // V3 config with junction node: ContentDocumentLink child with ContentDocument junction + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Opportunity","fields":["Name"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Opportunities"},' + - '{"id":"n2","object":"OpportunityContactRole","fields":["Role"],"parentFields":[],' + - '"parentNode":"n1","lookupField":"OpportunityId","relationshipName":"OpportunityContactRoles",' + - '"junction":{"targetObject":"Contact","targetIdField":"ContactId","targetFields":["FirstName","LastName"]}}]}'; + '{"id":"n1","object":"ContentDocumentLink","fields":["ShareType"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"LinkedEntityId","relationshipName":"ContentDocumentLinks",' + + '"junction":{"targetObject":"ContentDocument","targetIdField":"ContentDocumentId","targetFields":["Id","Title"]}}]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'V3 junction result should not be null'); - Map oppsData = (Map) result.get('Opportunities'); - List opps = (List) oppsData.get('records'); - Map oppRec = (Map) opps[0]; - System.assert(oppRec.containsKey('OpportunityContactRoles'), 'V3 should have OCR on Opp'); - - Map ocrContainer = (Map) oppRec.get('OpportunityContactRoles'); - List ocrRecords = (List) ocrContainer.get('records'); - System.assertEquals(1, ocrRecords.size(), 'V3 should have 1 OCR'); - - Map ocrRec = (Map) ocrRecords[0]; - System.assert(ocrRec.containsKey('Contact'), 'V3 junction should stitch Contact'); - Map contactData = (Map) ocrRec.get('Contact'); - System.assertEquals('V3Junction', contactData.get('FirstName'), 'V3 junction stitched FirstName'); + System.assert(result.containsKey('ContentDocumentLinks'), 'V3 should include ContentDocumentLinks'); + Map cdlContainer = (Map) result.get('ContentDocumentLinks'); + List cdlRecords = (List) cdlContainer.get('records'); + System.assert(cdlRecords.size() >= 1, 'V3 should have at least 1 ContentDocumentLink'); + + Map cdlRecord = (Map) cdlRecords[0]; + System.assert(cdlRecord.containsKey('ContentDocument'), 'V3 junction should stitch ContentDocument'); + Map contentDocumentData = (Map) cdlRecord.get('ContentDocument'); + System.assertEquals('V3 Junction Test', contentDocumentData.get('Title'), 'V3 junction stitched ContentDocument title'); } } @@ -2825,52 +2709,39 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV3Bulk() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - - // Create a second account for bulk testing - Account acc2 = new Account(Name = 'Bulk Test Account 2'); - insert acc2; - - Contact con1 = new Contact(FirstName = 'BulkA', LastName = 'Test', AccountId = acc.Id); - Contact con2 = new Contact(FirstName = 'BulkB', LastName = 'Test', AccountId = acc2.Id); - insert new List{ con1, con2 }; - + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Map config = (Map) JSON.deserializeUntyped( - '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName","LastName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts"}]}' + '{"id":"n1","object":"User","fields":["FirstName","LastName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers"}]}' ); - Set recordIds = new Set{ acc.Id, acc2.Id }; + Set recordIds = new Set{ UserInfo.getUserId(), usr.Id }; Test.startTest(); Map> results = DocGenDataRetriever.getRecordDataV3Bulk(recordIds, config); Test.stopTest(); - System.assertEquals(2, results.size(), 'Bulk should return 2 accounts'); - System.assert(results.containsKey(acc.Id), 'Bulk should contain first account'); - System.assert(results.containsKey(acc2.Id), 'Bulk should contain second account'); + System.assertEquals(2, results.size(), 'Bulk should return 2 Users'); + System.assert(results.containsKey(UserInfo.getUserId()), 'Bulk should contain first User'); - // Verify children were stitched for each account - Map acc1Data = results.get(acc.Id); - System.assert(acc1Data.containsKey('Contacts'), 'Bulk acc1 should have Contacts'); + // Verify children were stitched for each User + Map userData = results.get(UserInfo.getUserId()); + System.assert(userData.containsKey('ManagedUsers'), 'Bulk user should have ManagedUsers'); - Map acc2Data = results.get(acc2.Id); - System.assert(acc2Data.containsKey('Contacts'), 'Bulk acc2 should have Contacts'); - Map acc2Contacts = (Map) acc2Data.get('Contacts'); - List acc2ContactRecords = (List) acc2Contacts.get('records'); - System.assertEquals(1, acc2ContactRecords.size(), 'Bulk acc2 should have 1 Contact'); + Map managedUsersData = results.get(usr.Id); + System.assert(managedUsersData.containsKey('ManagedUsers'), 'Bulk managedUsers should have ManagedUsers'); + Map managedUsers = (Map) managedUsersData.get('ManagedUsers'); + List managedUsersRecords = (List) managedUsers.get('records'); } } @IsTest static void testGetRecordDataV3BulkEmpty() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Map> results = DocGenDataRetriever.getRecordDataV3Bulk( new Set(), @@ -2884,53 +2755,41 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV3BulkWithJunction() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con = new Contact(FirstName = 'BulkJunction', LastName = 'Test', AccountId = acc.Id); - insert con; - - Opportunity opp = new Opportunity( - Name = 'Bulk Junction Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + ContentVersion cv = new ContentVersion( + Title = 'Bulk Junction Test', + PathOnClient = 'Bulk Junction Test.docx', + VersionData = Blob.valueOf('FAKEPNGBYTES'), + FirstPublishLocationId = UserInfo.getUserId() ); - insert opp; - - OpportunityContactRole ocr = new OpportunityContactRole( - OpportunityId = opp.Id, - ContactId = con.Id, - Role = 'Evaluator' - ); - insert ocr; + insert cv; Map config = (Map) JSON.deserializeUntyped( - '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Opportunity","fields":["Name"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Opportunities"},' + - '{"id":"n2","object":"OpportunityContactRole","fields":["Role"],"parentFields":[],' + - '"parentNode":"n1","lookupField":"OpportunityId","relationshipName":"OpportunityContactRoles",' + - '"junction":{"targetObject":"Contact","targetIdField":"ContactId","targetFields":["FirstName"]}}]}' + '{"id":"n1","object":"ContentDocumentLink","fields":["ShareType"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"LinkedEntityId","relationshipName":"ContentDocumentLinks",' + + '"junction":{"targetObject":"ContentDocument","targetIdField":"ContentDocumentId","targetFields":["Id","Title"]}}]}' ); Test.startTest(); Map> results = DocGenDataRetriever.getRecordDataV3Bulk( - new Set{ acc.Id }, config + new Set{ UserInfo.getUserId(), usr.Id }, config ); Test.stopTest(); - System.assertEquals(1, results.size(), 'Bulk junction should return 1 account'); - Map accData = results.get(acc.Id); - Map oppsContainer = (Map) accData.get('Opportunities'); - List opps = (List) oppsContainer.get('records'); - Map oppRec = (Map) opps[0]; - Map ocrContainer = (Map) oppRec.get('OpportunityContactRoles'); - List ocrs = (List) ocrContainer.get('records'); - Map ocrRec = (Map) ocrs[0]; - System.assert(ocrRec.containsKey('Contact'), 'Bulk junction should stitch Contact'); + System.assert(results.size() >= 1, 'Bulk junction should return at least one User'); + Map userData = results.get(UserInfo.getUserId()); + System.assert(userData.containsKey('ContentDocumentLinks'), 'Bulk should contain ContentDocumentLinks'); + Map cdlContainer = (Map) userData.get('ContentDocumentLinks'); + List cdlRecords = (List) cdlContainer.get('records'); + System.assert(cdlRecords.size() >= 1, 'Bulk junction should return at least one ContentDocumentLink'); + Map cdlRecord = (Map) cdlRecords[0]; + System.assert(cdlRecord.containsKey('ContentDocument'), 'Bulk junction should stitch ContentDocument'); + Map contentDocumentData = (Map) cdlRecord.get('ContentDocument'); + System.assertEquals('Bulk Junction Test', contentDocumentData.get('Title'), 'Bulk junction should stitch ContentDocument Title'); } } @@ -2940,8 +2799,7 @@ private class DocGenMiscTests { @IsTest static void testGetConfigVersion() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); // V1: plain string System.assertEquals(1, DocGenDataRetriever.getConfigVersion('Name, Industry'), 'Plain string should be V1'); @@ -2949,13 +2807,13 @@ private class DocGenMiscTests { System.assertEquals(1, DocGenDataRetriever.getConfigVersion(null), 'Null should be V1'); // V2: JSON without explicit version - System.assertEquals(2, DocGenDataRetriever.getConfigVersion('{"baseObject":"Account","baseFields":["Name"]}'), 'JSON without v field should be V2'); + System.assertEquals(2, DocGenDataRetriever.getConfigVersion('{"baseObject":"User","baseFields":["Name"]}'), 'JSON without v field should be V2'); // V2: JSON with v:2 - System.assertEquals(2, DocGenDataRetriever.getConfigVersion('{"v":2,"baseObject":"Opportunity"}'), 'JSON with v:2 should be V2'); + System.assertEquals(2, DocGenDataRetriever.getConfigVersion('{"v":2,"baseObject":"User"}'), 'JSON with v:2 should be V2'); // V3: JSON with v:3 - System.assertEquals(3, DocGenDataRetriever.getConfigVersion('{"v":3,"root":"Account","nodes":[]}'), 'JSON with v:3 should be V3'); + System.assertEquals(3, DocGenDataRetriever.getConfigVersion('{"v":3,"root":"User","nodes":[]}'), 'JSON with v:3 should be V3'); // Malformed JSON falls back to V1 System.assertEquals(1, DocGenDataRetriever.getConfigVersion('{not valid json'), 'Malformed JSON should fall back to V1'); @@ -2969,155 +2827,146 @@ private class DocGenMiscTests { @IsTest static void testSanitizeClauseViaV2WhereClause() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con = new Contact(FirstName = 'ClauseTest', LastName = 'Safe', AccountId = acc.Id); - insert con; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // V2 config with valid WHERE and ORDER BY and LIMIT on children - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName","LastName"],' + - '"where":"LastName = \'Safe\'","orderBy":"FirstName","limit":"10"}],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName","LastName"],' + + '"where":"Id = \'' + usr.Id + '\'","orderBy":"FirstName","limit":"10"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'V2 with clauses should not be null'); - Map contactsData = (Map) result.get('Contacts'); - List records = (List) contactsData.get('records'); - System.assertEquals(1, records.size(), 'WHERE clause should filter to 1 contact'); + Map usersData = (Map) result.get('ManagedUsers'); + List records = (List) usersData.get('records'); + System.assertEquals(1, records.size(), 'WHERE clause should filter to 1 user'); } } @IsTest static void testSanitizeClauseRejectsSemicolon() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Boolean caught = false; // V2 config with injection attempt via semicolon in WHERE - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName"],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName"],' + '"where":"LastName = \'x\'; DELETE Contact"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Root data should still be returned'); - System.assert(!result.containsKey('Contacts'), 'Invalid child relationship should be skipped'); + System.assert(!result.containsKey('ManagedUsers'), 'Invalid child relationship should be skipped'); } } @IsTest static void testSanitizeClauseRejectsComment() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName"],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName"],' + '"where":"LastName = \'x\' -- comment"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for comment rejection test'); - System.assert(!result.containsKey('Contacts'), 'Comment in WHERE should skip child'); + System.assert(!result.containsKey('ManagedUsers'), 'Comment in WHERE should skip child'); } } @IsTest static void testSanitizeClauseRejectsBlockComment() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName"],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName"],' + '"where":"LastName = \'x\' /* block */"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for block comment rejection'); - System.assert(!result.containsKey('Contacts'), 'Block comment in WHERE should skip child'); + System.assert(!result.containsKey('ManagedUsers'), 'Block comment in WHERE should skip child'); } } @IsTest static void testSanitizeClauseRejectsNonNumericLimit() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName"],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName"],' + '"limit":"abc"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for non-numeric LIMIT test'); - System.assert(!result.containsKey('Contacts'), 'Non-numeric LIMIT should skip child'); + System.assert(!result.containsKey('ManagedUsers'), 'Non-numeric LIMIT should skip child'); } } @IsTest static void testSanitizeClauseRejectsDMLKeyword() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName"],' + - '"where":"DELETE FROM Contact"}],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName"],' + + '"where":"DELETE FROM User"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for DML keyword rejection'); - System.assert(!result.containsKey('Contacts'), 'DML keyword in WHERE should skip child'); + System.assert(!result.containsKey('ManagedUsers'), 'DML keyword in WHERE should skip child'); } } @IsTest static void testSanitizeClauseRejectsSelectInWhere() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":"Contacts","fields":["FirstName"],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName"],' + '"where":"Id IN (SELECT Id FROM User)"}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for SELECT injection rejection'); - System.assert(!result.containsKey('Contacts'), 'SELECT in WHERE clause should skip child'); + System.assert(!result.containsKey('ManagedUsers'), 'SELECT in WHERE clause should skip child'); } } @@ -3127,26 +2976,23 @@ private class DocGenMiscTests { @IsTest static void testMapSObjectWithAddress() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.BillingStreet = '123 Test St'; - acc.BillingCity = 'TestCity'; - acc.BillingState = 'CA'; - acc.BillingPostalCode = '90210'; - acc.BillingCountry = 'US'; - update acc; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + usr.Street = '123 Test St'; + usr.City = 'TestCity'; + usr.PostalCode = '90210'; + updateUser(usr); Test.startTest(); Map result = DocGenDataRetriever.getRecordData( - acc.Id, 'Account', - 'Name, BillingAddress' + usr.Id, 'User', + 'Name, Address' ); Test.stopTest(); System.assertNotEquals(null, result, 'mapSObject with Address should not be null'); - System.assert(result.containsKey('BillingAddress'), 'Should contain BillingAddress compound field'); - Map addrMap = (Map) result.get('BillingAddress'); + System.assert(result.containsKey('Address'), 'Should contain Address compound field'); + Map addrMap = (Map) result.get('Address'); System.assertEquals('123 Test St', addrMap.get('street'), 'Address street should match'); System.assertEquals('TestCity', addrMap.get('city'), 'Address city should match'); } @@ -3154,25 +3000,20 @@ private class DocGenMiscTests { @IsTest static void testMapSObjectWithSubquery() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con = new Contact(FirstName = 'MapSObj', LastName = 'Child', AccountId = acc.Id); - insert con; - + System.runAs(new User(Id = UserInfo.getUserId())) { // V1 style with subquery tests mapSObject's List branch Test.startTest(); Map result = DocGenDataRetriever.getRecordData( - acc.Id, 'Account', - 'Name, (SELECT FirstName, LastName FROM Contacts)' + UserInfo.getUserId(), 'User', + 'Name, (SELECT Email FROM ManagedUsers)' ); Test.stopTest(); System.assertNotEquals(null, result, 'V1 subquery result should not be null'); - System.assert(result.containsKey('Contacts'), 'Should contain Contacts from subquery'); - Map contactsMap = (Map) result.get('Contacts'); - Integer totalSize = (Integer) contactsMap.get('totalSize'); - System.assert(totalSize >= 1, 'Should have at least 1 contact'); + System.assert(result.containsKey('ManagedUsers'), 'Should contain ManagedUsers from subquery'); + Map managedUsersMap = (Map) result.get('ManagedUsers'); + Integer totalSize = (Integer) managedUsersMap.get('totalSize'); + System.assert(totalSize >= 1, 'Should have at least 1 ManagedUser'); } } @@ -3182,22 +3023,21 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV2SkipsInvalidChildren() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // Child with null rel should be skipped; child with empty fields should be skipped - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + '"parentFields":[],' + - '"children":[{"rel":null,"fields":["FirstName"]},{"rel":"Contacts","fields":[]}],' + + '"children":[{"rel":null,"fields":["FirstName"]},{"rel":"ManagedUsers","fields":[]}],' + '"junctions":[]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'V2 with invalid children should still return data'); - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should still be present'); + System.assertEquals(usr.Name, result.get('Name'), 'Name should still be present'); } } @@ -3207,27 +3047,22 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV3WithClauses() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Contact con1 = new Contact(FirstName = 'Alpha', LastName = 'Clause', AccountId = acc.Id); - Contact con2 = new Contact(FirstName = 'Beta', LastName = 'Clause', AccountId = acc.Id); - Contact con3 = new Contact(FirstName = 'Gamma', LastName = 'Other', AccountId = acc.Id); - insert new List{ con1, con2, con3 }; - - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName","LastName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts",' + + '{"id":"n1","object":"User","fields":["FirstName","LastName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers",' + '"where":"LastName = \'Clause\'","orderBy":"FirstName ASC","limit":"2"}]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for V3 with clauses'); - Map contactsData = (Map) result.get('Contacts'); + Map contactsData = (Map) result.get('ManagedUsers'); List records = (List) contactsData.get('records'); System.assert(records.size() <= 2, 'LIMIT 2 should cap results at 2'); // All returned records should have LastName = 'Clause' @@ -3244,52 +3079,25 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV1Grandchildren() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - Opportunity opp = new Opportunity( - Name = 'Grandchild Opp', - AccountId = acc.Id, - StageName = 'Prospecting', - CloseDate = Date.today().addDays(30) - ); - insert opp; - - Product2 prod = new Product2(Name = 'GC Product', IsActive = true); - insert prod; - Id stdPbId = Test.getStandardPricebookId(); - PricebookEntry pbe = new PricebookEntry( - Pricebook2Id = stdPbId, - Product2Id = prod.Id, - UnitPrice = 75, - IsActive = true - ); - insert pbe; - OpportunityLineItem oli = new OpportunityLineItem( - OpportunityId = opp.Id, - PricebookEntryId = pbe.Id, - Quantity = 2, - UnitPrice = 75 - ); - insert oli; - + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // V1 config with nested subquery (grandchild) - String v1Config = 'Name, (SELECT Name, (SELECT Quantity FROM OpportunityLineItems) FROM Opportunities)'; + String v1Config = 'Name, (SELECT Name, (SELECT Email FROM ManagedUsers) FROM ManagedUsers)'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v1Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v1Config); Test.stopTest(); System.assertNotEquals(null, result, 'V1 grandchild result should not be null'); - System.assert(result.containsKey('Opportunities'), 'Should contain Opportunities'); - Map oppContainer = (Map) result.get('Opportunities'); - List oppRecords = (List) oppContainer.get('records'); - System.assert(oppRecords.size() >= 1, 'Should have at least 1 Opportunity'); + System.assert(result.containsKey('ManagedUsers'), 'Should contain ManagedUsers'); + Map userContainer = (Map) result.get('ManagedUsers'); + List userRecords = (List) userContainer.get('records'); + System.assert(userRecords.size() >= 1, 'Should have at least 1 User'); // Verify grandchild stitching - Map oppRec = (Map) oppRecords[0]; - System.assert(oppRec.containsKey('OpportunityLineItems'), - 'Grandchild OpportunityLineItems should be stitched into Opportunity'); + Map userRec = (Map) userRecords[0]; + System.assert(userRec.containsKey('ManagedUsers'), + 'Grandchild ManagedUsers should be stitched into User'); } } @@ -3379,7 +3187,7 @@ private class DocGenMiscTests { // Child data wrapped in {records: [...]} format (V1 query style) Map data = new Map{ 'Name' => 'Test', - 'Contacts' => new Map{ + 'ManagedUsers' => new Map{ 'records' => new List{ new Map{ 'Score' => 10 }, new Map{ 'Score' => 20 } @@ -3387,7 +3195,7 @@ private class DocGenMiscTests { } }; - String xml = '{SUM:Contacts.Score}'; + String xml = '{SUM:ManagedUsers.Score}'; Test.startTest(); String result = DocGenService.processXmlForTest(xml, data); Test.stopTest(); @@ -3553,7 +3361,7 @@ private class DocGenMiscTests { static void testExcelSharedStringsProcessing() { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; tpl.Type__c = 'Excel'; - tpl.Query_Config__c = 'Name, AccountSource'; + tpl.Query_Config__c = 'Email, CreatedBy.Email'; update tpl; String sharedStringsXml = @@ -3595,10 +3403,10 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Excel document should be generated'); @@ -3716,10 +3524,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - String html = DocGenService.renderPreviewHtml(tpl.Id, acc.Id); + String html = DocGenService.renderPreviewHtml(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, html, 'Should return HTML'); @@ -3751,12 +3559,12 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); Boolean caught = false; try { - DocGenService.renderPreviewHtml(tpl.Id, acc.Id); + DocGenService.renderPreviewHtml(tpl.Id, usr.Id); } catch (DocGenException e) { caught = true; System.assert(e.getMessage().contains('only supported for Word'), 'Preview should mention Word-only support'); @@ -3778,10 +3586,9 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; Test.startTest(); - Map result = DocGenService.generateDocumentParts(tpl.Id, acc.Id); + Map result = DocGenService.generateDocumentParts(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, result.get('allXmlParts'), 'Should return XML parts'); @@ -3925,31 +3732,26 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV3WithWhereOrderByLimit() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new List{ - new Contact(FirstName = 'Alpha', LastName = 'V3W', AccountId = acc.Id), - new Contact(FirstName = 'Beta', LastName = 'V3W', AccountId = acc.Id) - }; - - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName","LastName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts",' + - '"where":"LastName = \'V3W\'","orderBy":"FirstName ASC","limit":"10"}' + + '{"id":"n1","object":"User","fields":["FirstName","LastName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers",' + + '"where":"LastName = \'' + usr.LastName + '\'","orderBy":"FirstName ASC","limit":"10"}' + ']}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'Should return data'); - System.assert(result.containsKey('Contacts'), 'Should contain Contacts relationship'); - Map contactsMap = (Map) result.get('Contacts'); + System.assert(result.containsKey('ManagedUsers'), 'Should contain ManagedUsers relationship'); + Map contactsMap = (Map) result.get('ManagedUsers'); List records = (List) contactsMap.get('records'); - System.assertEquals(2, records.size(), 'Should return 2 contacts matching WHERE clause'); + System.assert(records.size() >= 1, 'Should return at least 1 ManagedUser matching WHERE clause'); } } @@ -3959,22 +3761,21 @@ private class DocGenMiscTests { @IsTest static void testSanitizeClauseRejectsInjection() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // V3 config with dangerous WHERE clause containing semicolon - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts",' + + '{"id":"n1","object":"User","fields":["FirstName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers",' + '"where":"LastName = \'test\'; DROP TABLE"}' + ']}'; Test.startTest(); // The sanitizeClause should reject the semicolon and the child query will fail gracefully - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v3Config); Test.stopTest(); // The root query should still succeed; child with bad WHERE is skipped @@ -3984,22 +3785,20 @@ private class DocGenMiscTests { @IsTest static void testSanitizeClauseLimitMustBeNumeric() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new Contact(FirstName = 'Lim', LastName = 'Test', AccountId = acc.Id); + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // V3 config with non-numeric LIMIT — should throw and skip child - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts",' + + '{"id":"n1","object":"User","fields":["FirstName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers",' + '"limit":"abc"}' + ']}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'Root data should still be returned'); @@ -4008,21 +3807,20 @@ private class DocGenMiscTests { @IsTest static void testSanitizeClauseRejectsComments() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // V3 config with comment injection in WHERE - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts",' + + '{"id":"n1","object":"User","fields":["FirstName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers",' + '"where":"LastName = \'test\' /* comment */"}' + ']}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'Root data should still be returned'); @@ -4031,21 +3829,20 @@ private class DocGenMiscTests { @IsTest static void testSanitizeClauseRejectsDMLKeywords() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); // V3 config with DML keyword in WHERE - String v3Config = '{"v":3,"root":"Account","nodes":[' + - '{"id":"n0","object":"Account","fields":["Name"],"parentFields":[],' + + String v3Config = '{"v":3,"root":"User","nodes":[' + + '{"id":"n0","object":"User","fields":["Name"],"parentFields":[],' + '"parentNode":null,"lookupField":null,"relationshipName":null},' + - '{"id":"n1","object":"Contact","fields":["FirstName"],"parentFields":[],' + - '"parentNode":"n0","lookupField":"AccountId","relationshipName":"Contacts",' + + '{"id":"n1","object":"User","fields":["FirstName"],"parentFields":[],' + + '"parentNode":"n0","lookupField":"CreatedById","relationshipName":"ManagedUsers",' + '"where":"LastName = \'test\' INSERT something"}' + ']}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v3Config); + Map result = DocGenDataRetriever.getRecordData(usr.Id, 'User', v3Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for DML keyword in V3 WHERE'); @@ -4058,14 +3855,13 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithNullFieldsConfig() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Boolean caught = false; Test.startTest(); try { - DocGenDataRetriever.getRecordData(acc.Id, 'Account', null); + DocGenDataRetriever.getRecordData(usr.Id, 'User', null); } catch (AuraHandledException e) { caught = true; } @@ -4077,14 +3873,13 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithBlankConfig() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); Boolean caught = false; Test.startTest(); try { - DocGenDataRetriever.getRecordData(acc.Id, 'Account', ' '); + DocGenDataRetriever.getRecordData(usr.Id, 'User', ' '); } catch (AuraHandledException e) { caught = true; } @@ -4096,13 +3891,12 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithNullRecordId() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Boolean caught = false; Test.startTest(); try { - DocGenDataRetriever.getRecordData(null, 'Account', 'Name'); + DocGenDataRetriever.getRecordData(null, 'User', 'Name'); } catch (AuraHandledException e) { caught = true; } @@ -4118,8 +3912,7 @@ private class DocGenMiscTests { @IsTest static void testGetConfigVersionV1Blank() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Integer v = DocGenDataRetriever.getConfigVersion(null); Integer v2 = DocGenDataRetriever.getConfigVersion(''); @@ -4134,11 +3927,10 @@ private class DocGenMiscTests { @IsTest static void testGetConfigVersionV2() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { // JSON without version field defaults to V2 Test.startTest(); - Integer v = DocGenDataRetriever.getConfigVersion('{"baseObject":"Account","baseFields":["Name"]}'); + Integer v = DocGenDataRetriever.getConfigVersion('{"baseObject":"User","baseFields":["Name"]}'); Test.stopTest(); System.assertEquals(2, v, 'JSON without v field should be V2'); @@ -4147,10 +3939,9 @@ private class DocGenMiscTests { @IsTest static void testGetConfigVersionV3() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - Integer v = DocGenDataRetriever.getConfigVersion('{"v":3,"root":"Account","nodes":[]}'); + Integer v = DocGenDataRetriever.getConfigVersion('{"v":3,"root":"User","nodes":[]}'); Test.stopTest(); System.assertEquals(3, v, 'JSON with v:3 should be V3'); @@ -4159,8 +3950,7 @@ private class DocGenMiscTests { @IsTest static void testGetConfigVersionMalformedJson() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); Integer v = DocGenDataRetriever.getConfigVersion('{malformed json'); Test.stopTest(); @@ -4175,21 +3965,17 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithLocationField() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - // Account doesn't have geolocation, but Location type is covered + System.runAs(new User(Id = UserInfo.getUserId())) { + // User doesn't have geolocation, but Location type is covered // by the Address field test. Test null value handling instead. - acc.Description = null; - update acc; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', 'Name, Description'); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', 'Name, AboutMe'); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for location field test'); - // Description is null — should not be in the map (or null) - System.assertEquals('Misc Test Account', result.get('Name'), 'Name should match for location field test'); + // AboutMe is null — should not be in the map (or null) + System.assertEquals(UserInfo.getName(), result.get('Name'), 'Name should match for location field test'); } } @@ -4199,24 +3985,19 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataV2WithChildClauses() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; - insert new List{ - new Contact(FirstName = 'V2A', LastName = 'Test', AccountId = acc.Id), - new Contact(FirstName = 'V2B', LastName = 'Test', AccountId = acc.Id) - }; - - String v2Config = '{"v":2,"baseObject":"Account","baseFields":["Name"],' + - '"children":[{"rel":"Contacts","fields":["FirstName","LastName"],' + - '"where":"LastName = \'Test\'","orderBy":"FirstName ASC","limit":"5"}]}'; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); + + String v2Config = '{"v":2,"baseObject":"User","baseFields":["Name"],' + + '"children":[{"rel":"ManagedUsers","fields":["FirstName","LastName"],' + + '"where":"LastName = \'' + usr.LastName + '\'","orderBy":"FirstName ASC","limit":"5"}]}'; Test.startTest(); - Map result = DocGenDataRetriever.getRecordData(acc.Id, 'Account', v2Config); + Map result = DocGenDataRetriever.getRecordData(UserInfo.getUserId(), 'User', v2Config); Test.stopTest(); System.assertNotEquals(null, result, 'Result should not be null for V2 child clauses test'); - System.assert(result.containsKey('Contacts'), 'Should contain Contacts'); + System.assert(result.containsKey('ManagedUsers'), 'Should contain ManagedUsers'); } } @@ -4226,8 +4007,8 @@ private class DocGenMiscTests { @IsTest static void testSaveSettingsPermissionDenied() { - // Create a standard user profile user without admin access - Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1]; + // Create a user without admin access (permission set DocGen_Admin_Access is not assigned) + Profile p = [SELECT Id FROM Profile WHERE Id =: UserInfo.getProfileId() LIMIT 1]; User stdUser = new User( Alias = 'dgntst', Email = 'docgentest@example.com', @@ -4261,8 +4042,7 @@ private class DocGenMiscTests { @IsTest static void testSaveSettingsWithNullUrl() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); DocGenSetupController.saveSettings(null); Test.stopTest(); @@ -4278,15 +4058,14 @@ private class DocGenMiscTests { @IsTest static void testMergeTemplateForSignatureThrows() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Boolean caught = false; Test.startTest(); try { - DocGenService.mergeTemplateForSignature(tpl.Id, acc.Id); + DocGenService.mergeTemplateForSignature(tpl.Id, usr.Id); } catch (DocGenException e) { caught = true; System.assert(e.getMessage().contains('Signatures have been removed'), 'Should mention signatures removed'); @@ -4310,15 +4089,14 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; Map preloadedData = new Map{ - 'Name' => 'Cached Account Name', - 'AccountSource' => 'Web' + 'Name' => UserInfo.getName(), + 'LastName' => UserInfo.getLastName() }; Test.startTest(); - Id docId = DocGenService.generateDocumentFromData(tpl.Id, acc.Id, preloadedData); + Id docId = DocGenService.generateDocumentFromData(tpl.Id, UserInfo.getUserId(), preloadedData); Test.stopTest(); System.assertNotEquals(null, docId, 'Should return ContentDocumentId from pre-cached data'); @@ -4337,10 +4115,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - String html = DocGenService.generateHtmlForRecord(tpl.Id, acc.Id, null); + String html = DocGenService.generateHtmlForRecord(tpl.Id, usr.Id, null); Test.stopTest(); System.assertNotEquals(null, html, 'Should return HTML string'); @@ -4356,14 +4134,14 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Map preloaded = new Map{ 'Name' => 'Preloaded Name' }; Test.startTest(); - String html = DocGenService.generateHtmlForRecord(tpl.Id, acc.Id, preloaded); + String html = DocGenService.generateHtmlForRecord(tpl.Id, usr.Id, preloaded); Test.stopTest(); System.assertNotEquals(null, html, 'Should return HTML with preloaded data'); @@ -4398,12 +4176,12 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Boolean caught = false; Test.startTest(); try { - DocGenService.processDocumentAsHtmlWithImageMap(tpl.Id, acc.Id, new Map()); + DocGenService.processDocumentAsHtmlWithImageMap(tpl.Id, usr.Id, new Map()); } catch (DocGenException e) { caught = true; System.assert(e.getMessage().contains('only supported for Word'), 'HTML image map should mention Word-only support'); @@ -4455,18 +4233,16 @@ private class DocGenMiscTests { Content_Version_Id__c = cv.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account' + Base_Object_API__c = 'User' ); insert ver; // extractAndSaveTemplateImages saves pre-decomposed XML CVs and image CVs DocGenService.extractAndSaveTemplateImages(tpl.Id, ver.Id); - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); // generateDocument with PDF output should use tryMergeFromPreDecomposed - Id docId = DocGenService.generateDocument(tpl.Id, acc.Id); + Id docId = DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, docId, 'Pre-decomposed path should generate a PDF'); @@ -4493,7 +4269,7 @@ private class DocGenMiscTests { )); zw.addEntry('word/document.xml', Blob.valueOf( '' + - '{Name}' + '{UserName}' )); zw.addEntry('word/_rels/document.xml.rels', Blob.valueOf( '' + @@ -4512,16 +4288,16 @@ private class DocGenMiscTests { Content_Version_Id__c = cv.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account' + Base_Object_API__c = 'User' ); insert ver; DocGenService.extractAndSaveTemplateImages(tpl.Id, ver.Id); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'PDF with template images should generate'); @@ -4533,8 +4309,7 @@ private class DocGenMiscTests { @IsTest static void testExtractAndSaveTemplateImagesNullTemplate() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); try { DocGenService.extractAndSaveTemplateImages(null, null); @@ -4578,7 +4353,7 @@ private class DocGenMiscTests { Content_Version_Id__c = cv.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account' + Base_Object_API__c = 'User' ); insert ver; @@ -4597,19 +4372,19 @@ private class DocGenMiscTests { @IsTest static void testApplyResolvedImagesAmpEncoded() { - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('AMPIMG')); - acc.Description = ''; - update acc; + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Map resolvedImages = new Map{ @@ -4617,7 +4392,7 @@ private class DocGenMiscTests { }; Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id, resolvedImages); + Map result = DocGenService.processDocument(tpl.Id, usr.Id, resolvedImages); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for amp-encoded resolved images'); @@ -4693,22 +4468,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentRichTextMultiParagraph() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

First paragraph

Second bold paragraph

Third with italic and underline

'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

First paragraph

Second bold paragraph

Third with italic and underline

'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for rich text multi-paragraph'); @@ -4728,22 +4503,22 @@ private class DocGenMiscTests { insert imgCv; imgCv = [SELECT Id FROM ContentVersion WHERE Id = :imgCv.Id]; - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = imgCv.Id; - update acc; + User usr = getManagedUser(); + usr.AboutMe = imgCv.Id; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{%Description}'; + '{%AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.generateDocumentParts(tpl.Id, acc.Id); + Map result = DocGenService.generateDocumentParts(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('allXmlParts'), 'XML parts should not be null for image CV ID parts'); @@ -4793,10 +4568,10 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Excel with self-closing cell should generate'); @@ -4809,22 +4584,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentRichTextImgDataUriWithDims() { String tinyBase64 = EncodingUtil.base64Encode(Blob.valueOf('RICHIMG')); - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Text after

'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Text after

'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for rich text img with data URI dims'); @@ -4836,22 +4611,22 @@ private class DocGenMiscTests { @IsTest static void testGeneratePdfBlobWithAbsoluteImageUrl() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = ''; - update acc; + User usr = getManagedUser(); + usr.AboutMe = ''; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.generatePdfBlob(tpl.Id, acc.Id); + Map result = DocGenService.generatePdfBlob(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'PDF with absolute URL image should generate'); @@ -4867,7 +4642,7 @@ private class DocGenMiscTests { 'Name' => 'SkipPrefix' }; - String xml = '{!Account.Name}'; + String xml = '{!User.Name}'; Test.startTest(); String result = DocGenService.processXmlForTest(xml, data); Test.stopTest(); @@ -4910,14 +4685,13 @@ private class DocGenMiscTests { tpl.Output_Format__c = 'PDF'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Map preloadedData = new Map{ - 'Name' => 'PDF Cached Name' + 'Name' => UserInfo.getName(), + 'LastName' => UserInfo.getLastName() }; Test.startTest(); - Id docId = DocGenService.generateDocumentFromData(tpl.Id, acc.Id, preloadedData); + Id docId = DocGenService.generateDocumentFromData(tpl.Id, UserInfo.getUserId(), preloadedData); Test.stopTest(); System.assertNotEquals(null, docId, 'Should return ContentDocumentId for PDF from cached data'); @@ -4932,7 +4706,7 @@ private class DocGenMiscTests { @IsTest static void testAggregateCountRecordsMap() { Map data = new Map{ - 'Contacts' => new Map{ + 'ManagedUsers' => new Map{ 'records' => new List{ new Map{ 'Name' => 'A' }, new Map{ 'Name' => 'B' } @@ -4940,7 +4714,7 @@ private class DocGenMiscTests { } }; - String xml = '{COUNT:Contacts}'; + String xml = '{COUNT:ManagedUsers}'; Test.startTest(); String result = DocGenService.processXmlForTest(xml, data); Test.stopTest(); @@ -4981,10 +4755,10 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.generateDocumentParts(tpl.Id, acc.Id); + Map result = DocGenService.generateDocumentParts(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('allXmlParts'), 'XML parts should not be null for passthrough media'); @@ -5009,10 +4783,8 @@ private class DocGenMiscTests { tpl.Output_Format__c = 'PDF'; update tpl; - Account acc = [SELECT Id FROM Account LIMIT 1]; - Test.startTest(); - DocGenService.generateDocument(tpl.Id, acc.Id); + DocGenService.generateDocument(tpl.Id, UserInfo.getUserId()); Test.stopTest(); System.assertNotEquals(null, DocGenService.lastRenderedHtml, 'lastRenderedHtml should be populated after PDF generation'); @@ -5026,7 +4798,7 @@ private class DocGenMiscTests { static void testExcelMultipleWorksheets() { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; tpl.Type__c = 'Excel'; - tpl.Query_Config__c = 'Name, AccountSource'; + tpl.Query_Config__c = 'Email, CreatedBy.Email'; update tpl; String sharedStringsXml = @@ -5062,10 +4834,10 @@ private class DocGenMiscTests { Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Excel with multiple sheets should generate'); @@ -5077,22 +4849,22 @@ private class DocGenMiscTests { @IsTest static void testProcessDocumentRichTextEmptyParagraphs() { - Account acc = [SELECT Id FROM Account LIMIT 1]; - acc.Description = '

Content

'; - update acc; + User usr = getManagedUser(); + usr.AboutMe = '

Content

'; + updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, Description'; + tpl.Query_Config__c = 'Name, AboutMe'; update tpl; String docXml = '' + '' + - '{Description}'; + '{AboutMe}'; createValidDocxFile(docXml); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for rich text empty paragraphs'); @@ -5149,10 +4921,10 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; insert new ContentDocumentLink(LinkedEntityId=tpl.Id, ContentDocumentId=conDocId, ShareType='V'); - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = getManagedUser(); Test.startTest(); - Map result = DocGenService.processDocument(tpl.Id, acc.Id); + Map result = DocGenService.processDocument(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('blob'), 'Blob should not be null for non-image rels test'); @@ -5164,8 +4936,7 @@ private class DocGenMiscTests { @IsTest static void testFlowActionSaveToRecord() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { String docXml = '' + '' + @@ -5173,7 +4944,6 @@ private class DocGenMiscTests { ContentVersion cv = createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; // Create active version so generateDocument can find the template file insert new DocGen_Template_Version__c( @@ -5184,7 +4954,7 @@ private class DocGenMiscTests { DocGenFlowAction.Request req = new DocGenFlowAction.Request(); req.templateId = tpl.Id; - req.recordId = acc.Id; + req.recordId = UserInfo.getUserId(); req.saveToRecord = false; // Service already links via FirstPublishLocationId Test.startTest(); @@ -5203,7 +4973,7 @@ private class DocGenMiscTests { List links = [ SELECT Id FROM ContentDocumentLink WHERE ContentDocumentId = :responses[0].contentDocumentId - AND LinkedEntityId = :acc.Id + AND LinkedEntityId = :UserInfo.getUserId() ]; System.assertEquals(1, links.size(), 'Generated file should be linked to the source record'); } @@ -5215,13 +4985,12 @@ private class DocGenMiscTests { @IsTest static void testFlowActionNullTemplateId() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { - Account acc = [SELECT Id FROM Account LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + User usr = getManagedUser(); DocGenFlowAction.Request req = new DocGenFlowAction.Request(); req.templateId = null; - req.recordId = acc.Id; + req.recordId = usr.Id; Test.startTest(); List responses = DocGenFlowAction.generateDocument( @@ -5243,8 +5012,7 @@ private class DocGenMiscTests { @IsTest static void testFlowActionWithDocumentTitle() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { String docXml = '' + '' + @@ -5252,11 +5020,10 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; DocGenFlowAction.Request req = new DocGenFlowAction.Request(); req.templateId = tpl.Id; - req.recordId = acc.Id; + req.recordId = UserInfo.getUserId(); req.saveToRecord = false; req.documentTitle = 'Custom Title'; @@ -5267,7 +5034,7 @@ private class DocGenMiscTests { Test.stopTest(); System.assertEquals(1, responses.size(), 'Should return exactly one response for documentTitle test'); - System.assertEquals(true, responses[0].success, 'Success should be true with documentTitle override'); + System.assertEquals(true, responses[0].success, 'Success should be true with documentTitle override, ' + responses[0].errorMessage); } } @@ -5277,8 +5044,7 @@ private class DocGenMiscTests { @IsTest static void testFlowActionResponseAllFields() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGenFlowAction.Response res = new DocGenFlowAction.Response(); res.contentDocumentId = null; res.contentVersionId = null; @@ -5298,8 +5064,7 @@ private class DocGenMiscTests { @IsTest static void testFlowActionRequestFields() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { DocGenFlowAction.Request req = new DocGenFlowAction.Request(); req.templateId = null; req.recordId = null; diff --git a/force-app/main/default/classes/DocGenSharingTests.cls b/force-app/main/default/classes/DocGenSharingTests.cls index 15cd51d..4e43ecc 100644 --- a/force-app/main/default/classes/DocGenSharingTests.cls +++ b/force-app/main/default/classes/DocGenSharingTests.cls @@ -1,45 +1,56 @@ @IsTest private class DocGenSharingTests { - + private static String UNIQUE_IDENTIFIER = 'DocGenSharingTests'; + private static final String TEST_GROUP_NAME = String.format('DocGenShareGroup_{0}', new List{UserInfo.getUserId()}); @TestSetup static void setup() { - DocGen_Template__c t = new DocGen_Template__c( + DocGen_Template__c templateRecord = new DocGen_Template__c( Name = 'Sharing Template', - Base_Object_API__c = 'Account' + Base_Object_API__c = 'User' ); - insert t; - - Profile p = [SELECT Id FROM Profile WHERE Name='Standard User' LIMIT 1]; - User u = new User(Alias = 'standt', Email='standarduser@testorg.com', - EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US', - LocaleSidKey='en_US', ProfileId = p.Id, - TimeZoneSidKey='America/Los_Angeles', UserName='standarduser' + DateTime.now().getTime() + '@testorg.com'); - insert u; + insert templateRecord; + + // avoid mixed dml + System.runAs(new User(Id = UserInfo.getUserId())) { + Group shareGroup = new Group( + Name = TEST_GROUP_NAME, + DeveloperName = String.format('DocGenShareGroup_{0}', new List{String.valueOf(DateTime.now().getTime())}), + Type = 'Regular' + ); + insert shareGroup; + + GroupMember groupMembership = new GroupMember( + GroupId = shareGroup.Id, + UserOrGroupId = UserInfo.getUserId() + ); + insert groupMembership; + } } @IsTest static void testSharingOperations() { - User runAsUser = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(runAsUser) { - DocGen_Template__c t = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - User u = [SELECT Id FROM User WHERE LastName='Testing' LIMIT 1]; + System.runAs(new User(Id = UserInfo.getUserId())) { + DocGen_Template__c templateRecord = [SELECT Id FROM DocGen_Template__c LIMIT 1]; + Group shareGroup = [SELECT Id FROM Group WHERE Name = :TEST_GROUP_NAME LIMIT 1]; Test.startTest(); // Share - DocGenController.shareTemplate(t.Id, u.Id, 'Read'); + DocGenController.shareTemplate(templateRecord.Id, shareGroup.Id, 'Read'); // Verify Share exists via actual SOQL to guarantee valid ID type - DocGen_Template__Share shareRef = [SELECT Id FROM DocGen_Template__Share WHERE ParentId = :t.Id AND UserOrGroupId = :u.Id LIMIT 1]; + DocGen_Template__Share shareRef = [SELECT Id FROM DocGen_Template__Share WHERE ParentId = :templateRecord.Id AND UserOrGroupId = :shareGroup.Id LIMIT 1]; System.assertNotEquals(null, shareRef, 'Share record should exist in DB'); // Remove Share DocGenController.removeShare(shareRef.Id); - List> shares = DocGenController.getTemplateShares(t.Id); + List> shares = DocGenController.getTemplateShares(templateRecord.Id); Boolean found = false; - for (Map s : shares) { - if (s.get('UserOrGroupId') == u.Id) { found = true; } + for (Map shareRecord : shares) { + if (shareRecord.get('UserOrGroupId') == shareGroup.Id) { + found = true; break; + } } System.assert(!found, 'Share should be removed'); @@ -49,10 +60,9 @@ private class DocGenSharingTests { @IsTest static void testSearch() { - User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; - System.runAs(u) { + System.runAs(new User(Id = UserInfo.getUserId())) { Test.startTest(); - List> results = DocGenController.searchUsersAndGroups('Testing'); + List> results = DocGenController.searchUsersAndGroups(UserInfo.getLastName()); Test.stopTest(); System.assert(results.size() > 0, 'Should find user'); } diff --git a/force-app/main/default/classes/DocGenTests.cls b/force-app/main/default/classes/DocGenTests.cls index d1401ca..b62c1fb 100644 --- a/force-app/main/default/classes/DocGenTests.cls +++ b/force-app/main/default/classes/DocGenTests.cls @@ -6,16 +6,12 @@ private class DocGenTests { // Create Template DocGen_Template__c tpl = new DocGen_Template__c( Name = 'Test Template', - Base_Object_API__c = 'Account', - Query_Config__c = 'Name, AccountSource', + Base_Object_API__c = 'User', + Query_Config__c = 'Email, CreatedBy.Email', Type__c = 'Word' ); insert tpl; - // Create Account - Account acc = new Account(Name = 'Test Account', AccountSource = 'Web', Website = 'https://www.example.com'); - insert acc; - // Create ContentVersion (File) ContentVersion cv = new ContentVersion( Title = 'TestDoc', @@ -42,16 +38,16 @@ private class DocGenTests { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - Account acc = [SELECT Id FROM Account LIMIT 1]; + User usr = u; Test.startTest(); - Map result = DocGenController.generateDocumentData(tpl.Id, acc.Id); + Map result = DocGenController.generateDocumentData(tpl.Id, usr.Id); Test.stopTest(); System.assertNotEquals(null, result.get('data'), 'Data should not be null'); System.assertNotEquals(null, result.get('templateFile'), 'File should not be null'); Map data = (Map) result.get('data'); - System.assertEquals('Test Account', data.get('Name'), 'Account Name should match test data'); + System.assertEquals(UserInfo.getUserEmail(), data.get('Email'), 'User Email should match test data'); } } @@ -59,7 +55,7 @@ private class DocGenTests { static void testGetTemplates() { User u = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()]; System.runAs(u) { - List tpls = DocGenController.getTemplatesForObject('Account'); + List tpls = DocGenController.getTemplatesForObject('User'); System.assertEquals(1, tpls.size(), 'Should return 1 template for Account'); } } @@ -77,7 +73,7 @@ private class DocGenTests { Content_Version_Id__c = cv1.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Category__c = 'General', Description__c = 'V1 Description' ); @@ -106,7 +102,7 @@ private class DocGenTests { Content_Version_Id__c = cv2.Id, Is_Active__c = true, Type__c = 'Word', - Base_Object_API__c = 'Account', + Base_Object_API__c = 'User', Category__c = 'General', Description__c = 'V2 Description' ); From f69f73b94986eff56b47d7e0c628e48a57b318ce Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Mar 2026 18:36:45 +0100 Subject: [PATCH 08/19] merge text check, tags should be closed correctly --- force-app/main/default/classes/DocGenMiscTests.cls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index fb92d78..f1c0246 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -562,7 +562,7 @@ private class DocGenMiscTests { } Test.stopTest(); - System.assertEquals(null, result.get('blob'), 'Blob should not be null for unmatched section test'); + System.assertEquals(null, result, 'result should be null for unmatched section test'); } @IsTest @@ -1591,7 +1591,7 @@ private class DocGenMiscTests { } Test.stopTest(); - System.assertEquals(null, result.get('blob'), 'Blob should not be null for unclosed brace test'); + System.assertEquals(null, result, 'result should be null for unclosed brace test'); } @IsTest From 488b8a772d15c1238f5a607c06f5f9617f93b391 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 12:35:01 -0400 Subject: [PATCH 09/19] =?UTF-8?q?wip:=20header/footer=20rendering=20in=20P?= =?UTF-8?q?DF=20=E2=80=94=20merge=20tags=20work,=20formatting=20needs=20po?= =?UTF-8?q?lish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Headers and footers now render in PDF with merge tag replacement - Tables in header/footer lose borders (renderer formatting gap) - Footer positioned inline (Flying Saucer position:fixed crashes) - Needs: border parsing for header/footer tables, proper page positioning Co-Authored-By: Claude Opus 4.6 (1M context) --- .../default/classes/DocGenHtmlRenderer.cls | 71 +++++++++++++++++++ .../main/default/classes/DocGenService.cls | 16 ++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/force-app/main/default/classes/DocGenHtmlRenderer.cls b/force-app/main/default/classes/DocGenHtmlRenderer.cls index c095c75..942036d 100644 --- a/force-app/main/default/classes/DocGenHtmlRenderer.cls +++ b/force-app/main/default/classes/DocGenHtmlRenderer.cls @@ -117,6 +117,77 @@ public with sharing class DocGenHtmlRenderer { return convertToHtml(documentXml, new Map(), null); } + /** + * Converts DOCX XML to an HTML fragment (no html/head/body wrapper). + * Used for rendering header/footer content separately. + */ + public static String convertToHtmlFragment(String xml, Map images) { + if (String.isBlank(xml)) { return ''; } + if (images == null) { images = new Map(); } + String bodyContent = extractBodyContent(xml); + if (String.isBlank(bodyContent)) { + bodyContent = xml; + } + return processBodyContent(bodyContent, images); + } + + /** + * Converts DOCX XML to HTML with header/footer positioned via CSS running elements. + * Flying Saucer (Blob.toPdf engine) supports position:running() and @page margin boxes. + */ + public static String convertToHtmlWithHeaderFooter(String documentXml, Map images, String headerHtml, String footerHtml) { + if (String.isBlank(documentXml)) { + return HTML_PREFIX + HTML_SUFFIX; + } + if (images == null) { images = new Map(); } + + String bodyContent = extractBodyContent(documentXml); + String html = processBodyContent(bodyContent, images); + + // Build CSS with fixed-position header/footer for Flying Saucer + String headerHeight = String.isNotBlank(headerHtml) ? '0.75in' : '0'; + String footerHeight = String.isNotBlank(footerHtml) ? '0.75in' : '0'; + + String css = '@page { size: letter; margin: 1in; margin-top: ' + (String.isNotBlank(headerHtml) ? '1.75in' : '1in') + '; margin-bottom: ' + (String.isNotBlank(footerHtml) ? '1.75in' : '1in') + '; }'; + css += 'body { font-family: Arial, sans-serif; font-size: 11pt; line-height: 1.15; color: #000; }'; + css += 'h1 { font-size: 20pt; font-weight: bold; margin: 0 0 6pt 0; }'; + css += 'h2 { font-size: 16pt; font-weight: bold; margin: 0 0 4pt 0; }'; + css += 'h3 { font-size: 14pt; font-weight: bold; margin: 0 0 4pt 0; }'; + css += 'h4 { font-size: 12pt; font-weight: bold; margin: 0 0 4pt 0; }'; + css += 'h5 { font-size: 11pt; font-weight: bold; margin: 0 0 4pt 0; }'; + css += 'h6 { font-size: 10pt; font-weight: bold; margin: 0 0 4pt 0; }'; + css += 'h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }'; + css += 'table { border-collapse: collapse; width: 100%; page-break-inside: auto; }'; + css += 'tr { page-break-inside: avoid; page-break-after: auto; }'; + css += 'td, th { padding: 4pt; vertical-align: top; }'; + css += 'p { margin: 0 0 8pt 0; orphans: 2; widows: 2; page-break-inside: avoid; }'; + css += 'img { max-width: 100%; }'; + css += 'ul, ol { margin: 0 0 8pt 0; padding-left: 36pt; }'; + css += 'li { margin: 0 0 2pt 0; page-break-inside: avoid; }'; + css += 'hr { border: none; border-top: 1px solid #000; margin: 6pt 0; }'; + css += 'sub { vertical-align: sub; font-size: 0.8em; }'; + css += 'sup { vertical-align: super; font-size: 0.8em; }'; + css += '.docgen-header { border-bottom: 1px solid #ccc; padding-bottom: 6pt; margin-bottom: 12pt; font-size: 9pt; }'; + css += '.docgen-footer { border-top: 1px solid #ccc; padding-top: 6pt; margin-top: 12pt; font-size: 9pt; }'; + css += '.docgen-header table, .docgen-footer table { border-collapse: collapse; width: 100%; }'; + css += '.docgen-header td, .docgen-footer td { padding: 2pt; vertical-align: middle; }'; + + String result = ''; + + if (String.isNotBlank(headerHtml)) { + result += '
' + headerHtml + '
'; + } + + result += html; + + if (String.isNotBlank(footerHtml)) { + result += ''; + } + + result += HTML_SUFFIX; + return result; + } + /** * Extracts content from , , and tags. */ diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 878005c..78d450f 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -816,7 +816,21 @@ public with sharing class DocGenService { } Map pdfImages = buildPdfImageMap(mr, templateId); - String html = DocGenHtmlRenderer.convertToHtml(mr.documentXml, pdfImages); + + // Render header/footer XML separately for CSS @page positioning + String headerHtml = ''; + String footerHtml = ''; + if (mr.processedXmlEntries != null) { + for (String path : mr.processedXmlEntries.keySet()) { + if (path.startsWith('word/header')) { + headerHtml = DocGenHtmlRenderer.convertToHtmlFragment(mr.processedXmlEntries.get(path), pdfImages); + } else if (path.startsWith('word/footer')) { + footerHtml = DocGenHtmlRenderer.convertToHtmlFragment(mr.processedXmlEntries.get(path), pdfImages); + } + } + } + + String html = DocGenHtmlRenderer.convertToHtmlWithHeaderFooter(mr.documentXml, pdfImages, headerHtml, footerHtml); lastRenderedHtml = html; // Blob.toPdf() with the Spring '26 Release Update resolves relative Salesforce From 4d0de7f1de8d67b5eed888866f066394c5150165 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 13:17:46 -0400 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20PDF=20header/footer=20rendering=20?= =?UTF-8?q?=E2=80=94=20proper=20positioning,=20borders=20&=20merge=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #42. Based on approach from PR #46 by @josephedwards-png. - Headers render at top of page, footers pinned to bottom via absolute positioning - Namespaced image relIds prevent collisions between header/footer/document parts - Table style resolution: detect w:tblStyle references (TableGrid etc.) for borders - styles.xml now saved as pre-decomposed CV for future style lookups - Zero paragraph margins in header/footer tables for tight cell spacing - combineXmlWithHeadersFooters() helper used in all 4 render paths - Templates with headers/footers must be re-saved to pick up the fix Known limitation: Cell-level formatting (padding, exact widths) from Word styles renders slightly larger than the original template. Full styles.xml parsing for cell-level properties is a future enhancement. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../default/classes/DocGenHtmlRenderer.cls | 58 ++++++- .../main/default/classes/DocGenService.cls | 143 +++++++++++------- 2 files changed, 148 insertions(+), 53 deletions(-) diff --git a/force-app/main/default/classes/DocGenHtmlRenderer.cls b/force-app/main/default/classes/DocGenHtmlRenderer.cls index 942036d..4eeaed5 100644 --- a/force-app/main/default/classes/DocGenHtmlRenderer.cls +++ b/force-app/main/default/classes/DocGenHtmlRenderer.cls @@ -76,9 +76,55 @@ public with sharing class DocGenHtmlRenderer { images = new Map(); } - String bodyContent = extractBodyContent(documentXml); + // Extract header/footer markers if present + String headerXml = ''; + String footerXml = ''; + String mainXml = documentXml; + if (documentXml.contains('')) { + Integer hStart = documentXml.indexOf('') + ''.length(); + Integer hEnd = documentXml.indexOf(''); + if (hEnd > hStart) { + headerXml = documentXml.substring(hStart, hEnd); + mainXml = documentXml.substring(0, documentXml.indexOf('')) + + documentXml.substring(hEnd + ''.length()); + } + } + if (mainXml.contains('')) { + Integer fStart = mainXml.indexOf('') + ''.length(); + Integer fEnd = mainXml.indexOf(''); + if (fEnd > fStart) { + footerXml = mainXml.substring(fStart, fEnd); + mainXml = mainXml.substring(0, mainXml.indexOf('')); + } + } + + String bodyContent = extractBodyContent(mainXml); String html = processBodyContent(bodyContent, images); + // If we have header/footer, wrap in a table with thead/tfoot for repeating on every page + if (String.isNotBlank(headerXml) || String.isNotBlank(footerXml)) { + String headerHtml = String.isNotBlank(headerXml) ? processBodyContent(extractBodyContent(headerXml), images) : ''; + String footerHtml = String.isNotBlank(footerXml) ? processBodyContent(extractBodyContent(footerXml), images) : ''; + + // Zero out paragraph margins inside header/footer tables for tight cell spacing + headerHtml = headerHtml.replace('

', '

'); + footerHtml = footerHtml.replace('

', '

'); + + // Use a wrapper div with min-height to fill the page, pushing footer to bottom. + // Letter page (11in) minus margins (1in top + 1in bottom) = 9in usable. + // Subtract approximate header height (1in) = ~8in for body+footer. + String wrapped = '

'; + if (String.isNotBlank(headerHtml)) { + wrapped += '
' + headerHtml + '
'; + } + wrapped += '
' + html + '
'; + if (String.isNotBlank(footerHtml)) { + wrapped += '
' + footerHtml + '
'; + } + wrapped += '
'; + html = wrapped; + } + // Inject custom @font-face declarations before the base styles if (String.isNotBlank(fontFaceCss)) { String prefix = ''; + + // Always use dynamic prefix — it has resolved page dimensions and cell padding + // Inject font-face CSS if provided if (String.isNotBlank(fontFaceCss)) { - String prefix = ''; - return prefix + html + HTML_SUFFIX; - } - - return HTML_PREFIX + html + HTML_SUFFIX; + dynamicPrefix = ''; + } + return dynamicPrefix + html + HTML_SUFFIX; } /** @@ -962,14 +971,16 @@ public with sharing class DocGenHtmlRenderer { // Table borders — check inline borders first, then style reference if (tblPr.contains(' @@ -1092,7 +1103,7 @@ public with sharing class DocGenHtmlRenderer { addCellPaddingStyles(styles, tcMar); } } else if (hasBorders) { - styles.add('border:1px solid #000'); + styles.add('border:0.5pt solid #000'); // from hasBorders (style or inline) } // Process cell content (paragraphs) @@ -1110,6 +1121,122 @@ public with sharing class DocGenHtmlRenderer { /** * Adds border CSS styles from a tcBorders element. */ + /** + * Resolves a table style name to its border CSS by looking it up in stylesXml. + * Returns a CSS border shorthand (e.g., "0.5pt solid #000") or null if not found. + */ + /** + * Calculates the usable content height from page dimensions in w:sectPr. + * Parses pgSz (page height) and pgMar (top + bottom margins) from the XML. + * Returns a CSS length value (e.g., "8.8in"). + */ + /** + * Parses page dimensions from w:sectPr and returns usable content height. + */ + private static String calculatePageContentHeight(String xml) { + Map dims = parsePageDimensions(xml); + Decimal usable = dims.get('contentHeight'); + return usable.setScale(1, System.RoundingMode.DOWN) + 'in'; + } + + /** + * Parses page dimensions from w:sectPr. Returns map with keys: + * pageWidth, pageHeight, marginTop, marginRight, marginBottom, marginLeft, + * contentHeight, contentWidth (all in inches). + */ + private static Map parsePageDimensions(String xml) { + Map dims = new Map(); + // Defaults: Letter, 1in margins + Integer pgW = 12240, pgH = 15840; + Integer mTop = 1440, mRight = 1440, mBottom = 1440, mLeft = 1440; + + if (String.isNotBlank(xml) && xml.contains('', tnIdx); + if (tnStart == -1 || tnEnd == -1) { return '0pt 5.4pt'; } + String tnDef = stylesXml.substring(tnStart, tnEnd + 10); + + String topW = extractAttr(tnDef, 'w:top', 'w:w'); + String leftW = extractAttr(tnDef, 'w:left', 'w:w'); + String bottomW = extractAttr(tnDef, 'w:bottom', 'w:w'); + String rightW = extractAttr(tnDef, 'w:right', 'w:w'); + + Decimal top = (topW != null && topW.isNumeric()) ? twipsToPt(Integer.valueOf(topW)) : 0; + Decimal left = (leftW != null && leftW.isNumeric()) ? twipsToPt(Integer.valueOf(leftW)) : 5.4; + Decimal bottom = (bottomW != null && bottomW.isNumeric()) ? twipsToPt(Integer.valueOf(bottomW)) : 0; + Decimal right = (rightW != null && rightW.isNumeric()) ? twipsToPt(Integer.valueOf(rightW)) : 5.4; + + return top + 'pt ' + right + 'pt ' + bottom + 'pt ' + left + 'pt'; + } catch (Exception e) { + return '0pt 5.4pt'; + } + } + + private static String resolveTableStyleBorder(String styleName) { + if (String.isBlank(stylesXml) || String.isBlank(styleName)) { return null; } + + // Find the style definition: + String searchKey = 'w:styleId="' + styleName + '"'; + Integer styleIdx = stylesXml.indexOf(searchKey); + if (styleIdx == -1) { return null; } + + Integer styleStart = stylesXml.lastIndexOf('', styleIdx); + if (styleStart == -1 || styleEnd == -1) { return null; } + + String styleDef = stylesXml.substring(styleStart, styleEnd + 10); + + // Check for tblBorders + if (!styleDef.contains(' styles, String bdrXml) { String topBdr = parseBorderSide(bdrXml, 'w:top'); String bottomBdr = parseBorderSide(bdrXml, 'w:bottom'); diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 64abbf3..6a553f9 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -828,6 +828,10 @@ public with sharing class DocGenService { } Map pdfImages = buildPdfImageMap(mr, templateId); + // Pass styles.xml to renderer for dynamic table style resolution + if (mr.processedXmlEntries != null && mr.processedXmlEntries.containsKey('word/styles.xml')) { + DocGenHtmlRenderer.stylesXml = mr.processedXmlEntries.get('word/styles.xml'); + } String html = DocGenHtmlRenderer.convertToHtml(combineXmlWithHeadersFooters(mr), pdfImages); lastRenderedHtml = html; diff --git a/sfdx-project.json b/sfdx-project.json index ebf3796..baee954 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "1.0.9 — PDF Merger, Client-Side DOCX & Bug Fixes", - "versionNumber": "1.0.9.NEXT", + "versionName": "1.1.0 — Pixel-Perfect PDF Headers, Footers & Dynamic Styles", + "versionNumber": "1.1.0.NEXT", "path": "force-app", "default": true, "package": "Portwood DocGen", From 072380c3ca9029c40c55b5d229301a25cc4e11a5 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 14:49:17 -0400 Subject: [PATCH 12/19] fix: update test assertion for 0.5pt border (was 1px) Co-Authored-By: Claude Opus 4.6 (1M context) --- force-app/main/default/classes/DocGenHtmlRendererTest.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/force-app/main/default/classes/DocGenHtmlRendererTest.cls b/force-app/main/default/classes/DocGenHtmlRendererTest.cls index 962f618..6235032 100644 --- a/force-app/main/default/classes/DocGenHtmlRendererTest.cls +++ b/force-app/main/default/classes/DocGenHtmlRendererTest.cls @@ -377,7 +377,7 @@ private class DocGenHtmlRendererTest { '
'; String html = DocGenHtmlRenderer.convertToHtml(xml); - System.assert(html.contains('border:1px solid #000'), 'Should have cell borders'); + System.assert(html.contains('border:0.5pt solid #000'), 'Should have cell borders'); System.assert(html.contains('Pct'), 'Should contain pct cell text'); System.assert(html.contains('Dxa'), 'Should contain dxa cell text'); } From a5a977f21d2d550ebd92265fccf2cfbfa240ca07 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 15:16:15 -0400 Subject: [PATCH 13/19] chore: update install links to v1.1.0 package (04tal000006PCFRAA4) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 ++-- devhub-tools/main/default/classes/DocGenLandingController.cls | 4 ++-- devhub-tools/main/default/pages/DocGenLanding.page | 2 +- sfdx-project.json | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e017538..b4cdcfa 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ Scanned with `sf code-analyzer run --rule-selector "recommended"` — the rule s **New install:** ```bash -sf package install --package 04tal000006PBrFAAW --wait 10 --target-org +sf package install --package 04tal000006PCFRAA4 --wait 10 --target-org ``` -[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PBrFAAW) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PBrFAAW) +[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4) **Then:** Assign **DocGen Admin** permission set | Enable **Blob.toPdf() Release Update** | Open the **DocGen** app diff --git a/devhub-tools/main/default/classes/DocGenLandingController.cls b/devhub-tools/main/default/classes/DocGenLandingController.cls index 9ebc17d..9d8ef58 100644 --- a/devhub-tools/main/default/classes/DocGenLandingController.cls +++ b/devhub-tools/main/default/classes/DocGenLandingController.cls @@ -7,8 +7,8 @@ public without sharing class DocGenLandingController { // Store demo session in a custom setting or org-wide default // For now, use a static approach — refresh via CLI - private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PBrFAAW'; - private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PBrFAAW'; + private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4'; + private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4'; public String installLinkProd { get { return INSTALL_LINK_PROD; } } public String installLinkSandbox { get { return INSTALL_LINK_SANDBOX; } } diff --git a/devhub-tools/main/default/pages/DocGenLanding.page b/devhub-tools/main/default/pages/DocGenLanding.page index 5665fd4..766bfda 100644 --- a/devhub-tools/main/default/pages/DocGenLanding.page +++ b/devhub-tools/main/default/pages/DocGenLanding.page @@ -276,7 +276,7 @@ Install in Production Install in Sandbox -
sf package install --package 04tal000006PBrFAAW --wait 10 --target-org your-org
+
sf package install --package 04tal000006PCFRAA4 --wait 10 --target-org your-org

Then: Assign DocGen Admin permission set • Open the DocGen app

diff --git a/sfdx-project.json b/sfdx-project.json index baee954..a6c760e 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -27,6 +27,7 @@ "Portwood DocGen@1.0.6-1": "04tal000006PANJAA4", "Portwood DocGen@1.0.7-1": "04tal000006PAOvAAO", "Portwood DocGen@1.0.8-1": "04tal000006PB01AAG", - "Portwood DocGen@1.0.9-1": "04tal000006PBrFAAW" + "Portwood DocGen@1.0.9-1": "04tal000006PBrFAAW", + "Portwood DocGen@1.1.0-1": "04tal000006PCFRAA4" } } \ No newline at end of file From 00ea392bb69dff086d518c58cc66350bbda43c17 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 19:55:18 -0400 Subject: [PATCH 14/19] =?UTF-8?q?release:=20v1.1.1=20=E2=80=94=20Image=20S?= =?UTF-8?q?izing,=20Error=20Diagnostics=20&=20Multiline=20Text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates PR #47 from @Henk3000 with enhancements: - ImageRenderSpec: percentage sizing ({%Logo:100%x}), max constraints ({%Logo:m100%x}), intrinsic PNG/JPEG dimension detection, aspect ratio preservation - Error diagnostics: malformed merge tags and unclosed loops now throw DocGenException with descriptive messages - Multiline text: newlines in text fields render as proper with correct run element handling - Smart container expansion: loops inside numbered/bulleted lists now repeat list formatting (previously only table rows) - ahe() helper: consistent AuraHandledException creation with original exception logging across all 38 throw sites - Universal file save: saveContentVersion() handles objects that don't support FirstPublishLocationId or ContentDocumentLink (e.g. Pricebook2) - 0 Critical, 0 High on Code Analyzer. 507/507 tests, 22/22 E2E Thanks to @Henk3000 for the excellent PR — the ImageRenderSpec and error diagnostics are significant improvements to the template engine. Co-Authored-By: Henk3000 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 + README.md | 6 +- .../classes/DocGenLandingController.cls | 4 +- .../main/default/pages/DocGenLanding.page | 2 +- .../default/classes/DocGenBulkController.cls | 6 +- .../main/default/classes/DocGenController.cls | 16 +- .../default/classes/DocGenDataRetriever.cls | 8 +- .../default/classes/DocGenHtmlRenderer.cls | 2 +- .../main/default/classes/DocGenMiscTests.cls | 10 +- .../main/default/classes/DocGenService.cls | 457 ++++++++++++++---- sfdx-project.json | 7 +- 11 files changed, 396 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a7bd3..3f72a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v1.1.1 — "Image Sizing, Error Diagnostics & Multiline Text" (Portwood DocGen) + +Huge thanks to **@Henk3000** for PR #47 — ImageRenderSpec, ahe() helper, multiline text preservation, error diagnostics for malformed tags, and smart container expansion for numbered lists. + +- **ImageRenderSpec** — Percentage-based image sizing (`{%Logo:100%x}`), max constraints (`{%Logo:m100%x}`), intrinsic dimension detection from PNG/JPEG headers, aspect ratio preservation. Credit: @Henk3000 PR #47. +- **Error Diagnostics** — Malformed merge tags and unclosed loop tags now throw `DocGenException` with descriptive messages instead of silently producing broken output. +- **Multiline Text Preservation** — Newlines in Long Text Area and Text Area fields now render as proper Word line breaks (``) with correct run element handling. +- **Smart Container Expansion** — Loop tags inside numbered/bulleted lists now repeat the list paragraph formatting. Previously only table rows were detected. +- **`ahe()` Helper** — Consistent `AuraHandledException` creation with original exception logging. All 38 throw sites migrated. +- **Universal File Save** — `saveContentVersion()` gracefully handles objects that don't support `FirstPublishLocationId` or `ContentDocumentLink` (e.g., Pricebook2). +- **507/507 Apex tests**, 79% coverage, 0 Critical/High. E2E 22/22. + ## v1.1.0 — "Pixel-Perfect PDF" (Portwood DocGen) Huge thanks to **@josephedwards-png** for PR #46 — his analysis of the relId collision bug and namespacing approach was the key insight that unlocked header/footer image rendering. diff --git a/README.md b/README.md index b4cdcfa..ea697a7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generate PDFs, Word docs, Excel spreadsheets, and PowerPoint presentations from any Salesforce record. Merge PDFs, add barcodes and QR codes, compute totals — 100% native, zero external dependencies, completely free. -[![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)](#install) +[![Version](https://img.shields.io/badge/version-1.1.1-blue.svg)](#install) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Salesforce-00A1E0.svg)](https://www.salesforce.com) [![Namespace](https://img.shields.io/badge/namespace-portwoodglobal-purple.svg)](#install) @@ -30,10 +30,10 @@ Scanned with `sf code-analyzer run --rule-selector "recommended"` — the rule s **New install:** ```bash -sf package install --package 04tal000006PCFRAA4 --wait 10 --target-org +sf package install --package 04tal000006PCTxAAO --wait 10 --target-org ``` -[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4) +[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO) **Then:** Assign **DocGen Admin** permission set | Enable **Blob.toPdf() Release Update** | Open the **DocGen** app diff --git a/devhub-tools/main/default/classes/DocGenLandingController.cls b/devhub-tools/main/default/classes/DocGenLandingController.cls index 9d8ef58..ba1b993 100644 --- a/devhub-tools/main/default/classes/DocGenLandingController.cls +++ b/devhub-tools/main/default/classes/DocGenLandingController.cls @@ -7,8 +7,8 @@ public without sharing class DocGenLandingController { // Store demo session in a custom setting or org-wide default // For now, use a static approach — refresh via CLI - private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4'; - private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCFRAA4'; + private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO'; + private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO'; public String installLinkProd { get { return INSTALL_LINK_PROD; } } public String installLinkSandbox { get { return INSTALL_LINK_SANDBOX; } } diff --git a/devhub-tools/main/default/pages/DocGenLanding.page b/devhub-tools/main/default/pages/DocGenLanding.page index 766bfda..b6fde58 100644 --- a/devhub-tools/main/default/pages/DocGenLanding.page +++ b/devhub-tools/main/default/pages/DocGenLanding.page @@ -276,7 +276,7 @@ Install in Production Install in Sandbox -
sf package install --package 04tal000006PCFRAA4 --wait 10 --target-org your-org
+
sf package install --package 04tal000006PCTxAAO --wait 10 --target-org your-org

Then: Assign DocGen Admin permission set • Open the DocGen app

diff --git a/force-app/main/default/classes/DocGenBulkController.cls b/force-app/main/default/classes/DocGenBulkController.cls index 2592424..d5e2933 100644 --- a/force-app/main/default/classes/DocGenBulkController.cls +++ b/force-app/main/default/classes/DocGenBulkController.cls @@ -312,7 +312,7 @@ public with sharing class DocGenBulkController { return job.Id; } catch (Exception e) { - throw DocGenService.ahe('Error starting bulk generation job.'); + throw DocGenService.ahe(e, 'Error starting bulk generation job.'); } } @@ -435,7 +435,7 @@ public with sharing class DocGenBulkController { // Package-internal object — CRUD controlled by DocGen permission sets insert sq; // NOPMD ApexCRUDViolation - package-internal custom object; access controlled by DocGen permission sets } catch (Exception e) { - throw DocGenService.ahe('Error saving query.'); + throw DocGenService.ahe(e, 'Error saving query.'); } } @@ -451,7 +451,7 @@ public with sharing class DocGenBulkController { } delete new DocGen_Saved_Query__c(Id = queryId); } catch (Exception e) { - throw DocGenService.ahe('Error deleting query.'); + throw DocGenService.ahe(e, 'Error deleting query.'); } } } \ No newline at end of file diff --git a/force-app/main/default/classes/DocGenController.cls b/force-app/main/default/classes/DocGenController.cls index 90360da..510a0d9 100644 --- a/force-app/main/default/classes/DocGenController.cls +++ b/force-app/main/default/classes/DocGenController.cls @@ -171,7 +171,7 @@ public with sharing class DocGenController { 'templateType' => result.get('templateType') }; } catch (Exception e) { - throw DocGenService.ahe(e,'Error generating document. Please check the template configuration.'); + throw DocGenService.ahe(e, 'Error generating document. Please check the template configuration.'); } } @@ -188,7 +188,7 @@ public with sharing class DocGenController { try { return DocGenService.generateDocumentParts(templateId, recordId); } catch (Exception e) { - throw DocGenService.ahe(e,'Error generating document parts. Please check the template configuration.'); + throw DocGenService.ahe(e, 'Error generating document parts. Please check the template configuration.'); } } @@ -364,7 +364,7 @@ public with sharing class DocGenController { } } } catch (Exception e) { - throw DocGenService.ahe(e,'Error saving template. Please verify your inputs and permissions.'); + throw DocGenService.ahe(e, 'Error saving template. Please verify your inputs and permissions.'); } } @@ -380,7 +380,7 @@ public with sharing class DocGenController { } delete new DocGen_Template__c(Id = templateId); } catch (Exception e) { - throw DocGenService.ahe(e,'Error deleting template. Please check your permissions.'); + throw DocGenService.ahe(e, 'Error deleting template. Please check your permissions.'); } } @@ -479,7 +479,7 @@ public with sharing class DocGenController { }; } } catch (Exception e) { - throw DocGenService.ahe('Error generating PDF document. Please check the template configuration.'); + throw DocGenService.ahe(e, 'Error generating PDF document. Please check the template configuration.'); } } @@ -776,7 +776,7 @@ public with sharing class DocGenController { SObjectAccessDecision shareDecision = Security.stripInaccessible(AccessType.CREATABLE, new List{ share }); insert shareDecision.getRecords(); } catch (DmlException e) { - throw DocGenService.ahe(e); + throw DocGenService.ahe(e, 'Error sharing template. Please check permissions.'); } } @@ -792,7 +792,7 @@ public with sharing class DocGenController { } delete new DocGen_Template__Share(Id = shareId); // NOPMD ApexCRUDViolation - isDeletable() checked above; package-internal share object } catch (DmlException e) { - throw DocGenService.ahe(e); + throw DocGenService.ahe(e, 'Error removing share. Please check permissions.'); } } @@ -952,7 +952,7 @@ public with sharing class DocGenController { try { childRecords = Database.query(childQuery, AccessLevel.USER_MODE); // NOPMD ApexSOQLInjection - object/field names validated against Schema.getGlobalDescribe(); runs with USER_MODE } catch (Exception e) { - throw DocGenService.ahe('Error querying child records: ' + e.getMessage()); + throw DocGenService.ahe(e, 'Error querying child records'); } if (childRecords.isEmpty()) { diff --git a/force-app/main/default/classes/DocGenDataRetriever.cls b/force-app/main/default/classes/DocGenDataRetriever.cls index 44c1e56..657553d 100644 --- a/force-app/main/default/classes/DocGenDataRetriever.cls +++ b/force-app/main/default/classes/DocGenDataRetriever.cls @@ -112,7 +112,7 @@ public with sharing class DocGenDataRetriever { } data = mapSObject(results[0]); } catch (Exception e) { - throw DocGenService.ahe('Error executing query. Please verify your template configuration.'); + throw DocGenService.ahe(e, 'Error executing query. Please verify your template configuration.'); } // Stitch deeper levels (grandchildren, great-grandchildren) @@ -260,7 +260,7 @@ public with sharing class DocGenDataRetriever { } data = mapSObject(results[0]); } catch (Exception e) { - throw DocGenService.ahe('Error executing query. Please verify your template configuration.'); + throw DocGenService.ahe(e, 'Error executing query. Please verify your template configuration.'); } // Stitch junction targets (two-hop: junction record → target object) @@ -465,7 +465,7 @@ public with sharing class DocGenDataRetriever { } rootData = mapSObject(results[0]); } catch (Exception e) { - throw DocGenService.ahe('Error querying root record: ' + e.getMessage()); + throw DocGenService.ahe(e); } // Walk the tree: process children of each node @@ -560,7 +560,7 @@ public with sharing class DocGenDataRetriever { allData.put(rec.Id, mapSObject(rec)); } } catch (Exception e) { - throw DocGenService.ahe('Error bulk-querying records: ' + e.getMessage()); + throw DocGenService.ahe(e); } // Walk the tree in bulk: process children for all root records at once diff --git a/force-app/main/default/classes/DocGenHtmlRenderer.cls b/force-app/main/default/classes/DocGenHtmlRenderer.cls index 14867e4..8f999ce 100644 --- a/force-app/main/default/classes/DocGenHtmlRenderer.cls +++ b/force-app/main/default/classes/DocGenHtmlRenderer.cls @@ -1165,7 +1165,7 @@ public with sharing class DocGenHtmlRenderer { if (val != null && val.isNumeric()) { mBottom = Integer.valueOf(val); } val = extractAttr(xml, 'w:pgMar', 'w:left'); if (val != null && val.isNumeric()) { mLeft = Integer.valueOf(val); } - } catch (Exception e) { /* use defaults */ } + } catch (Exception e) { /* use defaults */ } // NOPMD EmptyCatchBlock - intentional fallback to default page dimensions } dims.put('pageWidth', pgW / 1440.0); diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index f1c0246..9c6246f 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -553,16 +553,16 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Account acc = [SELECT Id FROM Account LIMIT 1]; - Map result; + Map result; Test.startTest(); - try { + try { result = DocGenService.processDocument(tpl.Id, acc.Id); } catch (DocGenException e) { System.assert(e.getMessage().contains('Malformed loop tag'), 'Exception message should contain "Malformed loop tag"'); } Test.stopTest(); - System.assertEquals(null, result, 'result should be null for unmatched section test'); + System.assertEquals(null, result, 'Result should be null for unmatched section test'); } @IsTest @@ -1582,7 +1582,7 @@ private class DocGenMiscTests { DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; Account acc = [SELECT Id FROM Account LIMIT 1]; - Map result; + Map result; Test.startTest(); try { result = DocGenService.processDocument(tpl.Id, acc.Id); @@ -1591,7 +1591,7 @@ private class DocGenMiscTests { } Test.stopTest(); - System.assertEquals(null, result, 'result should be null for unclosed brace test'); + System.assertEquals(null, result, 'Result should be null for unclosed brace test'); } @IsTest diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 6a553f9..ff27287 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -1,5 +1,35 @@ public with sharing class DocGenService { + /** + * Creates an AuraHandledException with a user-facing message. + * Centralizes exception creation for consistent error handling. + */ + public static AuraHandledException ahe(String message) { + AuraHandledException ex = new AuraHandledException(message); + ex.setMessage(message); + return ex; + } + + /** + * Creates an AuraHandledException that includes the original exception details + * for debugging while showing a user-friendly message. + */ + public static AuraHandledException ahe(Exception cause, String message) { + String fullMessage = message + ' (' + cause.getMessage() + ')'; + AuraHandledException ex = new AuraHandledException(fullMessage); + ex.setMessage(fullMessage); + return ex; + } + + /** + * Creates an AuraHandledException from an existing exception, preserving its message. + */ + public static AuraHandledException ahe(Exception cause) { + AuraHandledException ex = new AuraHandledException(cause.getMessage()); + ex.setMessage(cause.getMessage()); + return ex; + } + // Collector for images to inject after XML processing private static List> pendingImages = new List>(); private static Integer imageCounter = 0; @@ -1378,7 +1408,8 @@ public with sharing class DocGenService { if (currentTemplateType == 'PowerPoint') { output += strVal.escapeXml(); } else { - output += escapeXmlPreservingDocxLineBreaks(strVal); + // Multiline support: convert newlines to Word line breaks + output += convertMultilineToXml(strVal); } } } @@ -1425,26 +1456,30 @@ public with sharing class DocGenService { return Math.max(withAttributesIndex, bareTagIndex); } - private static String escapeXmlPreservingDocxLineBreaks(String inputText) { + /** + * Converts a text value with newlines to Word XML with line breaks. + * Properly closes and reopens runs around each break for valid OOXML. + */ + private static String convertMultilineToXml(String inputText) { if (inputText == null) { return ''; } - String normalizedText = inputText.replace('\r\n', '\n').replace('\r', '\n'); - if (!normalizedText.contains('\n')) { - return normalizedText.escapeXml(); + String normalized = inputText.replace('\r\n', '\n').replace('\r', '\n'); + if (!normalized.contains('\n')) { + return normalized.escapeXml(); } - List lineParts = normalizedText.split('\n'); - String joinedText = ''; + List lines = normalized.split('\n'); + String result = ''; String lineBreakXml = ''; - for (Integer partIndex = 0; partIndex < lineParts.size(); partIndex++) { - if (partIndex > 0) { - joinedText += lineBreakXml; + for (Integer i = 0; i < lines.size(); i++) { + if (i > 0) { + result += lineBreakXml; } - joinedText += lineParts[partIndex].escapeXml(); + result += lines[i].escapeXml(); } - return joinedText; + return result; } - + private static Object resolveValue(Map data, String path) { if (data == null) { return null; @@ -2176,21 +2211,21 @@ public with sharing class DocGenService { } private static String buildImageXml(String fieldValue) { - ImageRenderSpec imageRenderSpec = new ImageRenderSpec(); - imageRenderSpec.fieldValue = fieldValue; - return buildImageXml(imageRenderSpec); + ImageRenderSpec spec = new ImageRenderSpec(); + spec.fieldValue = fieldValue; + return buildImageXml(spec); } private static String buildImageXml(String fieldValue, Integer widthPx, Integer heightPx) { - ImageRenderSpec imageRenderSpec = new ImageRenderSpec(); - imageRenderSpec.fieldValue = fieldValue; - imageRenderSpec.widthPx = widthPx; - imageRenderSpec.heightPx = heightPx; - return buildImageXml(imageRenderSpec); + ImageRenderSpec spec = new ImageRenderSpec(); + spec.fieldValue = fieldValue; + spec.widthPx = widthPx; + spec.heightPx = heightPx; + return buildImageXml(spec); } - private static String buildImageXml(ImageRenderSpec imageRenderSpec) { - if (imageRenderSpec == null || String.isBlank(imageRenderSpec.fieldValue)) { + private static String buildImageXml(ImageRenderSpec spec) { + if (spec == null || String.isBlank(spec.fieldValue)) { return null; } @@ -2198,120 +2233,108 @@ public with sharing class DocGenService { try { // 1. Rich text HTML containing tags - if (imageRenderSpec.fieldValue.containsIgnoreCase(' 100 && !imageRenderSpec.fieldValue.contains('/') && !imageRenderSpec.fieldValue.contains(' ')) { - imageRenderSpec.imageBlobData = EncodingUtil.base64Decode(imageRenderSpec.fieldValue); + else if (spec.fieldValue.length() > 100 && !spec.fieldValue.contains('/') && !spec.fieldValue.contains(' ')) { + spec.imageBlobData = EncodingUtil.base64Decode(spec.fieldValue); } // 4. ContentVersion ID (starts with 068) — works for both DOCX (blob) and PDF (URL) - else if (imageRenderSpec.fieldValue.startsWith('068') && imageRenderSpec.fieldValue.length() >= 15) { + else if (spec.fieldValue.startsWith('068') && spec.fieldValue.length() >= 15) { if (currentOutputFormat == 'PDF') { // PDF path: only need the URL — Blob.toPdf() fetches images server-side. // Skip loading the blob entirely to save heap. - List cvs = [SELECT Id, FileExtension FROM ContentVersion WHERE Id = :imageRenderSpec.fieldValue WITH SYSTEM_MODE LIMIT 1]; + List cvs = [SELECT Id, FileExtension FROM ContentVersion WHERE Id = :spec.fieldValue WITH SYSTEM_MODE LIMIT 1]; if (!cvs.isEmpty()) { if (cvs[0].FileExtension != null) { ext = cvs[0].FileExtension.toLowerCase(); } - imageRenderSpec.contentVersionImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; + spec.contentVersionImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; } } else { // DOCX path: need the blob for ZIP embedding - List cvs = [SELECT Id, VersionData, FileExtension FROM ContentVersion WHERE Id = :imageRenderSpec.fieldValue WITH SYSTEM_MODE LIMIT 1]; + List cvs = [SELECT Id, VersionData, FileExtension FROM ContentVersion WHERE Id = :spec.fieldValue WITH SYSTEM_MODE LIMIT 1]; if (!cvs.isEmpty()) { - imageRenderSpec.imageBlobData = cvs[0].VersionData; + spec.imageBlobData = cvs[0].VersionData; if (cvs[0].FileExtension != null) { ext = cvs[0].FileExtension.toLowerCase(); } - imageRenderSpec.contentVersionImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; + spec.contentVersionImageUrl = '/sfc/servlet.shepherd/version/download/' + cvs[0].Id; } } } // 5. Salesforce file URL containing refid (rich text image reference) - else if (imageRenderSpec.fieldValue.containsIgnoreCase('refid=')) { - String refId = imageRenderSpec.fieldValue.substringAfterLast('refid=').substringBefore('&'); - if (String.isBlank(refId)) { refId = imageRenderSpec.fieldValue.substringAfterLast('refid='); } - imageRenderSpec.imageBlobData = fetchSalesforceImage(refId); + else if (spec.fieldValue.containsIgnoreCase('refid=')) { + String refId = spec.fieldValue.substringAfterLast('refid=').substringBefore('&'); + if (String.isBlank(refId)) { refId = spec.fieldValue.substringAfterLast('refid='); } + spec.imageBlobData = fetchSalesforceImage(refId); } // 6. Salesforce servlet/sfc URL - else if (imageRenderSpec.fieldValue.containsIgnoreCase('/servlet/') || imageRenderSpec.fieldValue.containsIgnoreCase('/sfc/')) { - String docId = imageRenderSpec.fieldValue.substringAfterLast('/'); + else if (spec.fieldValue.containsIgnoreCase('/servlet/') || spec.fieldValue.containsIgnoreCase('/sfc/')) { + String docId = spec.fieldValue.substringAfterLast('/'); if (docId.contains('?')) { docId = docId.substringBefore('?'); } - imageRenderSpec.imageBlobData = fetchSalesforceImage(docId); + spec.imageBlobData = fetchSalesforceImage(docId); } // 7. Generic HTTPS URL — can't fetch without session ID (AppExchange safe). - // For {%ImageField} tags, users should use ContentVersion IDs or data URIs. - else if (imageRenderSpec.fieldValue.startsWith('https://')) { - // External URL images cannot be fetched without session ID — users should use ContentVersion IDs or data URIs + else if (spec.fieldValue.startsWith('https://')) { + // External URL images cannot be fetched without session ID } // If no blob but we have a URL, store it for PDF rendering - // (Blob.toPdf resolves Salesforce URLs natively via the VF engine) - imageRenderSpec.sourceUrl = imageRenderSpec.contentVersionImageUrl; // Use CV URL if already resolved (PDF path) - if (imageRenderSpec.imageBlobData == null && imageRenderSpec.sourceUrl == null) { - // Try to extract a URL from the field value for PDF - if (imageRenderSpec.fieldValue.containsIgnoreCase(' imgEntry = new Map{ - 'relId' => imageRenderSpec.relationshipId, - 'fileName' => imageRenderSpec.fileName + 'relId' => spec.relationshipId, + 'fileName' => spec.fileName }; - if (imageRenderSpec.imageBlobData != null) { - imgEntry.put('blob', imageRenderSpec.imageBlobData); + if (spec.imageBlobData != null) { + imgEntry.put('blob', spec.imageBlobData); } - // Prefer CV URL (committed, works for PDF), fall back to extracted source URL - if (imageRenderSpec.contentVersionImageUrl != null) { - imgEntry.put('url', imageRenderSpec.contentVersionImageUrl); - } else if (imageRenderSpec.sourceUrl != null) { - imgEntry.put('url', imageRenderSpec.sourceUrl); + if (spec.contentVersionImageUrl != null) { + imgEntry.put('url', spec.contentVersionImageUrl); + } else if (spec.sourceUrl != null) { + imgEntry.put('url', spec.sourceUrl); } pendingImages.add(imgEntry); - // Use DrawingML format () for broad compatibility. - // VML () works in Word but docx-preview (used for client-side PDF) cannot render it. - // DrawingML is the modern OOXML standard supported by Word, docx-preview, and Salesforce rendition API. - List intrinsicSizeEmu = getImageDimensionsInEmu(imageRenderSpec.imageBlobData, imageRenderSpec.fileExtension); + // Compute image dimensions in EMU with intrinsic size detection and scaling + List intrinsicSizeEmu = getImageDimensionsInEmu(spec.imageBlobData, ext); Integer baseWidthEmu = (intrinsicSizeEmu != null && intrinsicSizeEmu.size() == 2) ? intrinsicSizeEmu[0] : (288 * 12700); Integer baseHeightEmu = (intrinsicSizeEmu != null && intrinsicSizeEmu.size() == 2) ? intrinsicSizeEmu[1] : (216 * 12700); - Integer[] scaledDimensionsEmu = scaleImageDimensionsEmu( - baseWidthEmu, - baseHeightEmu, - imageRenderSpec - ); - Integer widthEmu = scaledDimensionsEmu[0]; - Integer heightEmu = scaledDimensionsEmu[1]; + Integer[] scaledDims = scaleImageDimensionsEmu(baseWidthEmu, baseHeightEmu, spec); + Integer widthEmu = scaledDims[0]; + Integer heightEmu = scaledDims[1]; return '' + '' + @@ -2327,7 +2350,7 @@ public with sharing class DocGenService { '' + '' + '' + - '' + '' + '' + @@ -2350,6 +2373,205 @@ public with sharing class DocGenService { } } + private static ImageRenderSpec parseImageTagSpec(String imageTagSpec) { + ImageRenderSpec spec = new ImageRenderSpec(); + spec.imageField = imageTagSpec; + if (String.isBlank(imageTagSpec) || !imageTagSpec.contains(':')) { + return spec; + } + + List tagParts = imageTagSpec.split(':', 2); + spec.imageField = tagParts[0].trim(); + String sizeToken = tagParts[1].trim().toLowerCase(); + if (!sizeToken.contains('x')) { + return spec; + } + + Integer separatorIndex = sizeToken.indexOf('x'); + if (separatorIndex < 0) { + return spec; + } + String widthToken = sizeToken.substring(0, separatorIndex).trim(); + String heightToken = sizeToken.substring(separatorIndex + 1).trim(); + + ImageDimensionSpec widthSpec = parseImageDimensionSpec(widthToken); + ImageDimensionSpec heightSpec = parseImageDimensionSpec(heightToken); + if (widthSpec != null) { + if (widthSpec.isMaxConstraint) { + spec.maxWidthPx = widthSpec.pixelValue; + spec.maxWidthPercent = widthSpec.percentValue; + } else { + spec.widthPx = widthSpec.pixelValue; + spec.widthPercent = widthSpec.percentValue; + } + } + if (heightSpec != null) { + if (heightSpec.isMaxConstraint) { + spec.maxHeightPx = heightSpec.pixelValue; + spec.maxHeightPercent = heightSpec.percentValue; + } else { + spec.heightPx = heightSpec.pixelValue; + spec.heightPercent = heightSpec.percentValue; + } + } + return spec; + } + + private static ImageDimensionSpec parseImageDimensionSpec(String rawToken) { + if (String.isBlank(rawToken)) { + return null; + } + String token = rawToken.trim().toLowerCase(); + Boolean isMax = token.startsWith('m'); + if (isMax) { + token = token.substring(1).trim(); + } + if (String.isBlank(token)) { + return null; + } + + Integer percentValue = null; + Integer pixelValue = null; + if (token.endsWith('%')) { + String num = token.substring(0, token.length() - 1).trim(); + if (num.isNumeric()) { + percentValue = Integer.valueOf(num); + } + } else if (token.endsWith('px')) { + String num = token.substring(0, token.length() - 2).trim(); + if (num.isNumeric()) { + pixelValue = Integer.valueOf(num); + } + } else if (token.isNumeric()) { + pixelValue = Integer.valueOf(token); + } + + if (percentValue == null && pixelValue == null) { + return null; + } + ImageDimensionSpec dimSpec = new ImageDimensionSpec(); + dimSpec.isMaxConstraint = isMax; + dimSpec.percentValue = percentValue; + dimSpec.pixelValue = pixelValue; + return dimSpec; + } + + private static Integer parseHexToInt(String hexStr) { + if (String.isBlank(hexStr)) { + return 0; + } + String hexChars = '0123456789ABCDEF'; + Integer result = 0; + for (Integer i = 0; i < hexStr.length(); i++) { + Integer digit = hexChars.indexOf(hexStr.substring(i, i + 1).toUpperCase()); + if (digit < 0) { + return 0; + } + result = (result * 16) + digit; + } + return result; + } + + private static List getImageDimensionsInEmu(Blob imageBlob, String fileExtension) { + if (imageBlob == null || imageBlob.size() < 24) { + return null; + } + String hex = EncodingUtil.convertToHex(imageBlob); + if (String.isBlank(hex) || hex.length() < 64) { + return null; + } + String hexUpper = hex.toUpperCase(); + String extLower = fileExtension != null ? fileExtension.toLowerCase() : ''; + Integer widthPx = null; + Integer heightPx = null; + try { + Boolean looksPng = extLower == 'png' || (hexUpper.length() >= 16 && hexUpper.substring(0, 16) == '89504E470D0A1A0A'); + if (looksPng) { + Integer ihdrIdx = hexUpper.indexOf('49484452'); + if (ihdrIdx >= 0 && ihdrIdx + 24 <= hexUpper.length()) { + Integer w = parseHexToInt(hexUpper.substring(ihdrIdx + 8, ihdrIdx + 16)); + Integer h = parseHexToInt(hexUpper.substring(ihdrIdx + 16, ihdrIdx + 24)); + if (w > 0 && h > 0) { + widthPx = w; + heightPx = h; + } + } + } + Boolean looksJpeg = extLower == 'jpg' || extLower == 'jpeg' || (hexUpper.length() >= 4 && hexUpper.substring(0, 4) == 'FFD8'); + if ((widthPx == null || heightPx == null) && looksJpeg) { + Integer sofIdx = hexUpper.indexOf('FFC0'); + if (sofIdx >= 0 && sofIdx + 18 <= hexUpper.length()) { + heightPx = parseHexToInt(hexUpper.substring(sofIdx + 10, sofIdx + 14)); + widthPx = parseHexToInt(hexUpper.substring(sofIdx + 14, sofIdx + 18)); + } + } + if (widthPx == null || heightPx == null || widthPx <= 0 || heightPx <= 0) { + return null; + } + Integer emuPerPx = 9525; + return new List{widthPx * emuPerPx, heightPx * emuPerPx}; + } catch (Exception e) { + return null; + } + } + + private static Integer[] scaleImageDimensionsEmu(Integer baseWidthEmu, Integer baseHeightEmu, ImageRenderSpec spec) { + Integer pageContentWidthEmu = 5943600; // 6.5in + Integer pageContentHeightEmu = 8229600; // 9in + + Integer resolvedW = baseWidthEmu; + Integer resolvedH = baseHeightEmu; + + Integer fixedW = spec.widthPx != null ? (spec.widthPx * 9525) : null; + if (fixedW == null && spec.widthPercent != null) { + fixedW = (pageContentWidthEmu * spec.widthPercent) / 100; + } + Integer fixedH = spec.heightPx != null ? (spec.heightPx * 9525) : null; + if (fixedH == null && spec.heightPercent != null) { + fixedH = (pageContentHeightEmu * spec.heightPercent) / 100; + } + + if (fixedW != null && fixedW > 0 && fixedH != null && fixedH > 0) { + resolvedW = fixedW; + resolvedH = fixedH; + } else if (fixedW != null && fixedW > 0 && baseWidthEmu > 0) { + Decimal scale = ((Decimal)fixedW) / ((Decimal)baseWidthEmu); + resolvedW = fixedW; + resolvedH = (((Decimal)baseHeightEmu) * scale).intValue(); + } else if (fixedH != null && fixedH > 0 && baseHeightEmu > 0) { + Decimal scale = ((Decimal)fixedH) / ((Decimal)baseHeightEmu); + resolvedH = fixedH; + resolvedW = (((Decimal)baseWidthEmu) * scale).intValue(); + } + + Integer maxW = spec.maxWidthPx != null ? (spec.maxWidthPx * 9525) : null; + if (maxW == null && spec.maxWidthPercent != null) { + maxW = (pageContentWidthEmu * spec.maxWidthPercent) / 100; + } + Integer maxH = spec.maxHeightPx != null ? (spec.maxHeightPx * 9525) : null; + if (maxH == null && spec.maxHeightPercent != null) { + maxH = (pageContentHeightEmu * spec.maxHeightPercent) / 100; + } + + Decimal maxScale = 1; + if (maxW != null && maxW > 0 && resolvedW > maxW && resolvedW > 0) { + Decimal ws = ((Decimal)maxW) / ((Decimal)resolvedW); + if (ws < maxScale) { maxScale = ws; } + } + if (maxH != null && maxH > 0 && resolvedH > maxH && resolvedH > 0) { + Decimal hs = ((Decimal)maxH) / ((Decimal)resolvedH); + if (hs < maxScale) { maxScale = hs; } + } + if (maxScale < 1) { + resolvedW = (((Decimal)resolvedW) * maxScale).intValue(); + resolvedH = (((Decimal)resolvedH) * maxScale).intValue(); + } + + if (resolvedW == null || resolvedW <= 0) { resolvedW = 288 * 12700; } + if (resolvedH == null || resolvedH <= 0) { resolvedH = 216 * 12700; } + return new Integer[]{resolvedW, resolvedH}; + } + /** * Parses a size token for image specs: * - 300, 300px, 100% @@ -2665,34 +2887,61 @@ public with sharing class DocGenService { private static Id saveFile(Blob fileBlob, String title, Id recordId, String format, String type) { - ContentVersion cv = new ContentVersion(); String baseName = title != null ? title : 'Generated_Document'; - String ext = (type == 'PowerPoint') ? '.pptx' : '.docx'; - cv.Title = baseName; - cv.PathOnClient = baseName + ext; - cv.VersionData = fileBlob; - cv.FirstPublishLocationId = recordId; - SObjectAccessDecision cvDecision = Security.stripInaccessible(AccessType.CREATABLE, new List{ cv }); - insert cvDecision.getRecords(); - Id savedCvId = cvDecision.getRecords()[0].Id; - - return [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :savedCvId WITH SYSTEM_MODE].ContentDocumentId; + return saveContentVersion(fileBlob, baseName, baseName + ext, recordId); } /** * Saves a PDF Blob as a ContentVersion attached to the record. */ private static Id savePdfFile(Blob pdfBlob, String title, Id recordId) { - ContentVersion cv = new ContentVersion(); String baseName = title != null ? title : 'Generated_Document'; - cv.Title = baseName; - cv.PathOnClient = baseName + '.pdf'; - cv.VersionData = pdfBlob; + return saveContentVersion(pdfBlob, baseName, baseName + '.pdf', recordId); + } + + /** + * Saves a Blob as a ContentVersion linked to the given record. + * Some standard objects (e.g., Pricebook2) don't support FirstPublishLocationId. + * In that case, insert without linking and create a ContentDocumentLink separately. + */ + private static Id saveContentVersion(Blob fileBlob, String title, String fileName, Id recordId) { + ContentVersion cv = new ContentVersion(); + cv.Title = title; + cv.PathOnClient = fileName; + cv.VersionData = fileBlob; cv.FirstPublishLocationId = recordId; - SObjectAccessDecision cvDecision = Security.stripInaccessible(AccessType.CREATABLE, new List{ cv }); - insert cvDecision.getRecords(); - Id savedCvId = cvDecision.getRecords()[0].Id; + + Id savedCvId; + try { + SObjectAccessDecision cvDecision = Security.stripInaccessible(AccessType.CREATABLE, new List{ cv }); + insert cvDecision.getRecords(); + savedCvId = cvDecision.getRecords()[0].Id; + } catch (DmlException dml) { + // FirstPublishLocationId rejected (e.g., Pricebook2) — insert without link, then create CDL + if (dml.getMessage().contains('FIELD_INTEGRITY_EXCEPTION') && dml.getMessage().containsIgnoreCase('FirstPublishLocationId')) { + cv.FirstPublishLocationId = null; + SObjectAccessDecision cvDecision2 = Security.stripInaccessible(AccessType.CREATABLE, new List{ cv }); + insert cvDecision2.getRecords(); + savedCvId = cvDecision2.getRecords()[0].Id; + + Id docId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :savedCvId WITH SYSTEM_MODE].ContentDocumentId; + // Try to link the file to the source record — some objects (e.g. Pricebook2) don't support CDLs + try { + ContentDocumentLink cdl = new ContentDocumentLink( + ContentDocumentId = docId, + LinkedEntityId = recordId, + ShareType = 'V', + Visibility = 'AllUsers' + ); + insert cdl; // NOPMD ApexCRUDViolation - linking generated document to source record + } catch (DmlException linkErr) { // NOPMD EmptyCatchBlock - some objects (e.g. Pricebook2) don't support ContentDocumentLink + // Object doesn't support file linking — file saved to user's library instead + } + return docId; + } + throw dml; + } return [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :savedCvId WITH SYSTEM_MODE].ContentDocumentId; } diff --git a/sfdx-project.json b/sfdx-project.json index a6c760e..58d31d1 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "1.1.0 — Pixel-Perfect PDF Headers, Footers & Dynamic Styles", - "versionNumber": "1.1.0.NEXT", + "versionName": "1.1.1 — Image Sizing, Error Diagnostics & Multiline Text", + "versionNumber": "1.1.1.NEXT", "path": "force-app", "default": true, "package": "Portwood DocGen", @@ -28,6 +28,7 @@ "Portwood DocGen@1.0.7-1": "04tal000006PAOvAAO", "Portwood DocGen@1.0.8-1": "04tal000006PB01AAG", "Portwood DocGen@1.0.9-1": "04tal000006PBrFAAW", - "Portwood DocGen@1.1.0-1": "04tal000006PCFRAA4" + "Portwood DocGen@1.1.0-1": "04tal000006PCFRAA4", + "Portwood DocGen@1.1.1-1": "04tal000006PCTxAAO" } } \ No newline at end of file From bb192ed727b8bc468c8dac05e34cc997d503a866 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 19:57:10 -0400 Subject: [PATCH 15/19] fix: replace NOPMD suppressions with debug logging in catch blocks Empty catch blocks now log warnings instead of silently swallowing. Code Analyzer: 0 Critical, 0 High without any suppressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- force-app/main/default/classes/DocGenHtmlRenderer.cls | 4 +++- force-app/main/default/classes/DocGenService.cls | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/force-app/main/default/classes/DocGenHtmlRenderer.cls b/force-app/main/default/classes/DocGenHtmlRenderer.cls index 8f999ce..481ffd8 100644 --- a/force-app/main/default/classes/DocGenHtmlRenderer.cls +++ b/force-app/main/default/classes/DocGenHtmlRenderer.cls @@ -1165,7 +1165,9 @@ public with sharing class DocGenHtmlRenderer { if (val != null && val.isNumeric()) { mBottom = Integer.valueOf(val); } val = extractAttr(xml, 'w:pgMar', 'w:left'); if (val != null && val.isNumeric()) { mLeft = Integer.valueOf(val); } - } catch (Exception e) { /* use defaults */ } // NOPMD EmptyCatchBlock - intentional fallback to default page dimensions + } catch (Exception e) { + System.debug(LoggingLevel.WARN, 'Page dimension parsing failed, using defaults: ' + e.getMessage()); + } } dims.put('pageWidth', pgW / 1440.0); diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index ff27287..27aa75f 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -2935,8 +2935,8 @@ public with sharing class DocGenService { Visibility = 'AllUsers' ); insert cdl; // NOPMD ApexCRUDViolation - linking generated document to source record - } catch (DmlException linkErr) { // NOPMD EmptyCatchBlock - some objects (e.g. Pricebook2) don't support ContentDocumentLink - // Object doesn't support file linking — file saved to user's library instead + } catch (DmlException linkErr) { + System.debug(LoggingLevel.WARN, 'Could not link file to record ' + recordId + ' — object may not support ContentDocumentLink: ' + linkErr.getMessage()); } return docId; } From f14e6e4fad37e344758d1470bf7280303e6593c1 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 20:05:47 -0400 Subject: [PATCH 16/19] release: v1.1.2 'Beacon' - Image Sizing, Error Diagnostics & Multiline Text Integrates PR #47 from @Henk3000: - ImageRenderSpec: percentage sizing, max constraints, intrinsic PNG/JPEG dimension detection, aspect ratio preservation - Error diagnostics: malformed tags throw DocGenException with descriptive messages instead of silently producing broken output - Multiline text: newlines render as proper with correct run element handling - Smart container expansion: loops in numbered/bulleted lists now repeat list formatting - ahe() helper: consistent AuraHandledException with debug logging - Universal file save: handles objects without file linking support 0 Critical, 0 High (no suppressions). 507/507 tests. 22/22 E2E. Co-Authored-By: Henk3000 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- README.md | 6 +- .../classes/DocGenLandingController.cls | 4 +- .../main/default/pages/DocGenLanding.page | 2 +- scripts/product-catalog-data.apex | 386 ++++++++++++++++++ scripts/product-catalog-step1.apex | 237 +++++++++++ scripts/product-catalog-step2.apex | 62 +++ scripts/product-catalog-step3.apex | 72 ++++ sfdx-project.json | 7 +- 9 files changed, 768 insertions(+), 10 deletions(-) create mode 100644 scripts/product-catalog-data.apex create mode 100644 scripts/product-catalog-step1.apex create mode 100644 scripts/product-catalog-step2.apex create mode 100644 scripts/product-catalog-step3.apex diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f72a87..c31a9a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v1.1.1 — "Image Sizing, Error Diagnostics & Multiline Text" (Portwood DocGen) +## v1.1.2 — "Image Sizing, Error Diagnostics & Multiline Text" (Portwood DocGen) Huge thanks to **@Henk3000** for PR #47 — ImageRenderSpec, ahe() helper, multiline text preservation, error diagnostics for malformed tags, and smart container expansion for numbered lists. diff --git a/README.md b/README.md index ea697a7..be2617e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generate PDFs, Word docs, Excel spreadsheets, and PowerPoint presentations from any Salesforce record. Merge PDFs, add barcodes and QR codes, compute totals — 100% native, zero external dependencies, completely free. -[![Version](https://img.shields.io/badge/version-1.1.1-blue.svg)](#install) +[![Version](https://img.shields.io/badge/version-1.1.2-blue.svg)](#install) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Salesforce-00A1E0.svg)](https://www.salesforce.com) [![Namespace](https://img.shields.io/badge/namespace-portwoodglobal-purple.svg)](#install) @@ -30,10 +30,10 @@ Scanned with `sf code-analyzer run --rule-selector "recommended"` — the rule s **New install:** ```bash -sf package install --package 04tal000006PCTxAAO --wait 10 --target-org +sf package install --package 04tal000006PCVZAA4 --wait 10 --target-org ``` -[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO) +[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4) **Then:** Assign **DocGen Admin** permission set | Enable **Blob.toPdf() Release Update** | Open the **DocGen** app diff --git a/devhub-tools/main/default/classes/DocGenLandingController.cls b/devhub-tools/main/default/classes/DocGenLandingController.cls index ba1b993..feb9885 100644 --- a/devhub-tools/main/default/classes/DocGenLandingController.cls +++ b/devhub-tools/main/default/classes/DocGenLandingController.cls @@ -7,8 +7,8 @@ public without sharing class DocGenLandingController { // Store demo session in a custom setting or org-wide default // For now, use a static approach — refresh via CLI - private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO'; - private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCTxAAO'; + private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4'; + private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4'; public String installLinkProd { get { return INSTALL_LINK_PROD; } } public String installLinkSandbox { get { return INSTALL_LINK_SANDBOX; } } diff --git a/devhub-tools/main/default/pages/DocGenLanding.page b/devhub-tools/main/default/pages/DocGenLanding.page index b6fde58..3444db9 100644 --- a/devhub-tools/main/default/pages/DocGenLanding.page +++ b/devhub-tools/main/default/pages/DocGenLanding.page @@ -276,7 +276,7 @@ Install in Production Install in Sandbox -
sf package install --package 04tal000006PCTxAAO --wait 10 --target-org your-org
+
sf package install --package 04tal000006PCVZAA4 --wait 10 --target-org your-org

Then: Assign DocGen Admin permission set • Open the DocGen app

diff --git a/scripts/product-catalog-data.apex b/scripts/product-catalog-data.apex new file mode 100644 index 0000000..a71569e --- /dev/null +++ b/scripts/product-catalog-data.apex @@ -0,0 +1,386 @@ +/** + * Product Catalog Data Generator + * Creates 500 realistic products with images, a custom pricebook, and pricebook entries. + * Each product gets a unique product image (ContentVersion) stored in Product_Image__c. + * + * Run: sf apex run --target-org -f scripts/product-catalog-data.apex + */ + +// ─── Product Names by Category ─────────────────────────────────────────────── +// 10 categories × 50 products each = 500 + +List electronics = new List{ + 'ProVision 4K Monitor 27"','ThunderBolt Wireless Mouse','ArcLight Mechanical Keyboard','NovaPad Pro Tablet 12"','SonicBoom Bluetooth Speaker', + 'ClearView Webcam HD','PowerSurge USB-C Hub 7-Port','EchoLink Wireless Earbuds','FlexCharge 65W Adapter','DataVault 2TB External SSD', + 'PixelPerfect Drawing Tablet','SwiftScan Document Scanner','CloudSync Wi-Fi 6 Router','QuietZone ANC Headphones','MagLock Laptop Stand', + 'TrueColor LED Desk Lamp','StreamPro Capture Card','BrightEdge Ring Light 18"','TechGuard Surge Protector 8','SpeedLink Cat8 Ethernet Cable 50ft', + 'UltraWide Monitor 34" Curved','MiniPod Portable Charger 20K','VoiceClear Conference Mic','HyperDock Thunderbolt 4','SmartTag Bluetooth Tracker 4-Pack', + 'CrystalClear HDMI Cable 6ft','PowerBar Desktop Charger','AirStream Laptop Cooler','PixelShift Webcam 4K','DataBridge USB Adapter Kit', + 'EchoSphere Smart Speaker','TurboCharge Car Adapter','ProLink DisplayPort Cable 10ft','SilentType Wireless Keyboard','NightOwl Gaming Mouse', + 'WaveForm Digital Audio Mixer','QuickShot Wireless Presenter','TechVault Laptop Sleeve 15"','PowerGrid Smart Power Strip','ArcBeam Portable Projector', + 'SoundStage Soundbar 36"','FlexMount Monitor Arm Dual','NetGuard VPN Router','ProCapture Streaming Kit','ChargePad Wireless Qi 15W', + 'CablePro Organizer Box','SmartPlug Wi-Fi Outlet 2-Pack','TrueTrack Fitness Band','UltraSlim Portable Monitor 15"','DataSync Card Reader USB-C' +}; + +List officeSupplies = new List{ + 'Premium Copy Paper 5000-Sheet','Executive Ballpoint Pen Set','Heavy-Duty Stapler Industrial','Ergonomic Gel Mouse Pad','Filing Cabinet 4-Drawer Steel', + 'Whiteboard Magnetic 48x36','Desk Organizer Bamboo 6-Slot','Label Maker Pro Wireless','Binder Clips Assorted 120-Pack','Standing Desk Converter 36"', + 'Paper Shredder Cross-Cut 12-Sheet','Sticky Notes Neon 24-Pack','Ink Cartridge Multi-Color 4-Pack','Desk Calendar 2026 Large','Manila Folders Letter 100-Pack', + 'Highlighters Assorted 12-Pack','Tape Dispenser Weighted Black','Scissors Precision 8" Titanium','Push Pins Multi-Color 500-Pack','Envelope #10 Security 500-Pack', + 'Dry Erase Markers 12-Pack','Correction Tape 10-Pack','Paper Clips Jumbo 250-Pack','Index Cards Ruled 3x5 500-Pack','Pencil Sharpener Electric Desktop', + 'Rubber Bands Assorted 1lb','Presentation Binder 2" White 6-Pack','Tab Dividers 8-Tab 24-Set','Post-It Flag Markers 8-Color','Glue Sticks 30-Pack', + 'Letter Tray Stackable 3-Tier','Clipboard Hardboard Letter 12-Pack','Mechanical Pencils 0.7mm 24-Pack','Printer Paper Recycled 2500-Sheet','Desk Pad Large 36x20 Leather', + 'Packing Tape Clear 12-Roll','Hanging File Folders 50-Pack','Business Card Holder 240-Card','Laminating Pouches Letter 200-Pack','Calculator Desktop 12-Digit', + 'Stamp Pad Ink Red','Bookends Metal Heavy-Duty Pair','Page Protectors Clear 200-Pack','Marker Permanent Black 36-Pack','Notebook Spiral College 10-Pack', + 'Cork Board 24x18 with Frame','Pen Cup Mesh Desktop Black','Ruler Aluminum 18"','Mouse Pad Extended 35x16','Wrist Rest Keyboard Gel' +}; + +List furniture = new List{ + 'Executive Mesh Office Chair','Standing Desk Electric 60x30','Conference Table Oval 8ft','Bookshelf 5-Tier Walnut','Filing Pedestal Mobile 3-Drawer', + 'Reception Desk L-Shaped','Guest Chair Stackable Fabric','Credenza Modern 60" Oak','Corner Desk with Hutch','Task Chair Ergonomic Lumbar', + 'Round Meeting Table 42"','Lateral File Cabinet 2-Drawer','Coat Rack Freestanding Wood','Side Table Modern 24x24','Storage Cabinet Locking 72"', + 'Drafting Stool Adjustable','Training Table Folding 72"','Privacy Screen Desktop 48"','Monitor Riser Walnut','Under-Desk Keyboard Tray', + 'Executive Desk 72x36 Mahogany','Whiteboard Stand Mobile 48x36','Lounge Chair Modern Leather','Coffee Table Round 36" Glass','Desk Return 48x24', + 'Adjustable Footrest Ergonomic','Wall-Mount Shelf 48"','Cubicle Panel 66" Fabric','Desk Lamp Architect Arm','Plant Stand 3-Tier Metal', + 'Breakroom Table Round 36"','Stacking Chair Polypropylene','TV Stand Mobile 65" Cart','Lectern Adjustable Height','Bench Entryway Storage 48"', + 'Sit-Stand Stool Active','End Table Drawer 18x18','Coat Hook Wall Mount 5-Hook','Desk Grommet Cable 2-Pack','Acoustic Panel 24x48 Charcoal', + 'Sofa Office 2-Seat Gray','Desk Partition Frosted 24x18','Pedestal Fan 16" Oscillating','Floor Mat Chair 46x60','Magazine Rack Wall Mount', + 'Wardrobe Locker Single','Step Stool 2-Step Folding','Corner Shelf Unit 5-Tier','Whiteboard Cabinet 48x36','Desk Bridge Connector 48"' +}; + +List kitchenware = new List{ + 'Chef Knife Set 15-Piece Block','Non-Stick Frying Pan 12"','Stainless Steel Mixing Bowls 5-Set','Digital Kitchen Scale 22lb','Silicone Spatula Set 6-Piece', + 'Cast Iron Dutch Oven 6qt','Bamboo Cutting Board XL','Electric Kettle 1.7L Glass','Colander Stainless 5qt','Measuring Cup Set 7-Piece', + 'Food Storage Container Set 24-Pc','Blender Professional 64oz','Toaster 4-Slice Wide Slot','Coffee Maker 12-Cup Programmable','Stand Mixer 5qt 10-Speed', + 'Baking Sheet Set 3-Piece','Rolling Pin Marble 18"','Whisk Set Stainless 3-Piece','Ice Cream Scoop Spring-Loaded','Pizza Stone 16" Round', + 'Cheese Grater 4-Sided Box','Potato Masher Stainless','Tongs Set 9" and 12"','Ladle Stainless 6oz','Can Opener Manual Heavy-Duty', + 'Vegetable Peeler Swivel 3-Pack','Meat Thermometer Digital Instant','Egg Timer Digital Magnetic','Tea Infuser Ball Stainless','Citrus Juicer Manual', + 'Bread Knife Serrated 10"','Wok Carbon Steel 14"','Steamer Basket Stainless 9"','Cake Pan Round 9" 2-Pack','Muffin Tin 12-Cup Non-Stick', + 'Pie Dish Ceramic 9"','Cookie Sheet Air Insulated','Cooling Rack Stackable 2-Pack','Pastry Brush Silicone 2-Pack','Kitchen Timer Dual Digital', + 'Garlic Press Heavy-Duty','Corkscrew Double-Hinged','Pepper Mill Wooden 8"','Salt Cellar Bamboo Lid','Spice Rack Rotating 20-Jar', + 'Paper Towel Holder Counter','Dish Rack 2-Tier Stainless','Sponge Holder Sink Caddy','Compost Bin Counter 1.3gal','Utensil Crock Ceramic White' +}; + +List clothing = new List{ + 'Oxford Button-Down Shirt Blue','Slim Fit Chinos Khaki','Merino Wool V-Neck Sweater','Classic Polo Shirt Navy','Stretch Dress Pants Charcoal', + 'Denim Jacket Trucker Medium','Performance T-Shirt Moisture-Wick','Fleece Quarter-Zip Pullover','Linen Blazer Summer Tan','Cargo Shorts Relaxed Olive', + 'Flannel Shirt Plaid Red','Athletic Joggers Tapered Black','Cashmere Scarf Heather Gray','Leather Belt Reversible 34"','Wool Peacoat Double-Breasted', + 'Rain Jacket Packable Blue','Thermal Henley Long-Sleeve','Corduroy Pants Straight Brown','Chambray Shirt Casual Fit','Down Vest Lightweight Navy', + 'Board Shorts Quick-Dry 9"','Dress Shirt French Cuff White','Windbreaker Half-Zip','Cable Knit Sweater Cream','Tech Pants 5-Pocket Gray', + 'Bomber Jacket Nylon Green','Compression Shirt Base Layer','Tweed Sport Coat Classic','Swimsuit Trunk Solid Navy','Hoodie Zip-Up Heather Gray', + 'Turtleneck Fine-Gauge Black','Hiking Pants Convertible','Graphic Tee Vintage Logo','Suit Jacket Slim Fit Navy','Parka Insulated Winter Black', + 'Rugby Shirt Striped','Track Pants Side-Stripe','Overcoat Wool-Blend Camel','Denim Shirt Western Snap','Softshell Jacket Waterproof', + 'Cardigan Button-Front Charcoal','Linen Pants Drawstring White','Performance Polo Quick-Dry','Varsity Jacket Letterman','Thermal Pants Base Layer', + 'Safari Jacket Multi-Pocket','Hawaiian Shirt Floral','Vest Quilted Field Green','Driving Gloves Leather Brown','Beanie Ribbed Knit Charcoal' +}; + +List sports = new List{ + 'Running Shoes Lightweight 10.5','Yoga Mat Premium 6mm Purple','Resistance Band Set 5-Level','Adjustable Dumbbell 52.5lb Pair','Jump Rope Speed Bearing', + 'Foam Roller High-Density 36"','Kettlebell Cast Iron 35lb','Pull-Up Bar Doorway','Exercise Ball 65cm Anti-Burst','Boxing Gloves 14oz Leather', + 'Tennis Racket Carbon Fiber','Basketball Indoor/Outdoor Size 7','Soccer Ball FIFA Official Size 5','Golf Club Set 12-Piece','Baseball Glove 12.5" Leather', + 'Swimming Goggles Anti-Fog','Cycling Helmet Road Large','Ski Goggles UV Protection','Badminton Racket 2-Pack','Table Tennis Paddle Pro Set', + 'Lacrosse Stick Complete','Hockey Stick Composite 60"','Volleyball Indoor Official','Cricket Bat English Willow','Archery Target Bag 26"', + 'Weight Bench Adjustable FID','Barbell Olympic 7ft 45lb','Weight Plates Rubber 45lb Pair','Squat Rack Folding Wall-Mount','Ab Roller Wheel Double', + 'Battle Rope 30ft 1.5"','Medicine Ball Rubber 15lb','Agility Ladder 20ft','Cone Set Training 50-Pack','Speed Parachute Running', + 'Grip Strengthener Set 3-Pack','Ankle Weights 5lb Pair','Stability Ball Chair Base','Stretch Strap Yoga 10-Loop','Massage Gun Percussion Deep', + 'Heart Rate Monitor Chest Strap','GPS Sports Watch Multi-Sport','Bike Computer Wireless','Swim Cap Silicone 2-Pack','Water Bottle Insulated 32oz', + 'Gym Bag Duffel 40L','Sweatband Headband 6-Pack','Athletic Tape 1.5" 6-Roll','Compression Sleeve Calf Pair','Sports Sunglasses Polarized' +}; + +List gardening = new List{ + 'Garden Hose Expandable 100ft','Pruning Shears Bypass Titanium','Raised Garden Bed 4x8 Cedar','Wheelbarrow Steel 6 Cubic Ft','Sprinkler Oscillating Brass', + 'Potting Soil Premium 40qt','Garden Gloves Thorn-Proof','Seed Starting Kit 72-Cell','Lawn Mower Push 22"','Hedge Trimmer Electric 24"', + 'Plant Labels Bamboo 100-Pack','Watering Can Galvanized 2gal','Garden Kneeler Pad Foam','Trowel Stainless Steel','Cultivator 3-Prong Hand', + 'Fertilizer All-Purpose 10lb','Compost Bin Tumbling 43gal','Garden Fork Steel 4-Tine','Leaf Rake Adjustable 30"','Edging Border 20ft', + 'Planter Pot Ceramic 12"','Hanging Basket Coconut Liner','Plant Stand Indoor 3-Tier','Garden Hose Reel Wall-Mount','Soaker Hose 50ft', + 'Weed Barrier Fabric 4x100ft','Mulch Cedar Red 2 Cubic Ft','Topsoil Screened 40lb','Bird Feeder Cedar House','Birdbath Pedestal Concrete', + 'Rain Gauge Clear 5"','Thermometer Outdoor Wireless','Solar Path Lights 10-Pack','Garden Statue Gnome 12"','Wind Chime Bamboo 36"', + 'Greenhouse Mini 4-Tier','Cold Frame Double-Wall','Drip Irrigation Kit 100ft','Timer Hose Digital 2-Zone','Pest Control Neem Oil 32oz', + 'Insect Net Garden 10x12','Tomato Cage Heavy-Duty 5-Pack','Plant Support Stakes 4ft 20-Pack','Seed Storage Box Organizer','Soil Test Kit Digital pH', + 'Pressure Washer 2100 PSI','Garden Cart Dump 10 Cubic Ft','Chainsaw Electric 16"','Leaf Blower Cordless 20V','String Trimmer 15" Dual-Line' +}; + +List automotive = new List{ + 'Floor Mats All-Weather 4-Piece','Car Cover Waterproof XL','Dash Cam Dual 4K Front+Rear','Jump Starter Portable 2000A','Tire Inflator Digital 12V', + 'Car Vacuum Cordless Handheld','Phone Mount Magnetic Vent','Seat Cover Set Leather 2-Pack','Steering Wheel Cover Leather','Sun Shade Windshield Foldable', + 'Trunk Organizer Collapsible','Emergency Kit Roadside 120-Pc','OBD2 Scanner Bluetooth Pro','Battery Charger Smart 12V','Wiper Blades Premium 24" Pair', + 'Car Wash Kit Complete 10-Piece','Polishing Machine Dual-Action','Clay Bar Kit Detailing','Tire Pressure Gauge Digital','Lug Wrench Telescoping Cross', + 'Jack Stand 3-Ton Pair','Creeper Mechanic Padded 40"','Tool Set Mechanic 200-Piece','Oil Drain Pan 16qt','Funnel Set Flexible 4-Piece', + 'Air Freshener Vent Clip 6-Pack','LED Headlight Bulb H11 Pair','Fog Light Kit Yellow 3000K','License Plate Frame 2-Pack','Cargo Net Trunk Elastic', + 'Roof Rack Cross Bars Universal','Bike Rack Hitch 4-Bike','Trailer Hitch Ball 2"','Tow Strap 30ft 30000lb','Recovery Board Traction 2-Pack', + 'Blind Spot Mirror Pair','Backup Camera Wireless HD','Parking Sensor Kit 8-Sensor','Paint Touch-Up Kit Universal','Scratch Remover Compound', + 'Interior Cleaner All-Surface','Leather Conditioner 16oz','Glass Cleaner Streak-Free 32oz','Tire Shine Gel Long-Lasting','Microfiber Towels 24-Pack', + 'Ice Scraper Snow Brush 36"','Antifreeze Coolant 1gal','Transmission Fluid ATF 1qt','Brake Fluid DOT 4 12oz','Engine Oil Synthetic 5W-30 5qt' +}; + +List healthBeauty = new List{ + 'Vitamin D3 5000 IU 360-Cap','Protein Powder Whey 5lb Vanilla','Multivitamin Complete Daily','Omega-3 Fish Oil 1000mg 120-Soft','Probiotic 50 Billion CFU 60-Cap', + 'Collagen Peptides Powder 20oz','Magnesium Glycinate 400mg','Zinc Picolinate 50mg 120-Cap','Vitamin C 1000mg 250-Tab','B-Complex Super 100-Cap', + 'Sunscreen SPF 50 Face 3oz','Moisturizer Daily SPF 30','Face Wash Gentle Cleanser 8oz','Retinol Serum 2.5% 1oz','Hyaluronic Acid Serum 2oz', + 'Eye Cream Anti-Aging 0.5oz','Lip Balm SPF 15 3-Pack','Body Lotion Unscented 16oz','Hand Cream Intensive 3oz','Foot Cream Repair 4oz', + 'Shampoo Volumizing 16oz','Conditioner Hydrating 16oz','Hair Oil Argan 4oz','Hair Mask Deep Repair 8oz','Dry Shampoo Spray 7oz', + 'Toothbrush Electric Sonic','Toothpaste Whitening 2-Pack','Dental Floss Mint 6-Pack','Mouthwash Antiseptic 1.5L','Tongue Scraper Stainless', + 'Deodorant Natural 3-Pack','Razor Safety Double-Edge','Shaving Cream Sensitive 6oz','Aftershave Balm 3.4oz','Beard Oil Sandalwood 2oz', + 'Nail Clipper Set 3-Piece','Tweezers Precision Slant','Cotton Rounds 100-Pack','Facial Tissue Soft 12-Box','Hand Sanitizer 8oz 3-Pack', + 'Heating Pad Electric 12x24','Ice Pack Gel Reusable 2-Pack','Thermometer Forehead Digital','Blood Pressure Monitor Arm','Pulse Oximeter Fingertip', + 'First Aid Kit 300-Piece','Bandages Adhesive 100-Pack','Melatonin 5mg Gummies 120','Electrolyte Powder 30-Pack','Essential Oil Set 6-Bottle' +}; + +List petSupplies = new List{ + 'Dog Food Premium Chicken 30lb','Cat Food Indoor Formula 16lb','Dog Bed Orthopedic Large','Cat Tree Tower 72" Multi-Level','Pet Carrier Airline Approved', + 'Dog Leash Retractable 16ft','Cat Litter Clumping 40lb','Dog Crate Wire Folding 42"','Pet Water Fountain 128oz','Dog Toy Chew Rope 5-Pack', + 'Cat Toy Interactive Feather','Dog Brush Deshedding Tool','Cat Scratcher Lounge Cardboard','Dog Bowl Slow Feeder','Cat Bowl Elevated 2-Pack', + 'Pet Shampoo Oatmeal 16oz','Dog Treats Training 1lb','Cat Treats Freeze-Dried 5oz','Flea Collar Dog Large 8-Month','Tick Remover Tool Kit', + 'Dog Harness No-Pull Padded','Cat Harness and Leash Set','Pet First Aid Kit 75-Piece','Dog Waste Bags 900-Count','Cat Litter Mat 30x24', + 'Pet Camera Indoor Wi-Fi','Dog Door Flap Large','Cat Flap Microchip Smart','Pet Gate Freestanding 76"','Dog Ramp Folding Car', + 'Pet Blanket Waterproof','Dog Coat Winter Reflective','Cat Bed Heated Electric','Fish Tank Starter Kit 20gal','Aquarium Filter Canister 75gal', + 'Bird Cage Large 30x18x36','Hamster Habitat Multi-Level','Reptile Terrarium 40gal','Pet Nail Grinder Quiet','Dog Toothbrush Kit 3-Piece', + 'Cat Fountain Filter 6-Pack','Dog ID Tag Stainless Custom','Pet Hair Remover Roller','Dog Training Clicker 2-Pack','Cat Catnip Organic 2oz', + 'Aquarium Heater 200W','Fish Food Tropical Flake 4oz','Bird Seed Mix Premium 10lb','Small Animal Bedding 56L','Pet Odor Eliminator Spray 32oz' +}; + +// ─── Build master list with categories ────────────────────────────────────── +List> allCategories = new List>{ + electronics, officeSupplies, furniture, kitchenware, clothing, + sports, gardening, automotive, healthBeauty, petSupplies +}; +List categoryNames = new List{ + 'Electronics', 'Office Supplies', 'Furniture', 'Kitchen & Dining', 'Clothing & Apparel', + 'Sports & Fitness', 'Garden & Outdoor', 'Automotive', 'Health & Beauty', 'Pet Supplies' +}; + +// ─── Generate color-coded placeholder product images ──────────────────────── +// Each category gets a distinct background color to visually distinguish products. +// Images are 200x200 SVG rendered as PNG-like placeholders stored as ContentVersion. + +// Actually, we'll create simple but visually distinct images using SVG stored as content. +// Since we can't generate real PNGs in Apex, we'll create HTML-rendered images via a different approach: +// Store the product name as text in a small colored rectangle using SVG (converted to blob). + +// Category colors (vibrant, distinct) +List categoryColors = new List{ + '3B82F6', 'F59E0B', '10B981', 'EF4444', '8B5CF6', + 'EC4899', '14B8A6', 'F97316', '06B6D4', '84CC16' +}; + +// Category icons (emoji-like symbols for the SVG) +List categoryIcons = new List{ + '⚡', // ⚡ Electronics + '✎', // ✎ Office + '☖', // ☆ Furniture + '♨', // ♨ Kitchen + '★', // ★ Clothing + '⚽', // ⚽ Sports + '☘', // ☘ Garden + '⚙', // ⚙ Automotive + '♥', // ♥ Health + '🐾' // 🐾 Pets +}; + +System.debug('Creating 500 products with images, pricebook, and entries...'); + +// ─── Step 1: Create all products ──────────────────────────────────────────── +List products = new List(); +List productCategoryIndex = new List(); +for (Integer catIdx = 0; catIdx < allCategories.size(); catIdx++) { + List catProducts = allCategories[catIdx]; + for (Integer pIdx = 0; pIdx < catProducts.size(); pIdx++) { + Product2 p = new Product2(); + p.Name = catProducts[pIdx]; + p.ProductCode = categoryNames[catIdx].substring(0, 3).toUpperCase() + '-' + String.valueOf(catIdx * 50 + pIdx + 1).leftPad(4, '0'); + p.Description = categoryNames[catIdx] + ' — ' + catProducts[pIdx]; + p.Family = categoryNames[catIdx]; + p.IsActive = true; + products.add(p); + productCategoryIndex.add(catIdx); + } +} +insert products; +System.debug('Inserted ' + products.size() + ' products'); + +// ─── Step 2: Create product images as ContentVersion (SVG) ────────────────── +List imageCvs = new List(); +for (Integer i = 0; i < products.size(); i++) { + Integer catIdx = productCategoryIndex[i]; + String color = categoryColors[catIdx]; + String icon = categoryIcons[catIdx]; + String prodName = products[i].Name; + // Truncate long names for the image + if (prodName.length() > 30) { + prodName = prodName.substring(0, 28) + '..'; + } + String category = categoryNames[catIdx]; + + // Create a simple but attractive SVG product image + String svg = '' + + '' + + '' + + '' + + '' + + '' + + '' + icon + '' + + '' + prodName.escapeXml() + '' + + '' + category.escapeXml() + '' + + '' + + '' + products[i].ProductCode + '' + + ''; + + ContentVersion cv = new ContentVersion(); + cv.Title = 'Product Image - ' + products[i].Name; + cv.PathOnClient = products[i].ProductCode + '.svg'; + cv.VersionData = Blob.valueOf(svg); + cv.FirstPublishLocationId = products[i].Id; + imageCvs.add(cv); +} + +// Insert in batches of 200 to avoid DML limits +List batch1 = new List(); +List batch2 = new List(); +List batch3 = new List(); +for (Integer i = 0; i < imageCvs.size(); i++) { + if (i < 200) { batch1.add(imageCvs[i]); } + else if (i < 400) { batch2.add(imageCvs[i]); } + else { batch3.add(imageCvs[i]); } +} +insert batch1; +insert batch2; +insert batch3; +System.debug('Inserted ' + imageCvs.size() + ' product images'); + +// ─── Step 3: Query back ContentVersion IDs and update Product_Image__c ────── +// We need the committed CV IDs (after insert) +Set allCvIds = new Set(); +for (ContentVersion cv : imageCvs) { allCvIds.add(cv.Id); } + +Map cvMap = new Map([ + SELECT Id, FirstPublishLocationId + FROM ContentVersion + WHERE Id IN :allCvIds +]); + +List productsToUpdate = new List(); +for (ContentVersion cv : cvMap.values()) { + if (cv.FirstPublishLocationId != null) { + Product2 p = new Product2(Id = cv.FirstPublishLocationId); + p.put('portwoodglobal__Product_Image__c', cv.Id); + productsToUpdate.add(p); + } +} + +// Update in batches +List upBatch1 = new List(); +List upBatch2 = new List(); +List upBatch3 = new List(); +for (Integer i = 0; i < productsToUpdate.size(); i++) { + if (i < 200) { upBatch1.add(productsToUpdate[i]); } + else if (i < 400) { upBatch2.add(productsToUpdate[i]); } + else { upBatch3.add(productsToUpdate[i]); } +} +if (!upBatch1.isEmpty()) { update upBatch1; } +if (!upBatch2.isEmpty()) { update upBatch2; } +if (!upBatch3.isEmpty()) { update upBatch3; } +System.debug('Updated ' + productsToUpdate.size() + ' products with image CV IDs'); + +// ─── Step 4: Create Pricebook and Entries ─────────────────────────────────── +// Get standard pricebook +Id stdPbId = Test.isRunningTest() ? Test.getStandardPricebookId() : [SELECT Id FROM Pricebook2 WHERE IsStandard = true LIMIT 1].Id; + +// Create standard pricebook entries first (required before custom PB entries) +List stdEntries = new List(); +for (Integer i = 0; i < products.size(); i++) { + Integer catIdx = productCategoryIndex[i]; + // Realistic price ranges by category + Decimal basePrice; + if (catIdx == 0) { basePrice = 29.99 + (Math.random() * 470); } // Electronics: $30-500 + else if (catIdx == 1) { basePrice = 4.99 + (Math.random() * 95); } // Office: $5-100 + else if (catIdx == 2) { basePrice = 89.99 + (Math.random() * 910); } // Furniture: $90-1000 + else if (catIdx == 3) { basePrice = 9.99 + (Math.random() * 190); } // Kitchen: $10-200 + else if (catIdx == 4) { basePrice = 19.99 + (Math.random() * 280); } // Clothing: $20-300 + else if (catIdx == 5) { basePrice = 14.99 + (Math.random() * 285); } // Sports: $15-300 + else if (catIdx == 6) { basePrice = 9.99 + (Math.random() * 290); } // Garden: $10-300 + else if (catIdx == 7) { basePrice = 7.99 + (Math.random() * 192); } // Auto: $8-200 + else if (catIdx == 8) { basePrice = 6.99 + (Math.random() * 93); } // Health: $7-100 + else { basePrice = 8.99 + (Math.random() * 141); } // Pets: $9-150 + + // Round to .99 + basePrice = Math.floor(basePrice) + 0.99; + + PricebookEntry pbe = new PricebookEntry(); + pbe.Pricebook2Id = stdPbId; + pbe.Product2Id = products[i].Id; + pbe.UnitPrice = basePrice; + pbe.IsActive = true; + stdEntries.add(pbe); +} + +// Insert standard entries in batches +List sBatch1 = new List(); +List sBatch2 = new List(); +List sBatch3 = new List(); +for (Integer i = 0; i < stdEntries.size(); i++) { + if (i < 200) { sBatch1.add(stdEntries[i]); } + else if (i < 400) { sBatch2.add(stdEntries[i]); } + else { sBatch3.add(stdEntries[i]); } +} +insert sBatch1; +insert sBatch2; +insert sBatch3; +System.debug('Inserted ' + stdEntries.size() + ' standard pricebook entries'); + +// Create custom pricebook +Pricebook2 catalog = new Pricebook2(); +catalog.Name = 'Product Catalog 2026'; +catalog.Description = '2026 Full Product Catalog — 500 products across 10 categories'; +catalog.IsActive = true; +insert catalog; +System.debug('Created pricebook: ' + catalog.Name); + +// Create custom pricebook entries (with slight markup) +List catalogEntries = new List(); +for (Integer i = 0; i < products.size(); i++) { + PricebookEntry pbe = new PricebookEntry(); + pbe.Pricebook2Id = catalog.Id; + pbe.Product2Id = products[i].Id; + pbe.UnitPrice = stdEntries[i].UnitPrice; // Same price as standard + pbe.IsActive = true; + catalogEntries.add(pbe); +} + +List cBatch1 = new List(); +List cBatch2 = new List(); +List cBatch3 = new List(); +for (Integer i = 0; i < catalogEntries.size(); i++) { + if (i < 200) { cBatch1.add(catalogEntries[i]); } + else if (i < 400) { cBatch2.add(catalogEntries[i]); } + else { cBatch3.add(catalogEntries[i]); } +} +insert cBatch1; +insert cBatch2; +insert cBatch3; +System.debug('Inserted ' + catalogEntries.size() + ' catalog pricebook entries'); + +// ─── Summary ──────────────────────────────────────────────────────────────── +System.debug(''); +System.debug('═══════════════════════════════════════════════════'); +System.debug(' PRODUCT CATALOG DATA GENERATION COMPLETE'); +System.debug('═══════════════════════════════════════════════════'); +System.debug(' Products created: ' + products.size()); +System.debug(' Product images: ' + imageCvs.size()); +System.debug(' Pricebook: ' + catalog.Name); +System.debug(' Standard PB entries: ' + stdEntries.size()); +System.debug(' Catalog PB entries: ' + catalogEntries.size()); +System.debug(''); +System.debug(' Categories:'); +for (Integer c = 0; c < categoryNames.size(); c++) { + System.debug(' • ' + categoryNames[c] + ' (' + allCategories[c].size() + ' products)'); +} +System.debug(''); +System.debug(' Template fields to use:'); +System.debug(' {Product2.Name}'); +System.debug(' {Product2.Id}'); +System.debug(' {%Product2.portwoodglobal__Product_Image__c}'); +System.debug(' {Product2.ProductCode}'); +System.debug(' {Product2.Family}'); +System.debug(' {UnitPrice:currency}'); +System.debug(' {*Product2.ProductCode} (barcode)'); +System.debug('═══════════════════════════════════════════════════'); diff --git a/scripts/product-catalog-step1.apex b/scripts/product-catalog-step1.apex new file mode 100644 index 0000000..8854bf6 --- /dev/null +++ b/scripts/product-catalog-step1.apex @@ -0,0 +1,237 @@ +/** + * Step 1: Create 500 products + pricebook + pricebook entries. + * Run: sf apex run --target-org -f scripts/product-catalog-step1.apex + * Then run step2 and step3 for images. + */ + +List names = new List{ +'ProVision 4K Monitor 27"','ThunderBolt Wireless Mouse','ArcLight Mechanical Keyboard','NovaPad Pro Tablet 12"','SonicBoom Bluetooth Speaker', +'ClearView Webcam HD','PowerSurge USB-C Hub 7-Port','EchoLink Wireless Earbuds','FlexCharge 65W Adapter','DataVault 2TB External SSD', +'PixelPerfect Drawing Tablet','SwiftScan Document Scanner','CloudSync Wi-Fi 6 Router','QuietZone ANC Headphones','MagLock Laptop Stand', +'TrueColor LED Desk Lamp','StreamPro Capture Card','BrightEdge Ring Light 18"','TechGuard Surge Protector 8','SpeedLink Cat8 Ethernet Cable 50ft', +'UltraWide Monitor 34" Curved','MiniPod Portable Charger 20K','VoiceClear Conference Mic','HyperDock Thunderbolt 4','SmartTag Bluetooth Tracker 4-Pack', +'CrystalClear HDMI Cable 6ft','PowerBar Desktop Charger','AirStream Laptop Cooler','PixelShift Webcam 4K','DataBridge USB Adapter Kit', +'EchoSphere Smart Speaker','TurboCharge Car Adapter','ProLink DisplayPort Cable 10ft','SilentType Wireless Keyboard','NightOwl Gaming Mouse', +'WaveForm Digital Audio Mixer','QuickShot Wireless Presenter','TechVault Laptop Sleeve 15"','PowerGrid Smart Power Strip','ArcBeam Portable Projector', +'SoundStage Soundbar 36"','FlexMount Monitor Arm Dual','NetGuard VPN Router','ProCapture Streaming Kit','ChargePad Wireless Qi 15W', +'CablePro Organizer Box','SmartPlug Wi-Fi Outlet 2-Pack','TrueTrack Fitness Band','UltraSlim Portable Monitor 15"','DataSync Card Reader USB-C', +'Premium Copy Paper 5000-Sheet','Executive Ballpoint Pen Set','Heavy-Duty Stapler Industrial','Ergonomic Gel Mouse Pad','Filing Cabinet 4-Drawer Steel', +'Whiteboard Magnetic 48x36','Desk Organizer Bamboo 6-Slot','Label Maker Pro Wireless','Binder Clips Assorted 120-Pack','Standing Desk Converter 36"', +'Paper Shredder Cross-Cut 12-Sheet','Sticky Notes Neon 24-Pack','Ink Cartridge Multi-Color 4-Pack','Desk Calendar 2026 Large','Manila Folders Letter 100-Pack', +'Highlighters Assorted 12-Pack','Tape Dispenser Weighted Black','Scissors Precision 8" Titanium','Push Pins Multi-Color 500-Pack','Envelope #10 Security 500-Pack', +'Dry Erase Markers 12-Pack','Correction Tape 10-Pack','Paper Clips Jumbo 250-Pack','Index Cards Ruled 3x5 500-Pack','Pencil Sharpener Electric Desktop', +'Rubber Bands Assorted 1lb','Presentation Binder 2" White 6-Pack','Tab Dividers 8-Tab 24-Set','Post-It Flag Markers 8-Color','Glue Sticks 30-Pack', +'Letter Tray Stackable 3-Tier','Clipboard Hardboard Letter 12-Pack','Mechanical Pencils 0.7mm 24-Pack','Printer Paper Recycled 2500-Sheet','Desk Pad Large 36x20 Leather', +'Packing Tape Clear 12-Roll','Hanging File Folders 50-Pack','Business Card Holder 240-Card','Laminating Pouches Letter 200-Pack','Calculator Desktop 12-Digit', +'Stamp Pad Ink Red','Bookends Metal Heavy-Duty Pair','Page Protectors Clear 200-Pack','Marker Permanent Black 36-Pack','Notebook Spiral College 10-Pack', +'Cork Board 24x18 with Frame','Pen Cup Mesh Desktop Black','Ruler Aluminum 18"','Mouse Pad Extended 35x16','Wrist Rest Keyboard Gel', +'Executive Mesh Office Chair','Standing Desk Electric 60x30','Conference Table Oval 8ft','Bookshelf 5-Tier Walnut','Filing Pedestal Mobile 3-Drawer', +'Reception Desk L-Shaped','Guest Chair Stackable Fabric','Credenza Modern 60" Oak','Corner Desk with Hutch','Task Chair Ergonomic Lumbar', +'Round Meeting Table 42"','Lateral File Cabinet 2-Drawer','Coat Rack Freestanding Wood','Side Table Modern 24x24','Storage Cabinet Locking 72"', +'Drafting Stool Adjustable','Training Table Folding 72"','Privacy Screen Desktop 48"','Monitor Riser Walnut','Under-Desk Keyboard Tray', +'Executive Desk 72x36 Mahogany','Whiteboard Stand Mobile 48x36','Lounge Chair Modern Leather','Coffee Table Round 36" Glass','Desk Return 48x24', +'Adjustable Footrest Ergonomic','Wall-Mount Shelf 48"','Cubicle Panel 66" Fabric','Desk Lamp Architect Arm','Plant Stand 3-Tier Metal', +'Breakroom Table Round 36"','Stacking Chair Polypropylene','TV Stand Mobile 65" Cart','Lectern Adjustable Height','Bench Entryway Storage 48"', +'Sit-Stand Stool Active','End Table Drawer 18x18','Coat Hook Wall Mount 5-Hook','Desk Grommet Cable 2-Pack','Acoustic Panel 24x48 Charcoal', +'Sofa Office 2-Seat Gray','Desk Partition Frosted 24x18','Pedestal Fan 16" Oscillating','Floor Mat Chair 46x60','Magazine Rack Wall Mount', +'Wardrobe Locker Single','Step Stool 2-Step Folding','Corner Shelf Unit 5-Tier','Whiteboard Cabinet 48x36','Desk Bridge Connector 48"', +'Chef Knife Set 15-Piece Block','Non-Stick Frying Pan 12"','Stainless Steel Mixing Bowls 5-Set','Digital Kitchen Scale 22lb','Silicone Spatula Set 6-Piece', +'Cast Iron Dutch Oven 6qt','Bamboo Cutting Board XL','Electric Kettle 1.7L Glass','Colander Stainless 5qt','Measuring Cup Set 7-Piece', +'Food Storage Container Set 24-Pc','Blender Professional 64oz','Toaster 4-Slice Wide Slot','Coffee Maker 12-Cup Programmable','Stand Mixer 5qt 10-Speed', +'Baking Sheet Set 3-Piece','Rolling Pin Marble 18"','Whisk Set Stainless 3-Piece','Ice Cream Scoop Spring-Loaded','Pizza Stone 16" Round', +'Cheese Grater 4-Sided Box','Potato Masher Stainless','Tongs Set 9" and 12"','Ladle Stainless 6oz','Can Opener Manual Heavy-Duty', +'Vegetable Peeler Swivel 3-Pack','Meat Thermometer Digital Instant','Egg Timer Digital Magnetic','Tea Infuser Ball Stainless','Citrus Juicer Manual', +'Bread Knife Serrated 10"','Wok Carbon Steel 14"','Steamer Basket Stainless 9"','Cake Pan Round 9" 2-Pack','Muffin Tin 12-Cup Non-Stick', +'Pie Dish Ceramic 9"','Cookie Sheet Air Insulated','Cooling Rack Stackable 2-Pack','Pastry Brush Silicone 2-Pack','Kitchen Timer Dual Digital', +'Garlic Press Heavy-Duty','Corkscrew Double-Hinged','Pepper Mill Wooden 8"','Salt Cellar Bamboo Lid','Spice Rack Rotating 20-Jar', +'Paper Towel Holder Counter','Dish Rack 2-Tier Stainless','Sponge Holder Sink Caddy','Compost Bin Counter 1.3gal','Utensil Crock Ceramic White', +'Oxford Button-Down Shirt Blue','Slim Fit Chinos Khaki','Merino Wool V-Neck Sweater','Classic Polo Shirt Navy','Stretch Dress Pants Charcoal', +'Denim Jacket Trucker Medium','Performance T-Shirt Moisture-Wick','Fleece Quarter-Zip Pullover','Linen Blazer Summer Tan','Cargo Shorts Relaxed Olive', +'Flannel Shirt Plaid Red','Athletic Joggers Tapered Black','Cashmere Scarf Heather Gray','Leather Belt Reversible 34"','Wool Peacoat Double-Breasted', +'Rain Jacket Packable Blue','Thermal Henley Long-Sleeve','Corduroy Pants Straight Brown','Chambray Shirt Casual Fit','Down Vest Lightweight Navy', +'Board Shorts Quick-Dry 9"','Dress Shirt French Cuff White','Windbreaker Half-Zip','Cable Knit Sweater Cream','Tech Pants 5-Pocket Gray', +'Bomber Jacket Nylon Green','Compression Shirt Base Layer','Tweed Sport Coat Classic','Swimsuit Trunk Solid Navy','Hoodie Zip-Up Heather Gray', +'Turtleneck Fine-Gauge Black','Hiking Pants Convertible','Graphic Tee Vintage Logo','Suit Jacket Slim Fit Navy','Parka Insulated Winter Black', +'Rugby Shirt Striped','Track Pants Side-Stripe','Overcoat Wool-Blend Camel','Denim Shirt Western Snap','Softshell Jacket Waterproof', +'Cardigan Button-Front Charcoal','Linen Pants Drawstring White','Performance Polo Quick-Dry','Varsity Jacket Letterman','Thermal Pants Base Layer', +'Safari Jacket Multi-Pocket','Hawaiian Shirt Floral','Vest Quilted Field Green','Driving Gloves Leather Brown','Beanie Ribbed Knit Charcoal', +'Running Shoes Lightweight 10.5','Yoga Mat Premium 6mm Purple','Resistance Band Set 5-Level','Adjustable Dumbbell 52.5lb Pair','Jump Rope Speed Bearing', +'Foam Roller High-Density 36"','Kettlebell Cast Iron 35lb','Pull-Up Bar Doorway','Exercise Ball 65cm Anti-Burst','Boxing Gloves 14oz Leather', +'Tennis Racket Carbon Fiber','Basketball Indoor/Outdoor Size 7','Soccer Ball FIFA Official Size 5','Golf Club Set 12-Piece','Baseball Glove 12.5" Leather', +'Swimming Goggles Anti-Fog','Cycling Helmet Road Large','Ski Goggles UV Protection','Badminton Racket 2-Pack','Table Tennis Paddle Pro Set', +'Lacrosse Stick Complete','Hockey Stick Composite 60"','Volleyball Indoor Official','Cricket Bat English Willow','Archery Target Bag 26"', +'Weight Bench Adjustable FID','Barbell Olympic 7ft 45lb','Weight Plates Rubber 45lb Pair','Squat Rack Folding Wall-Mount','Ab Roller Wheel Double', +'Battle Rope 30ft 1.5"','Medicine Ball Rubber 15lb','Agility Ladder 20ft','Cone Set Training 50-Pack','Speed Parachute Running', +'Grip Strengthener Set 3-Pack','Ankle Weights 5lb Pair','Stability Ball Chair Base','Stretch Strap Yoga 10-Loop','Massage Gun Percussion Deep', +'Heart Rate Monitor Chest Strap','GPS Sports Watch Multi-Sport','Bike Computer Wireless','Swim Cap Silicone 2-Pack','Water Bottle Insulated 32oz', +'Gym Bag Duffel 40L','Sweatband Headband 6-Pack','Athletic Tape 1.5" 6-Roll','Compression Sleeve Calf Pair','Sports Sunglasses Polarized', +'Garden Hose Expandable 100ft','Pruning Shears Bypass Titanium','Raised Garden Bed 4x8 Cedar','Wheelbarrow Steel 6 Cubic Ft','Sprinkler Oscillating Brass', +'Potting Soil Premium 40qt','Garden Gloves Thorn-Proof','Seed Starting Kit 72-Cell','Lawn Mower Push 22"','Hedge Trimmer Electric 24"', +'Plant Labels Bamboo 100-Pack','Watering Can Galvanized 2gal','Garden Kneeler Pad Foam','Trowel Stainless Steel','Cultivator 3-Prong Hand', +'Fertilizer All-Purpose 10lb','Compost Bin Tumbling 43gal','Garden Fork Steel 4-Tine','Leaf Rake Adjustable 30"','Edging Border 20ft', +'Planter Pot Ceramic 12"','Hanging Basket Coconut Liner','Plant Stand Indoor 3-Tier','Garden Hose Reel Wall-Mount','Soaker Hose 50ft', +'Weed Barrier Fabric 4x100ft','Mulch Cedar Red 2 Cubic Ft','Topsoil Screened 40lb','Bird Feeder Cedar House','Birdbath Pedestal Concrete', +'Rain Gauge Clear 5"','Thermometer Outdoor Wireless','Solar Path Lights 10-Pack','Garden Statue Gnome 12"','Wind Chime Bamboo 36"', +'Greenhouse Mini 4-Tier','Cold Frame Double-Wall','Drip Irrigation Kit 100ft','Timer Hose Digital 2-Zone','Pest Control Neem Oil 32oz', +'Insect Net Garden 10x12','Tomato Cage Heavy-Duty 5-Pack','Plant Support Stakes 4ft 20-Pack','Seed Storage Box Organizer','Soil Test Kit Digital pH', +'Pressure Washer 2100 PSI','Garden Cart Dump 10 Cubic Ft','Chainsaw Electric 16"','Leaf Blower Cordless 20V','String Trimmer 15" Dual-Line', +'Floor Mats All-Weather 4-Piece','Car Cover Waterproof XL','Dash Cam Dual 4K Front+Rear','Jump Starter Portable 2000A','Tire Inflator Digital 12V', +'Car Vacuum Cordless Handheld','Phone Mount Magnetic Vent','Seat Cover Set Leather 2-Pack','Steering Wheel Cover Leather','Sun Shade Windshield Foldable', +'Trunk Organizer Collapsible','Emergency Kit Roadside 120-Pc','OBD2 Scanner Bluetooth Pro','Battery Charger Smart 12V','Wiper Blades Premium 24" Pair', +'Car Wash Kit Complete 10-Piece','Polishing Machine Dual-Action','Clay Bar Kit Detailing','Tire Pressure Gauge Digital','Lug Wrench Telescoping Cross', +'Jack Stand 3-Ton Pair','Creeper Mechanic Padded 40"','Tool Set Mechanic 200-Piece','Oil Drain Pan 16qt','Funnel Set Flexible 4-Piece', +'Air Freshener Vent Clip 6-Pack','LED Headlight Bulb H11 Pair','Fog Light Kit Yellow 3000K','License Plate Frame 2-Pack','Cargo Net Trunk Elastic', +'Roof Rack Cross Bars Universal','Bike Rack Hitch 4-Bike','Trailer Hitch Ball 2"','Tow Strap 30ft 30000lb','Recovery Board Traction 2-Pack', +'Blind Spot Mirror Pair','Backup Camera Wireless HD','Parking Sensor Kit 8-Sensor','Paint Touch-Up Kit Universal','Scratch Remover Compound', +'Interior Cleaner All-Surface','Leather Conditioner 16oz','Glass Cleaner Streak-Free 32oz','Tire Shine Gel Long-Lasting','Microfiber Towels 24-Pack', +'Ice Scraper Snow Brush 36"','Antifreeze Coolant 1gal','Transmission Fluid ATF 1qt','Brake Fluid DOT 4 12oz','Engine Oil Synthetic 5W-30 5qt', +'Vitamin D3 5000 IU 360-Cap','Protein Powder Whey 5lb Vanilla','Multivitamin Complete Daily','Omega-3 Fish Oil 1000mg 120-Soft','Probiotic 50 Billion CFU 60-Cap', +'Collagen Peptides Powder 20oz','Magnesium Glycinate 400mg','Zinc Picolinate 50mg 120-Cap','Vitamin C 1000mg 250-Tab','B-Complex Super 100-Cap', +'Sunscreen SPF 50 Face 3oz','Moisturizer Daily SPF 30','Face Wash Gentle Cleanser 8oz','Retinol Serum 2.5% 1oz','Hyaluronic Acid Serum 2oz', +'Eye Cream Anti-Aging 0.5oz','Lip Balm SPF 15 3-Pack','Body Lotion Unscented 16oz','Hand Cream Intensive 3oz','Foot Cream Repair 4oz', +'Shampoo Volumizing 16oz','Conditioner Hydrating 16oz','Hair Oil Argan 4oz','Hair Mask Deep Repair 8oz','Dry Shampoo Spray 7oz', +'Toothbrush Electric Sonic','Toothpaste Whitening 2-Pack','Dental Floss Mint 6-Pack','Mouthwash Antiseptic 1.5L','Tongue Scraper Stainless', +'Deodorant Natural 3-Pack','Razor Safety Double-Edge','Shaving Cream Sensitive 6oz','Aftershave Balm 3.4oz','Beard Oil Sandalwood 2oz', +'Nail Clipper Set 3-Piece','Tweezers Precision Slant','Cotton Rounds 100-Pack','Facial Tissue Soft 12-Box','Hand Sanitizer 8oz 3-Pack', +'Heating Pad Electric 12x24','Ice Pack Gel Reusable 2-Pack','Thermometer Forehead Digital','Blood Pressure Monitor Arm','Pulse Oximeter Fingertip', +'First Aid Kit 300-Piece','Bandages Adhesive 100-Pack','Melatonin 5mg Gummies 120','Electrolyte Powder 30-Pack','Essential Oil Set 6-Bottle', +'Dog Food Premium Chicken 30lb','Cat Food Indoor Formula 16lb','Dog Bed Orthopedic Large','Cat Tree Tower 72" Multi-Level','Pet Carrier Airline Approved', +'Dog Leash Retractable 16ft','Cat Litter Clumping 40lb','Dog Crate Wire Folding 42"','Pet Water Fountain 128oz','Dog Toy Chew Rope 5-Pack', +'Cat Toy Interactive Feather','Dog Brush Deshedding Tool','Cat Scratcher Lounge Cardboard','Dog Bowl Slow Feeder','Cat Bowl Elevated 2-Pack', +'Pet Shampoo Oatmeal 16oz','Dog Treats Training 1lb','Cat Treats Freeze-Dried 5oz','Flea Collar Dog Large 8-Month','Tick Remover Tool Kit', +'Dog Harness No-Pull Padded','Cat Harness and Leash Set','Pet First Aid Kit 75-Piece','Dog Waste Bags 900-Count','Cat Litter Mat 30x24', +'Pet Camera Indoor Wi-Fi','Dog Door Flap Large','Cat Flap Microchip Smart','Pet Gate Freestanding 76"','Dog Ramp Folding Car', +'Pet Blanket Waterproof','Dog Coat Winter Reflective','Cat Bed Heated Electric','Fish Tank Starter Kit 20gal','Aquarium Filter Canister 75gal', +'Bird Cage Large 30x18x36','Hamster Habitat Multi-Level','Reptile Terrarium 40gal','Pet Nail Grinder Quiet','Dog Toothbrush Kit 3-Piece', +'Cat Fountain Filter 6-Pack','Dog ID Tag Stainless Custom','Pet Hair Remover Roller','Dog Training Clicker 2-Pack','Cat Catnip Organic 2oz', +'Aquarium Heater 200W','Fish Food Tropical Flake 4oz','Bird Seed Mix Premium 10lb','Small Animal Bedding 56L','Pet Odor Eliminator Spray 32oz' +}; + +List categories = new List{ + 'Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics', + 'Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics', + 'Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics', + 'Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics', + 'Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics','Electronics', + 'Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies', + 'Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies', + 'Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies', + 'Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies', + 'Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies','Office Supplies', + 'Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture', + 'Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture', + 'Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture', + 'Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture', + 'Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture','Furniture', + 'Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen', + 'Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen', + 'Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen', + 'Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen', + 'Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen','Kitchen', + 'Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing', + 'Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing', + 'Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing', + 'Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing', + 'Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing','Clothing', + 'Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports', + 'Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports', + 'Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports', + 'Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports', + 'Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports','Sports', + 'Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden', + 'Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden', + 'Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden', + 'Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden', + 'Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden','Garden', + 'Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive', + 'Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive', + 'Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive', + 'Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive', + 'Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive','Automotive', + 'Health','Health','Health','Health','Health','Health','Health','Health','Health','Health', + 'Health','Health','Health','Health','Health','Health','Health','Health','Health','Health', + 'Health','Health','Health','Health','Health','Health','Health','Health','Health','Health', + 'Health','Health','Health','Health','Health','Health','Health','Health','Health','Health', + 'Health','Health','Health','Health','Health','Health','Health','Health','Health','Health', + 'Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets', + 'Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets', + 'Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets', + 'Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets', + 'Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets','Pets' +}; + +// Price ranges by category +Map priceRanges = new Map{ + 'Electronics' => new Decimal[]{29.99, 499.99}, + 'Office Supplies' => new Decimal[]{4.99, 99.99}, + 'Furniture' => new Decimal[]{89.99, 999.99}, + 'Kitchen' => new Decimal[]{9.99, 199.99}, + 'Clothing' => new Decimal[]{19.99, 299.99}, + 'Sports' => new Decimal[]{14.99, 299.99}, + 'Garden' => new Decimal[]{9.99, 299.99}, + 'Automotive' => new Decimal[]{7.99, 199.99}, + 'Health' => new Decimal[]{6.99, 99.99}, + 'Pets' => new Decimal[]{8.99, 149.99} +}; + +// Create products +List products = new List(); +for (Integer i = 0; i < names.size(); i++) { + String cat = categories[i]; + String prefix = cat.substring(0, 3).toUpperCase(); + Product2 p = new Product2( + Name = names[i], + ProductCode = prefix + '-' + String.valueOf(i + 1).leftPad(4, '0'), + Description = cat + ' — ' + names[i], + Family = cat, + IsActive = true + ); + products.add(p); +} +insert products; +System.debug('Inserted ' + products.size() + ' products'); + +// Create standard PB entries +Id stdPbId = [SELECT Id FROM Pricebook2 WHERE IsStandard = true LIMIT 1].Id; +List stdEntries = new List(); +for (Integer i = 0; i < products.size(); i++) { + Decimal[] range = priceRanges.get(categories[i]); + Decimal price = Math.floor(range[0] + (Math.random() * (range[1] - range[0]))) + 0.99; + stdEntries.add(new PricebookEntry( + Pricebook2Id = stdPbId, + Product2Id = products[i].Id, + UnitPrice = price, + IsActive = true + )); +} +// Batch insert +insert new List(new List{stdEntries[0],stdEntries[1],stdEntries[2],stdEntries[3],stdEntries[4],stdEntries[5],stdEntries[6],stdEntries[7],stdEntries[8],stdEntries[9],stdEntries[10],stdEntries[11],stdEntries[12],stdEntries[13],stdEntries[14],stdEntries[15],stdEntries[16],stdEntries[17],stdEntries[18],stdEntries[19],stdEntries[20],stdEntries[21],stdEntries[22],stdEntries[23],stdEntries[24],stdEntries[25],stdEntries[26],stdEntries[27],stdEntries[28],stdEntries[29],stdEntries[30],stdEntries[31],stdEntries[32],stdEntries[33],stdEntries[34],stdEntries[35],stdEntries[36],stdEntries[37],stdEntries[38],stdEntries[39],stdEntries[40],stdEntries[41],stdEntries[42],stdEntries[43],stdEntries[44],stdEntries[45],stdEntries[46],stdEntries[47],stdEntries[48],stdEntries[49],stdEntries[50],stdEntries[51],stdEntries[52],stdEntries[53],stdEntries[54],stdEntries[55],stdEntries[56],stdEntries[57],stdEntries[58],stdEntries[59],stdEntries[60],stdEntries[61],stdEntries[62],stdEntries[63],stdEntries[64],stdEntries[65],stdEntries[66],stdEntries[67],stdEntries[68],stdEntries[69],stdEntries[70],stdEntries[71],stdEntries[72],stdEntries[73],stdEntries[74],stdEntries[75],stdEntries[76],stdEntries[77],stdEntries[78],stdEntries[79],stdEntries[80],stdEntries[81],stdEntries[82],stdEntries[83],stdEntries[84],stdEntries[85],stdEntries[86],stdEntries[87],stdEntries[88],stdEntries[89],stdEntries[90],stdEntries[91],stdEntries[92],stdEntries[93],stdEntries[94],stdEntries[95],stdEntries[96],stdEntries[97],stdEntries[98],stdEntries[99],stdEntries[100],stdEntries[101],stdEntries[102],stdEntries[103],stdEntries[104],stdEntries[105],stdEntries[106],stdEntries[107],stdEntries[108],stdEntries[109],stdEntries[110],stdEntries[111],stdEntries[112],stdEntries[113],stdEntries[114],stdEntries[115],stdEntries[116],stdEntries[117],stdEntries[118],stdEntries[119],stdEntries[120],stdEntries[121],stdEntries[122],stdEntries[123],stdEntries[124],stdEntries[125],stdEntries[126],stdEntries[127],stdEntries[128],stdEntries[129],stdEntries[130],stdEntries[131],stdEntries[132],stdEntries[133],stdEntries[134],stdEntries[135],stdEntries[136],stdEntries[137],stdEntries[138],stdEntries[139],stdEntries[140],stdEntries[141],stdEntries[142],stdEntries[143],stdEntries[144],stdEntries[145],stdEntries[146],stdEntries[147],stdEntries[148],stdEntries[149],stdEntries[150],stdEntries[151],stdEntries[152],stdEntries[153],stdEntries[154],stdEntries[155],stdEntries[156],stdEntries[157],stdEntries[158],stdEntries[159],stdEntries[160],stdEntries[161],stdEntries[162],stdEntries[163],stdEntries[164],stdEntries[165],stdEntries[166],stdEntries[167],stdEntries[168],stdEntries[169],stdEntries[170],stdEntries[171],stdEntries[172],stdEntries[173],stdEntries[174],stdEntries[175],stdEntries[176],stdEntries[177],stdEntries[178],stdEntries[179],stdEntries[180],stdEntries[181],stdEntries[182],stdEntries[183],stdEntries[184],stdEntries[185],stdEntries[186],stdEntries[187],stdEntries[188],stdEntries[189],stdEntries[190],stdEntries[191],stdEntries[192],stdEntries[193],stdEntries[194],stdEntries[195],stdEntries[196],stdEntries[197],stdEntries[198],stdEntries[199]}); +System.debug('Inserted standard PB entries batch 1'); + +List batch2 = new List(); +for (Integer i = 200; i < stdEntries.size(); i++) { batch2.add(stdEntries[i]); } +insert batch2; +System.debug('Inserted standard PB entries batch 2'); + +// Custom pricebook +Pricebook2 catalog = new Pricebook2(Name = 'Product Catalog 2026', Description = '2026 Full Product Catalog — 500 products across 10 categories', IsActive = true); +insert catalog; + +List catEntries = new List(); +for (Integer i = 0; i < products.size(); i++) { + catEntries.add(new PricebookEntry( + Pricebook2Id = catalog.Id, + Product2Id = products[i].Id, + UnitPrice = stdEntries[i].UnitPrice, + IsActive = true + )); +} +List cb1 = new List(); +for (Integer i = 0; i < 200; i++) { cb1.add(catEntries[i]); } +insert cb1; +List cb2 = new List(); +for (Integer i = 200; i < catEntries.size(); i++) { cb2.add(catEntries[i]); } +insert cb2; + +System.debug('DONE: ' + products.size() + ' products, pricebook "Product Catalog 2026", ' + stdEntries.size() + ' std entries, ' + catEntries.size() + ' catalog entries'); +System.debug('Now run step2 and step3 to create images.'); diff --git a/scripts/product-catalog-step2.apex b/scripts/product-catalog-step2.apex new file mode 100644 index 0000000..79136e4 --- /dev/null +++ b/scripts/product-catalog-step2.apex @@ -0,0 +1,62 @@ +/** + * Step 2: Create product images for first 250 products and set Product_Image__c. + * Run AFTER step1. Run: sf apex run --target-org -f scripts/product-catalog-step2.apex + */ + +Map catColors = new Map{ + 'Electronics'=>'3B82F6','Office Supplies'=>'F59E0B','Furniture'=>'10B981', + 'Kitchen'=>'EF4444','Clothing'=>'8B5CF6','Sports'=>'EC4899', + 'Garden'=>'14B8A6','Automotive'=>'F97316','Health'=>'06B6D4','Pets'=>'84CC16' +}; + +List products = [SELECT Id, Name, ProductCode, Family FROM Product2 WHERE IsActive = true ORDER BY ProductCode ASC LIMIT 250]; +System.debug('Found ' + products.size() + ' products for image batch 1'); + +List cvs = new List(); +for (Product2 p : products) { + String color = catColors.containsKey(p.Family) ? catColors.get(p.Family) : '64748B'; + String label = p.Name.length() > 28 ? p.Name.substring(0, 26) + '..' : p.Name; + String svg = '' + + '' + + '' + label.escapeXml() + '' + + '' + (p.Family != null ? p.Family.escapeXml() : '') + '' + + '' + + '' + p.ProductCode + '' + + ''; + cvs.add(new ContentVersion( + Title = 'Product Image - ' + p.Name, + PathOnClient = p.ProductCode + '.svg', + VersionData = Blob.valueOf(svg), + FirstPublishLocationId = p.Id + )); +} + +// Insert in batches of 125 +List b1 = new List(); +List b2 = new List(); +for (Integer i = 0; i < cvs.size(); i++) { + if (i < 125) { b1.add(cvs[i]); } else { b2.add(cvs[i]); } +} +insert b1; +if (!b2.isEmpty()) { insert b2; } +System.debug('Created ' + cvs.size() + ' product images'); + +// Update Product_Image__c with CV IDs +Set cvIds = new Set(); +for (ContentVersion cv : cvs) { cvIds.add(cv.Id); } +List committed = [SELECT Id, FirstPublishLocationId FROM ContentVersion WHERE Id IN :cvIds]; + +List updates = new List(); +for (ContentVersion cv : committed) { + Product2 p = new Product2(Id = cv.FirstPublishLocationId); + p.put('portwoodglobal__Product_Image__c', cv.Id); + updates.add(p); +} +List u1 = new List(); +List u2 = new List(); +for (Integer i = 0; i < updates.size(); i++) { + if (i < 125) { u1.add(updates[i]); } else { u2.add(updates[i]); } +} +update u1; +if (!u2.isEmpty()) { update u2; } +System.debug('DONE: Updated ' + updates.size() + ' products with image CV IDs (batch 1 of 2)'); diff --git a/scripts/product-catalog-step3.apex b/scripts/product-catalog-step3.apex new file mode 100644 index 0000000..d70c794 --- /dev/null +++ b/scripts/product-catalog-step3.apex @@ -0,0 +1,72 @@ +/** + * Step 3: Create product images for remaining 250 products (251-500) and set Product_Image__c. + * Run AFTER step2. Run: sf apex run --target-org -f scripts/product-catalog-step3.apex + */ + +Map catColors = new Map{ + 'Electronics'=>'3B82F6','Office Supplies'=>'F59E0B','Furniture'=>'10B981', + 'Kitchen'=>'EF4444','Clothing'=>'8B5CF6','Sports'=>'EC4899', + 'Garden'=>'14B8A6','Automotive'=>'F97316','Health'=>'06B6D4','Pets'=>'84CC16' +}; + +// Get products that DON'T have an image yet +List products = [ + SELECT Id, Name, ProductCode, Family + FROM Product2 + WHERE IsActive = true AND portwoodglobal__Product_Image__c = null + ORDER BY ProductCode ASC + LIMIT 250 +]; +System.debug('Found ' + products.size() + ' products needing images (batch 2)'); + +if (products.isEmpty()) { + System.debug('All products already have images. Nothing to do.'); +} else { + List cvs = new List(); + for (Product2 p : products) { + String color = catColors.containsKey(p.Family) ? catColors.get(p.Family) : '64748B'; + String label = p.Name.length() > 28 ? p.Name.substring(0, 26) + '..' : p.Name; + String svg = '' + + '' + + '' + label.escapeXml() + '' + + '' + (p.Family != null ? p.Family.escapeXml() : '') + '' + + '' + + '' + p.ProductCode + '' + + ''; + cvs.add(new ContentVersion( + Title = 'Product Image - ' + p.Name, + PathOnClient = p.ProductCode + '.svg', + VersionData = Blob.valueOf(svg), + FirstPublishLocationId = p.Id + )); + } + + List b1 = new List(); + List b2 = new List(); + for (Integer i = 0; i < cvs.size(); i++) { + if (i < 125) { b1.add(cvs[i]); } else { b2.add(cvs[i]); } + } + insert b1; + if (!b2.isEmpty()) { insert b2; } + System.debug('Created ' + cvs.size() + ' product images'); + + Set cvIds = new Set(); + for (ContentVersion cv : cvs) { cvIds.add(cv.Id); } + List committed = [SELECT Id, FirstPublishLocationId FROM ContentVersion WHERE Id IN :cvIds]; + + List updates = new List(); + for (ContentVersion cv : committed) { + Product2 p = new Product2(Id = cv.FirstPublishLocationId); + p.put('portwoodglobal__Product_Image__c', cv.Id); + updates.add(p); + } + List u1 = new List(); + List u2 = new List(); + for (Integer i = 0; i < updates.size(); i++) { + if (i < 125) { u1.add(updates[i]); } else { u2.add(updates[i]); } + } + update u1; + if (!u2.isEmpty()) { update u2; } + System.debug('DONE: Updated ' + updates.size() + ' products with image CV IDs (batch 2 of 2)'); + System.debug('All 500 products should now have images!'); +} diff --git a/sfdx-project.json b/sfdx-project.json index 58d31d1..570fe3b 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "1.1.1 — Image Sizing, Error Diagnostics & Multiline Text", - "versionNumber": "1.1.1.NEXT", + "versionName": "1.1.2 — Image Sizing, Error Diagnostics & Multiline Text", + "versionNumber": "1.1.2.NEXT", "path": "force-app", "default": true, "package": "Portwood DocGen", @@ -29,6 +29,7 @@ "Portwood DocGen@1.0.8-1": "04tal000006PB01AAG", "Portwood DocGen@1.0.9-1": "04tal000006PBrFAAW", "Portwood DocGen@1.1.0-1": "04tal000006PCFRAA4", - "Portwood DocGen@1.1.1-1": "04tal000006PCTxAAO" + "Portwood DocGen@1.1.1-1": "04tal000006PCTxAAO", + "Portwood DocGen@1.1.2-1": "04tal000006PCVZAA4" } } \ No newline at end of file From dabfc09070fc90f7856bc5633b92be41fa6b3938 Mon Sep 17 00:00:00 2001 From: Dave Moudy Date: Thu, 26 Mar 2026 22:57:24 -0400 Subject: [PATCH 17/19] release: v1.1.3 'Beacon' - Clickable Hyperlinks in Rich Text PDFs Clickable hyperlinks from rich text fields now render as real tags in PDF output. Install links updated to 04tal000006PClhAAG. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 +++ README.md | 6 +-- .../classes/DocGenLandingController.cls | 4 +- .../main/default/pages/DocGenLanding.page | 2 +- .../default/classes/DocGenHtmlRenderer.cls | 16 +++++- .../main/default/classes/DocGenService.cls | 53 ++++++++++++++++++- sfdx-project.json | 7 +-- 7 files changed, 83 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c31a9a2..e44ae8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.1.3 — "Clickable Hyperlinks in Rich Text PDFs" (Portwood DocGen) + +- **Clickable Hyperlinks in PDF** — Rich text `` tags now render as real clickable links in PDF output. Previously, hyperlinks from rich text fields were rendered as styled text (blue + underline) but were not clickable. Now they generate proper `` tags in the HTML passed to `Blob.toPdf()`. +- **Anchor Tag Parsing** — New `extractAttribute()` helper parses `href` from rich text anchor tags. Handles quoted and unquoted attributes, `&` decoding. +- **Custom URL Attribute for DOCX→PDF Bridge** — Rich text links embed a `w:docgen-url` attribute on `w:hyperlink` elements during XML processing, which the HTML renderer reads to produce clickable `` tags without needing relationship file lookups. + ## v1.1.2 — "Image Sizing, Error Diagnostics & Multiline Text" (Portwood DocGen) Huge thanks to **@Henk3000** for PR #47 — ImageRenderSpec, ahe() helper, multiline text preservation, error diagnostics for malformed tags, and smart container expansion for numbered lists. diff --git a/README.md b/README.md index be2617e..d54f495 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generate PDFs, Word docs, Excel spreadsheets, and PowerPoint presentations from any Salesforce record. Merge PDFs, add barcodes and QR codes, compute totals — 100% native, zero external dependencies, completely free. -[![Version](https://img.shields.io/badge/version-1.1.2-blue.svg)](#install) +[![Version](https://img.shields.io/badge/version-1.1.3-blue.svg)](#install) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Salesforce-00A1E0.svg)](https://www.salesforce.com) [![Namespace](https://img.shields.io/badge/namespace-portwoodglobal-purple.svg)](#install) @@ -30,10 +30,10 @@ Scanned with `sf code-analyzer run --rule-selector "recommended"` — the rule s **New install:** ```bash -sf package install --package 04tal000006PCVZAA4 --wait 10 --target-org +sf package install --package 04tal000006PClhAAG --wait 10 --target-org ``` -[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4) +[Install in Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PClhAAG) | [Install in Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PClhAAG) **Then:** Assign **DocGen Admin** permission set | Enable **Blob.toPdf() Release Update** | Open the **DocGen** app diff --git a/devhub-tools/main/default/classes/DocGenLandingController.cls b/devhub-tools/main/default/classes/DocGenLandingController.cls index feb9885..5451722 100644 --- a/devhub-tools/main/default/classes/DocGenLandingController.cls +++ b/devhub-tools/main/default/classes/DocGenLandingController.cls @@ -7,8 +7,8 @@ public without sharing class DocGenLandingController { // Store demo session in a custom setting or org-wide default // For now, use a static approach — refresh via CLI - private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4'; - private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PCVZAA4'; + private static final String INSTALL_LINK_PROD = 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PClhAAG'; + private static final String INSTALL_LINK_SANDBOX = 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04tal000006PClhAAG'; public String installLinkProd { get { return INSTALL_LINK_PROD; } } public String installLinkSandbox { get { return INSTALL_LINK_SANDBOX; } } diff --git a/devhub-tools/main/default/pages/DocGenLanding.page b/devhub-tools/main/default/pages/DocGenLanding.page index 3444db9..356499e 100644 --- a/devhub-tools/main/default/pages/DocGenLanding.page +++ b/devhub-tools/main/default/pages/DocGenLanding.page @@ -276,7 +276,7 @@ Install in Production Install in Sandbox -
sf package install --package 04tal000006PCVZAA4 --wait 10 --target-org your-org
+
sf package install --package 04tal000006PClhAAG --wait 10 --target-org your-org

Then: Assign DocGen Admin permission set • Open the DocGen app

diff --git a/force-app/main/default/classes/DocGenHtmlRenderer.cls b/force-app/main/default/classes/DocGenHtmlRenderer.cls index 481ffd8..7858a62 100644 --- a/force-app/main/default/classes/DocGenHtmlRenderer.cls +++ b/force-app/main/default/classes/DocGenHtmlRenderer.cls @@ -747,7 +747,21 @@ public with sharing class DocGenHtmlRenderer { if (String.isBlank(innerContent)) { return ''; } - // Wrap in styled span to indicate link (blue + underline) + // Check for direct URL from rich text (custom attribute set by DocGenService) + String directUrl = null; + Integer urlAttr = hlXml.indexOf('w:docgen-url="'); + if (urlAttr != -1) { + Integer urlStart = urlAttr + 14; + Integer urlEnd = hlXml.indexOf('"', urlStart); + if (urlEnd != -1) { + directUrl = hlXml.substring(urlStart, urlEnd); + } + } + + if (String.isNotBlank(directUrl)) { + return '' + innerContent + ''; + } + // No URL available (template hyperlink without rels) — render as styled text return '' + innerContent + ''; } diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index 27aa75f..c131c45 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -1995,6 +1995,30 @@ public with sharing class DocGenService { isUnderline = true; } else if (tag.startsWith('')) { isUnderline = false; + } else if (tag.startsWith('')) { + // Extract href from anchor tag + String anchorTag = html.substring(nextTag, tagEnd); + String href = extractAttribute(anchorTag, 'href'); + // Find the closing and get inner text + Integer closeA = html.indexOfIgnoreCase('', tagEnd); + if (closeA != -1) { + String linkText = html.substring(tagEnd, closeA); + linkText = linkText.replaceAll('<[^>]+>', '').trim(); + if (String.isBlank(linkText)) { linkText = href; } + if (String.isNotBlank(href)) { + // Build w:hyperlink with direct URL attribute for PDF rendering + result += '' + + '' + + '' + linkText.escapeXml() + '' + + ''; + } else { + result += buildRun(linkText, isBold, isItalic, true); + } + cursor = closeA + 4; // skip past + continue; + } + } else if (tag.startsWith(' tag String imgTag = html.substring(nextTag, tagEnd); @@ -2003,7 +2027,7 @@ public with sharing class DocGenService { result += imgResult; } } - // Skip other tags (spans, anchors, etc.) — just process their text content + // Skip other tags (spans, etc.) — just process their text content cursor = tagEnd; } @@ -2036,6 +2060,33 @@ public with sharing class DocGenService { return text.replaceAll('<[^>]+>', ''); } + /** + * Extracts an attribute value from an HTML tag string. + * e.g. extractAttribute('', 'href') => 'https://example.com' + */ + private static String extractAttribute(String tag, String attrName) { + Integer attrIdx = tag.indexOfIgnoreCase(attrName + '='); + if (attrIdx == -1) { return null; } + Integer valStart = attrIdx + attrName.length() + 1; + if (valStart >= tag.length()) { return null; } + String quote = tag.substring(valStart, valStart + 1); + if (quote == '"' || quote == '\'') { + valStart++; + Integer valEnd = tag.indexOf(quote, valStart); + if (valEnd != -1) { + return tag.substring(valStart, valEnd).replace('&', '&'); + } + } + // Unquoted attribute — read until space or > + Integer valEnd = tag.indexOf(' ', valStart); + Integer tagEnd = tag.indexOf('>', valStart); + if (valEnd == -1 || (tagEnd != -1 && tagEnd < valEnd)) { valEnd = tagEnd; } + if (valEnd != -1) { + return tag.substring(valStart, valEnd).replace('&', '&'); + } + return null; + } + /** * Processes an tag from rich text, fetches the image, and returns DrawingML XML. */ diff --git a/sfdx-project.json b/sfdx-project.json index 570fe3b..25bf65f 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "1.1.2 — Image Sizing, Error Diagnostics & Multiline Text", - "versionNumber": "1.1.2.NEXT", + "versionName": "1.1.3 — Clickable Hyperlinks in Rich Text PDFs", + "versionNumber": "1.1.3.NEXT", "path": "force-app", "default": true, "package": "Portwood DocGen", @@ -30,6 +30,7 @@ "Portwood DocGen@1.0.9-1": "04tal000006PBrFAAW", "Portwood DocGen@1.1.0-1": "04tal000006PCFRAA4", "Portwood DocGen@1.1.1-1": "04tal000006PCTxAAO", - "Portwood DocGen@1.1.2-1": "04tal000006PCVZAA4" + "Portwood DocGen@1.1.2-1": "04tal000006PCVZAA4", + "Portwood DocGen@1.1.3-1": "04tal000006PClhAAG" } } \ No newline at end of file From 5e98d8728b13fcb6fb914b541d92c3a8fa82e1eb Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 30 Mar 2026 10:58:31 +0200 Subject: [PATCH 18/19] refactor: update DocGenMiscTests and DocGenService - Removed unused methods from DocGenService. - Updated template fields in DocGenMiscTests to replace 'AccountSource' with 'Id' in various locations. - Adjusted test cases to reflect changes in template fields and ensure proper functionality. --- .../main/default/classes/DocGenMiscTests.cls | 23 +++++++++---------- .../main/default/classes/DocGenService.cls | 22 ------------------ 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index 3fc2d29..e19f8cc 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -13,7 +13,6 @@ private class DocGenMiscTests { ); insert tpl; - createManagedUser(UNIQUE_IDENTIFIER); } @@ -381,7 +380,7 @@ private class DocGenMiscTests { '' + '' + '{Name}' + - '{AccountSource}'; + '{Id}
'; createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; @@ -468,7 +467,7 @@ private class DocGenMiscTests { // Update template query config to include Signature DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, AccountSource, Signature'; + tpl.Query_Config__c = 'Name, Id, Signature'; update tpl; User usr = getManagedUser(); @@ -573,7 +572,7 @@ private class DocGenMiscTests { Map result; Test.startTest(); try { - result = DocGenService.processDocument(tpl.Id, acc.Id); + result = DocGenService.processDocument(tpl.Id, usr.Id); } catch (DocGenException e) { System.assert(e.getMessage().contains('Malformed loop tag'), 'Exception message should contain "Malformed loop tag"'); } @@ -607,7 +606,7 @@ private class DocGenMiscTests { zw.addEntry('word/document.xml', Blob.valueOf(bodyXml)); String headerXml = 'Header: {Name}'; zw.addEntry('word/header1.xml', Blob.valueOf(headerXml)); - String footerXml = 'Footer: {AccountSource}'; + String footerXml = 'Footer: {Id}'; zw.addEntry('word/footer1.xml', Blob.valueOf(footerXml)); ContentVersion cv = new ContentVersion(Title='HeaderFooterDoc', PathOnClient='HeaderFooterDoc.docx', VersionData=zw.getArchive()); @@ -862,7 +861,7 @@ private class DocGenMiscTests { @IsTest static void testGetRecordDataWithAutoAppendC() { - // Exercises the __c auto-append branch — AccountSource exists, but try without __c + // Exercises the __c auto-append branch — Id exists, but try without __c // "Signature" is a standard field so this tests the field-not-found skip path Test.startTest(); @@ -1581,7 +1580,7 @@ private class DocGenMiscTests { Map result; Test.startTest(); try { - result = DocGenService.processDocument(tpl.Id, acc.Id); + result = DocGenService.processDocument(tpl.Id, usr.Id); } catch (DocGenException e) { System.assert(e.getMessage().contains('Malformed merge tag'), 'Exception message should contain "Malformed merge tag"'); } @@ -1759,13 +1758,13 @@ private class DocGenMiscTests { updateUser(usr); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - tpl.Query_Config__c = 'Name, AboutMe, AccountSource'; + tpl.Query_Config__c = 'Name, AboutMe, Id'; update tpl; String docXml = '' + '' + - '{#AboutMe}{#AccountSource}Both present{/AccountSource}{/AboutMe}'; + '{#AboutMe}{#Id}Both present{/Id}{/AboutMe}'; createValidDocxFile(docXml); Test.startTest(); @@ -1915,7 +1914,7 @@ private class DocGenMiscTests { '{Name}'; String slide2 = '' + '' + - '{AccountSource}'; + '{Id}'; zw.addEntry('ppt/slides/slide1.xml', Blob.valueOf(slide1)); zw.addEntry('ppt/slides/slide2.xml', Blob.valueOf(slide2)); @@ -3368,7 +3367,7 @@ private class DocGenMiscTests { '' + '' + '{Name}' + - '{AccountSource}' + + '{Id}' + 'Static Text' + ''; @@ -4803,7 +4802,7 @@ private class DocGenMiscTests { String sharedStringsXml = '' + - '{Name}{AccountSource}'; + '{Name}{Id}'; String sheet1Xml = '' + diff --git a/force-app/main/default/classes/DocGenService.cls b/force-app/main/default/classes/DocGenService.cls index e7ac461..4bc6acb 100644 --- a/force-app/main/default/classes/DocGenService.cls +++ b/force-app/main/default/classes/DocGenService.cls @@ -2828,26 +2828,4 @@ public with sharing class DocGenService { public static Map mergeTemplateForSignature(Id templateId, String recordIdStr) { throw new DocGenException('Signatures have been removed from DocGen. Please delete the old signature classes from your org.'); } - - // Method to create an AuraHandledException with an Exception message - public static AuraHandledException ahe(Exception exc) { - return ahe(exc, exc.getMessage()); - } - - public static AuraHandledException ahe(Exception exc, String message) { - if (exc instanceof AuraHandledException) { - return (AuraHandledException) exc; - } - System.debug(LoggingLevel.ERROR, 'AuraHandledException created from Exception type: ' + exc.getTypeName()); - System.debug(LoggingLevel.ERROR, 'Message: ' + exc.getMessage()); - System.debug(LoggingLevel.ERROR, 'StackTrace: ' + exc.getStackTraceString()); - return ahe(message); - } - - // Method to create an AuraHandledException with a custom message - public static AuraHandledException ahe(String message) { - AuraHandledException ahe = new AuraHandledException(message); - ahe.setMessage(message); - return ahe; - } } \ No newline at end of file From a190bf7e9d2b57f44894128a80ba4ca190611658 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 30 Mar 2026 12:01:36 +0200 Subject: [PATCH 19/19] create a chatter free user as it's available in all languages --- force-app/main/default/classes/DocGenMiscTests.cls | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/force-app/main/default/classes/DocGenMiscTests.cls b/force-app/main/default/classes/DocGenMiscTests.cls index e19f8cc..d092e82 100644 --- a/force-app/main/default/classes/DocGenMiscTests.cls +++ b/force-app/main/default/classes/DocGenMiscTests.cls @@ -36,7 +36,7 @@ private class DocGenMiscTests { LocaleSidKey = 'en_US', EmailEncodingKey = 'UTF-8', ManagerId = UserInfo.getUserId(), - ProfileId = [SELECT Id FROM Profile WHERE Name = 'Chatter Free User' LIMIT 1].Id, + ProfileId = [SELECT Id FROM Profile WHERE Name = :CHATTER_FREE_USER_PROFILE_NAME LIMIT 1].Id, TimeZoneSidKey = 'America/Los_Angeles' ); insert usr; @@ -4007,7 +4007,7 @@ private class DocGenMiscTests { @IsTest static void testSaveSettingsPermissionDenied() { // Create a user without admin access (permission set DocGen_Admin_Access is not assigned) - Profile p = [SELECT Id FROM Profile WHERE Id =: UserInfo.getProfileId() LIMIT 1]; + Profile p = [SELECT Id FROM Profile WHERE Name = :CHATTER_FREE_USER_PROFILE_NAME LIMIT 1]; User stdUser = new User( Alias = 'dgntst', Email = 'docgentest@example.com', @@ -4133,14 +4133,13 @@ private class DocGenMiscTests { createValidDocxFile(docXml); DocGen_Template__c tpl = [SELECT Id FROM DocGen_Template__c LIMIT 1]; - User usr = getManagedUser(); Map preloaded = new Map{ 'Name' => 'Preloaded Name' }; Test.startTest(); - String html = DocGenService.generateHtmlForRecord(tpl.Id, usr.Id, preloaded); + String html = DocGenService.generateHtmlForRecord(tpl.Id, UserInfo.getUserId(), preloaded); Test.stopTest(); System.assertNotEquals(null, html, 'Should return HTML with preloaded data');