mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
@W-21645777 Removed unused skills (#68)
* Delete skills/apex-class directory * Delete skills/apex-test-class directory * Delete skills/deployment-readiness-check directory
This commit is contained in:
parent
b5b7a5ea52
commit
8bc46ed21a
@ -1,253 +0,0 @@
|
||||
---
|
||||
name: apex-class
|
||||
description: Generate production-ready Apex classes for Salesforce following enterprise best practices. Covers service classes, selectors, domain classes, batch/queueable/schedulable, DTOs, utilities, interfaces, abstract classes, and custom exceptions. Not for triggers or unit tests. Use this skill whenever the user asks to create, write, build, generate, or scaffold any Apex class — including when they describe functionality that would require an Apex class without explicitly saying "Apex class." Trigger on phrases like "build a service for," "create a handler," "I need a class that," "write Apex to," "scaffold a batch job," "create a queueable," or any request involving Salesforce server-side logic. Also use when the user asks to refactor, improve, or restructure existing Apex classes. Even if the request is vague like "I need something that processes Opportunities nightly," use this skill if the solution would involve an Apex class.
|
||||
---
|
||||
|
||||
# Apex Class
|
||||
|
||||
Generate well-structured, production-ready Apex classes for Salesforce development.
|
||||
|
||||
## Scope
|
||||
|
||||
**Generates:**
|
||||
- Service classes (business logic layer)
|
||||
- Selector / query classes (SOQL encapsulation)
|
||||
- Domain classes (SObject-specific logic)
|
||||
- Batch Apex classes (`Database.Batchable`)
|
||||
- Queueable Apex classes (`Queueable`, optionally `Finalizer`)
|
||||
- Schedulable Apex classes (`Schedulable`)
|
||||
- DTO / wrapper / request-response classes
|
||||
- Utility / helper classes
|
||||
- Custom exception classes
|
||||
- Interfaces and abstract classes
|
||||
|
||||
**Does NOT generate:**
|
||||
- Triggers (use a trigger framework skill)
|
||||
- Unit tests (use a test writer skill)
|
||||
- Aura controllers
|
||||
- LWC JavaScript controllers
|
||||
|
||||
---
|
||||
|
||||
## Gathering Requirements
|
||||
|
||||
Before generating code, gather these inputs from the user (ask if not provided):
|
||||
|
||||
1. **Class type** — Service, Selector, Batch, Queueable, Schedulable, Domain, DTO, Utility, Interface, Abstract, Exception
|
||||
2. **Class name** — or enough context to derive a meaningful name
|
||||
3. **SObject(s) involved** — if applicable
|
||||
4. **Business requirements** — plain-language description of what the class should do
|
||||
5. **Optional preferences:**
|
||||
- Sharing model (`with sharing`, `without sharing`, `inherited sharing`)
|
||||
- Access modifier (`public` or `global`)
|
||||
- API version (default: `62.0`)
|
||||
- Whether to include ApexDoc comments (default: yes)
|
||||
|
||||
If the user provides a clear, complete request, generate immediately without unnecessary back-and-forth.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards — ALWAYS Follow These
|
||||
|
||||
### Separation of Concerns
|
||||
- **Service classes** contain business logic. They call Selectors for data and may call Domain classes for SObject-specific behavior.
|
||||
- **Selector classes** encapsulate all SOQL queries. Services never contain inline SOQL.
|
||||
- **Domain classes** encapsulate SObject-specific logic (field defaults, validation, transformation).
|
||||
- Keep classes focused on a single responsibility.
|
||||
|
||||
### Bulkification
|
||||
- All methods must operate on collections (`List`, `Set`, `Map`) by default.
|
||||
- Never accept a single SObject when a `List<SObject>` is appropriate.
|
||||
- Provide convenience overloads for single-record callers only when it makes the API cleaner, and have them delegate to the bulk method.
|
||||
|
||||
### Governor Limit Safety
|
||||
- **No SOQL or DML inside loops.** Ever.
|
||||
- Collect IDs/records first, query/DML once outside the loop.
|
||||
- Use `Limits` class checks in batch/bulk operations where appropriate.
|
||||
- Prefer `Database.insert(records, false)` with error handling in batch contexts.
|
||||
|
||||
### Sharing Model
|
||||
- Default to `with sharing` unless the user specifies otherwise.
|
||||
- If `without sharing` or `inherited sharing` is used, add an ApexDoc `@description` comment explaining WHY.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Class Type | Pattern | Example |
|
||||
|-------------|-------------------------------|-------------------------------|
|
||||
| Service | `{SObject}Service` | `AccountService` |
|
||||
| Selector | `{SObject}Selector` | `AccountSelector` |
|
||||
| Domain | `{SObject}Domain` | `OpportunityDomain` |
|
||||
| Batch | `{Descriptive}Batch` | `AccountDeduplicationBatch` |
|
||||
| Queueable | `{Descriptive}Queueable` | `ExternalSyncQueueable` |
|
||||
| Schedulable | `{Descriptive}Schedulable` | `DailyCleanupSchedulable` |
|
||||
| DTO | `{Descriptive}DTO` | `AccountMergeRequestDTO` |
|
||||
| Wrapper | `{Descriptive}Wrapper` | `OpportunityLineWrapper` |
|
||||
| Utility | `{Descriptive}Util` | `StringUtil`, `DateUtil` |
|
||||
| Interface | `I{Descriptive}` | `INotificationService` |
|
||||
| Abstract | `Abstract{Descriptive}` | `AbstractIntegrationService` |
|
||||
| Exception | `{Descriptive}Exception` | `AccountServiceException` |
|
||||
|
||||
### ApexDoc Comments
|
||||
Include ApexDoc on every `public` and `global` method and on the class itself:
|
||||
|
||||
```apex
|
||||
/**
|
||||
* @description Brief description of what the class does
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
```
|
||||
|
||||
```apex
|
||||
/**
|
||||
* @description Brief description of what the method does
|
||||
* @param paramName Description of the parameter
|
||||
* @return Description of the return value
|
||||
* @example
|
||||
* List<Account> results = AccountService.deduplicateAccounts(accountIds);
|
||||
*/
|
||||
```
|
||||
|
||||
### Null Safety
|
||||
- Use guard clauses at the top of methods for null/empty inputs.
|
||||
- Use safe navigation (`?.`) where appropriate.
|
||||
- Return empty collections rather than `null`.
|
||||
|
||||
### Constants
|
||||
- No magic strings or numbers in logic.
|
||||
- Use `private static final` constants or a dedicated constants class.
|
||||
- Use `Label.` custom labels for user-facing strings when appropriate.
|
||||
|
||||
### Custom Exceptions
|
||||
- Each service class should define or reference a corresponding custom exception.
|
||||
- Inner exception classes are preferred for simple cases: `public class AccountServiceException extends Exception {}`
|
||||
- Include meaningful error messages with context.
|
||||
|
||||
### Error Handling
|
||||
- Catch specific exceptions, not generic `Exception` unless re-throwing.
|
||||
- Log errors meaningfully (assume a logging utility exists or stub one).
|
||||
- In batch contexts, use `Database.SaveResult` / `Database.UpsertResult` with partial success.
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
For every class, produce TWO files:
|
||||
|
||||
1. **`{ClassName}.cls`** — The Apex class source code
|
||||
2. **`{ClassName}.cls-meta.xml`** — The metadata file
|
||||
|
||||
### Meta XML Template
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>{API_VERSION}</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
```
|
||||
|
||||
Default `apiVersion` is `62.0` unless the user specifies otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Class Type–Specific Instructions
|
||||
|
||||
### Service Classes
|
||||
- Read and follow: `templates/service.cls`
|
||||
- Stateless — no instance variables holding mutable state
|
||||
- All public methods should be `static` unless there's a compelling reason for instance methods
|
||||
- Delegate queries to a Selector class
|
||||
- Wrap business logic errors in a custom exception
|
||||
|
||||
### Selector Classes
|
||||
- Read and follow: `templates/selector.cls`
|
||||
- One Selector per SObject (or per logical query domain)
|
||||
- Return `List<SObject>` or `Map<Id, SObject>`
|
||||
- Accept filter criteria as method parameters, not hardcoded
|
||||
- Include a private method that returns the base field list to keep DRY
|
||||
|
||||
### Domain Classes
|
||||
- Read and follow: `templates/domain.cls`
|
||||
- Encapsulate field-level defaults, derivations, and validations
|
||||
- Operate on `List<SObject>` — designed to be called from triggers or services
|
||||
- No SOQL or DML — only in-memory SObject manipulation
|
||||
|
||||
### Batch Classes
|
||||
- Read and follow: `templates/batch.cls`
|
||||
- Implement `Database.Batchable<SObject>` and optionally `Database.Stateful`
|
||||
- Use `Database.QueryLocator` in `start()` for large datasets
|
||||
- Handle partial failures in `execute()` using `Database.SaveResult`
|
||||
- Implement meaningful `finish()` — at minimum, log completion
|
||||
|
||||
### Queueable Classes
|
||||
- Read and follow: `templates/queueable.cls`
|
||||
- Implement `Queueable` and optionally `Database.AllowsCallouts`
|
||||
- Accept data through the constructor — queueables are stateful
|
||||
- For chaining, include guard logic to prevent infinite chains
|
||||
- Optionally implement `Finalizer` for error recovery
|
||||
|
||||
### Schedulable Classes
|
||||
- Read and follow: `templates/schedulable.cls`
|
||||
- Implement `Schedulable`
|
||||
- Keep `execute()` lightweight — delegate to a Batch or Queueable
|
||||
- Include a static method that returns a CRON expression for convenience
|
||||
- Document the expected schedule in ApexDoc
|
||||
|
||||
### DTO / Wrapper Classes
|
||||
- Read and follow: `templates/dto.cls`
|
||||
- Use `public` properties — no getters/setters unless validation is needed
|
||||
- Include a no-arg constructor and optionally a parameterized constructor
|
||||
- Implement `Comparable` if sorting is needed
|
||||
- Keep them serialization-friendly (no transient state unless intentional)
|
||||
|
||||
### Utility Classes
|
||||
- Read and follow: `templates/utility.cls`
|
||||
- All methods `public static`
|
||||
- Class should be `public with sharing` with a `private` constructor to prevent instantiation
|
||||
- Group related utilities (e.g., `StringUtil`, `DateUtil`, `CollectionUtil`)
|
||||
- Every method must be side-effect-free (no DML, no SOQL)
|
||||
|
||||
### Interfaces
|
||||
- Read and follow: `templates/interface.cls`
|
||||
- Define the contract clearly with ApexDoc on every method signature
|
||||
- Use meaningful names that describe the capability: `INotificationService`, `IRetryable`
|
||||
|
||||
### Abstract Classes
|
||||
- Read and follow: `templates/abstract.cls`
|
||||
- Provide default implementations for common behavior
|
||||
- Mark extension points as `protected virtual` or `protected abstract`
|
||||
- Include a concrete example in the ApexDoc showing how to extend
|
||||
|
||||
### Custom Exceptions
|
||||
- Read and follow: `templates/exception.cls`
|
||||
- Extend `Exception`
|
||||
- Keep them simple — Apex exceptions don't support custom constructors well
|
||||
- Name them descriptively: `AccountServiceException`, `IntegrationTimeoutException`
|
||||
|
||||
---
|
||||
|
||||
## Generation Workflow
|
||||
|
||||
1. Determine the class type from the user's request
|
||||
2. Read the corresponding template from `templates/`
|
||||
3. Read relevant examples from `examples/` if the class type has one
|
||||
4. Apply the user's requirements to the template pattern
|
||||
5. Generate the `.cls` file with full ApexDoc
|
||||
6. Generate the `.cls-meta.xml` file
|
||||
7. Present both files to the user
|
||||
8. Include a brief note on design decisions if any non-obvious choices were made
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- ❌ SOQL or DML inside loops
|
||||
- ❌ Hardcoded IDs or record type names (use `Schema.SObjectType` or Custom Metadata)
|
||||
- ❌ God classes that mix query + logic + DML
|
||||
- ❌ `public` fields on service classes
|
||||
- ❌ Returning `null` from methods that should return collections
|
||||
- ❌ Generic `catch (Exception e)` without re-throwing or meaningful handling
|
||||
- ❌ Business logic in Batch `start()` methods
|
||||
- ❌ Tight coupling between classes — use interfaces for extensibility
|
||||
- ❌ Magic strings or numbers
|
||||
- ❌ Methods longer than ~40 lines — break them into private helpers
|
||||
@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @description Batch Apex class for identifying and flagging duplicate Account records.
|
||||
* Compares Accounts by Name and BillingPostalCode to find potential duplicates.
|
||||
* Flags duplicates by setting the Is_Potential_Duplicate__c checkbox.
|
||||
* Implements Database.Stateful to track results across batch chunks.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Run with default batch size (200)
|
||||
* Id jobId = AccountDeduplicationBatch.run();
|
||||
*
|
||||
* // Run with smaller batch size for complex processing
|
||||
* Database.executeBatch(new AccountDeduplicationBatch(), 50);
|
||||
*/
|
||||
public with sharing class AccountDeduplicationBatch implements Database.Batchable<SObject>, Database.Stateful {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer DEFAULT_BATCH_SIZE = 200;
|
||||
|
||||
// ─── Stateful Tracking ───────────────────────────────────────────────
|
||||
private Integer totalScanned = 0;
|
||||
private Integer duplicatesFound = 0;
|
||||
private Integer totalErrors = 0;
|
||||
private List<String> errorMessages = new List<String>();
|
||||
|
||||
// ─── Batchable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Queries all active Accounts that haven't already been flagged
|
||||
* @param bc The batch context
|
||||
* @return QueryLocator scoped to unflagged active Accounts
|
||||
*/
|
||||
public Database.QueryLocator start(Database.BatchableContext bc) {
|
||||
return Database.getQueryLocator([
|
||||
SELECT Id, Name, BillingPostalCode, Is_Potential_Duplicate__c
|
||||
FROM Account
|
||||
WHERE IsDeleted = FALSE
|
||||
AND Is_Potential_Duplicate__c = FALSE
|
||||
ORDER BY Name ASC
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Processes each batch by building a duplicate key and checking for matches.
|
||||
* Uses a composite key of normalized Name + BillingPostalCode.
|
||||
* @param bc The batch context
|
||||
* @param scope List of Account records in the current batch
|
||||
*/
|
||||
public void execute(Database.BatchableContext bc, List<Account> scope) {
|
||||
totalScanned += scope.size();
|
||||
|
||||
// Build duplicate keys for this batch
|
||||
Map<String, List<Account>> dupeKeyMap = new Map<String, List<Account>>();
|
||||
for (Account acct : scope) {
|
||||
String key = buildDuplicateKey(acct);
|
||||
if (String.isNotBlank(key)) {
|
||||
if (!dupeKeyMap.containsKey(key)) {
|
||||
dupeKeyMap.put(key, new List<Account>());
|
||||
}
|
||||
dupeKeyMap.get(key).add(acct);
|
||||
}
|
||||
}
|
||||
|
||||
// Flag records that share a key with another record
|
||||
List<Account> toUpdate = new List<Account>();
|
||||
for (String key : dupeKeyMap.keySet()) {
|
||||
List<Account> group = dupeKeyMap.get(key);
|
||||
if (group.size() > 1) {
|
||||
for (Account acct : group) {
|
||||
toUpdate.add(new Account(
|
||||
Id = acct.Id,
|
||||
Is_Potential_Duplicate__c = true
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
List<Database.SaveResult> results = Database.update(toUpdate, false);
|
||||
processResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Logs a summary of the deduplication batch run
|
||||
* @param bc The batch context
|
||||
*/
|
||||
public void finish(Database.BatchableContext bc) {
|
||||
String summary = String.format(
|
||||
'AccountDeduplicationBatch completed. Scanned: {0}, Duplicates flagged: {1}, Errors: {2}',
|
||||
new List<Object>{ totalScanned, duplicatesFound, totalErrors }
|
||||
);
|
||||
|
||||
System.debug(LoggingLevel.INFO, summary);
|
||||
|
||||
if (!errorMessages.isEmpty()) {
|
||||
System.debug(LoggingLevel.ERROR, 'Error details:\n' + String.join(errorMessages, '\n'));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Builds a normalized composite key for duplicate detection
|
||||
* @param acct The Account record
|
||||
* @return Normalized key string, or null if insufficient data
|
||||
*/
|
||||
private String buildDuplicateKey(Account acct) {
|
||||
if (String.isBlank(acct.Name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedName = acct.Name.trim().toUpperCase().replaceAll('\\s+', ' ');
|
||||
String postalCode = (acct.BillingPostalCode ?? '').trim().toUpperCase();
|
||||
|
||||
return normalizedName + '|' + postalCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Processes DML results, tracking successes and failures
|
||||
* @param results List of Database.SaveResult from update operation
|
||||
*/
|
||||
private void processResults(List<Database.SaveResult> results) {
|
||||
for (Database.SaveResult sr : results) {
|
||||
if (sr.isSuccess()) {
|
||||
duplicatesFound++;
|
||||
} else {
|
||||
totalErrors++;
|
||||
for (Database.Error err : sr.getErrors()) {
|
||||
errorMessages.add(
|
||||
'Record ' + sr.getId() + ': ' +
|
||||
err.getStatusCode() + ' - ' + err.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Static Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Convenience method to execute with default batch size
|
||||
* @return The batch job Id
|
||||
*/
|
||||
public static Id run() {
|
||||
return Database.executeBatch(new AccountDeduplicationBatch(), DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
}
|
||||
@ -1,193 +0,0 @@
|
||||
/**
|
||||
* @description Selector class for Account queries.
|
||||
* Encapsulates all SOQL for Account records.
|
||||
* All methods return bulkified results (Lists or Maps).
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class AccountSelector {
|
||||
|
||||
// ─── Field Lists ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Returns the default set of fields to query for Account.
|
||||
* Centralizes field references to keep queries DRY.
|
||||
* @return Comma-separated field list as a String
|
||||
*/
|
||||
private static String getDefaultFields() {
|
||||
return String.join(
|
||||
new List<String>{
|
||||
'Id',
|
||||
'Name',
|
||||
'AccountNumber',
|
||||
'Type',
|
||||
'Industry',
|
||||
'AnnualRevenue',
|
||||
'NumberOfEmployees',
|
||||
'Phone',
|
||||
'Website',
|
||||
'OwnerId',
|
||||
'CreatedDate',
|
||||
'LastModifiedDate'
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Returns fields needed for billing/territory operations
|
||||
* @return Comma-separated field list as a String
|
||||
*/
|
||||
private static String getBillingFields() {
|
||||
return String.join(
|
||||
new List<String>{
|
||||
'Id',
|
||||
'Name',
|
||||
'BillingStreet',
|
||||
'BillingCity',
|
||||
'BillingState',
|
||||
'BillingPostalCode',
|
||||
'BillingCountry',
|
||||
'Territory__c'
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Query Methods ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Selects Account records by their Ids
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return List of Account records matching the provided Ids
|
||||
* @example
|
||||
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
|
||||
* List<Account> results = AccountSelector.selectByIds(ids);
|
||||
*/
|
||||
public static List<Account> selectByIds(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Id IN :recordIds'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Account records as a Map keyed by Id
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return Map of Id to Account
|
||||
*/
|
||||
public static Map<Id, Account> selectMapByIds(Set<Id> recordIds) {
|
||||
return new Map<Id, Account>(selectByIds(recordIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts with billing address fields for territory assignment
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return List of Account records with billing address fields populated
|
||||
*/
|
||||
public static List<Account> selectWithBillingAddress(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getBillingFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Id IN :recordIds'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts by Account Type
|
||||
* @param accountTypes Set of Account Type values to filter by
|
||||
* @return List of matching Account records
|
||||
* @example
|
||||
* List<Account> prospects = AccountSelector.selectByType(
|
||||
* new Set<String>{ 'Prospect', 'Customer - Direct' }
|
||||
* );
|
||||
*/
|
||||
public static List<Account> selectByType(Set<String> accountTypes) {
|
||||
if (accountTypes == null || accountTypes.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Type IN :accountTypes' +
|
||||
' ORDER BY Name ASC'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts by Industry with a minimum annual revenue
|
||||
* @param industries Set of Industry values to filter by
|
||||
* @param minRevenue Minimum AnnualRevenue threshold
|
||||
* @return List of matching Account records ordered by revenue descending
|
||||
* @example
|
||||
* List<Account> techAccounts = AccountSelector.selectByIndustryAndRevenue(
|
||||
* new Set<String>{ 'Technology' },
|
||||
* 1000000
|
||||
* );
|
||||
*/
|
||||
public static List<Account> selectByIndustryAndRevenue(Set<String> industries, Decimal minRevenue) {
|
||||
if (industries == null || industries.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
Decimal revenueThreshold = minRevenue ?? 0;
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Industry IN :industries' +
|
||||
' AND AnnualRevenue >= :revenueThreshold' +
|
||||
' ORDER BY AnnualRevenue DESC'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts with their related Contacts (subquery)
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return List of Account records with nested Contacts
|
||||
*/
|
||||
public static List<Account> selectWithContacts(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return [
|
||||
SELECT Id, Name, Type, Industry,
|
||||
(SELECT Id, FirstName, LastName, Email, Title
|
||||
FROM Contacts
|
||||
ORDER BY LastName ASC)
|
||||
FROM Account
|
||||
WHERE Id IN :recordIds
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Aggregate Queries ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Returns a count of Accounts grouped by Industry
|
||||
* @return List of AggregateResult with Industry and record count
|
||||
* @example
|
||||
* List<AggregateResult> results = AccountSelector.countByIndustry();
|
||||
* for (AggregateResult ar : results) {
|
||||
* System.debug(ar.get('Industry') + ': ' + ar.get('cnt'));
|
||||
* }
|
||||
*/
|
||||
public static List<AggregateResult> countByIndustry() {
|
||||
return [
|
||||
SELECT Industry, COUNT(Id) cnt
|
||||
FROM Account
|
||||
WHERE Industry != NULL
|
||||
GROUP BY Industry
|
||||
ORDER BY COUNT(Id) DESC
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
/**
|
||||
* @description Service class for Account business logic.
|
||||
* Provides account deduplication, enrichment, and territory assignment.
|
||||
* Delegates queries to AccountSelector and SObject manipulation to AccountDomain.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class AccountService {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
|
||||
private static final String ERROR_MERGE_FAILED = 'Account merge failed for master Id: ';
|
||||
private static final Integer MAX_MERGE_BATCH = 3;
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Merges duplicate Account records into a master record.
|
||||
* The master record retains its field values; child records are reparented.
|
||||
* @param masterIds Map of master Account Id to Set of duplicate Account Ids to merge
|
||||
* @return List of master Account Ids that were successfully merged
|
||||
* @throws AccountServiceException if merge processing fails
|
||||
* @example
|
||||
* Map<Id, Set<Id>> mergeMap = new Map<Id, Set<Id>>{
|
||||
* masterAcctId => new Set<Id>{ dupeId1, dupeId2 }
|
||||
* };
|
||||
* List<Id> mergedIds = AccountService.mergeAccounts(mergeMap);
|
||||
*/
|
||||
public static List<Id> mergeAccounts(Map<Id, Set<Id>> mergeMap) {
|
||||
if (mergeMap == null || mergeMap.isEmpty()) {
|
||||
throw new AccountServiceException(ERROR_NULL_INPUT);
|
||||
}
|
||||
|
||||
// Collect all Ids for a single query
|
||||
Set<Id> allIds = new Set<Id>();
|
||||
allIds.addAll(mergeMap.keySet());
|
||||
for (Set<Id> dupeIds : mergeMap.values()) {
|
||||
allIds.addAll(dupeIds);
|
||||
}
|
||||
|
||||
// Query all records at once via Selector
|
||||
Map<Id, Account> accountMap = AccountSelector.selectMapByIds(allIds);
|
||||
|
||||
List<Id> successfulMerges = new List<Id>();
|
||||
List<String> errors = new List<String>();
|
||||
|
||||
for (Id masterId : mergeMap.keySet()) {
|
||||
Account master = accountMap.get(masterId);
|
||||
if (master == null) {
|
||||
errors.add('Master account not found: ' + masterId);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<Account> duplicates = new List<Account>();
|
||||
for (Id dupeId : mergeMap.get(masterId)) {
|
||||
Account dupe = accountMap.get(dupeId);
|
||||
if (dupe != null) {
|
||||
duplicates.add(dupe);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Apex merge supports up to 3 records at a time
|
||||
for (List<Account> chunk : chunkAccounts(duplicates, MAX_MERGE_BATCH)) {
|
||||
Database.merge(master, chunk);
|
||||
}
|
||||
successfulMerges.add(masterId);
|
||||
} catch (DmlException e) {
|
||||
errors.add(ERROR_MERGE_FAILED + masterId + ' - ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
System.debug(LoggingLevel.WARN, 'Merge errors: ' + String.join(errors, '\n'));
|
||||
}
|
||||
|
||||
return successfulMerges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Assigns accounts to territories based on Billing State/Country.
|
||||
* Uses Custom Metadata Type (Territory_Mapping__mdt) for mappings.
|
||||
* @param accountIds Set of Account Ids to assign territories for
|
||||
* @return Number of accounts successfully updated
|
||||
* @throws AccountServiceException if territory assignment fails
|
||||
*/
|
||||
public static Integer assignTerritories(Set<Id> accountIds) {
|
||||
if (accountIds == null || accountIds.isEmpty()) {
|
||||
throw new AccountServiceException(ERROR_NULL_INPUT);
|
||||
}
|
||||
|
||||
List<Account> accounts = AccountSelector.selectWithBillingAddress(accountIds);
|
||||
Map<String, String> territoryMap = loadTerritoryMappings();
|
||||
|
||||
List<Account> toUpdate = new List<Account>();
|
||||
for (Account acct : accounts) {
|
||||
String key = buildTerritoryKey(acct.BillingState, acct.BillingCountry);
|
||||
String territory = territoryMap.get(key);
|
||||
|
||||
if (territory != null && territory != acct.Territory__c) {
|
||||
toUpdate.add(new Account(
|
||||
Id = acct.Id,
|
||||
Territory__c = territory
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
List<Database.SaveResult> results = Database.update(toUpdate, false);
|
||||
return countSuccesses(results);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── Convenience Overloads ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Single-account territory assignment convenience method
|
||||
* @param accountId The Account Id to assign a territory for
|
||||
* @return 1 if updated, 0 if no change needed
|
||||
*/
|
||||
public static Integer assignTerritory(Id accountId) {
|
||||
return assignTerritories(new Set<Id>{ accountId });
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Loads territory mappings from Custom Metadata
|
||||
* @return Map of territory key (State:Country) to territory name
|
||||
*/
|
||||
private static Map<String, String> loadTerritoryMappings() {
|
||||
Map<String, String> mappings = new Map<String, String>();
|
||||
for (Territory_Mapping__mdt mapping : Territory_Mapping__mdt.getAll().values()) {
|
||||
String key = buildTerritoryKey(mapping.State__c, mapping.Country__c);
|
||||
mappings.put(key, mapping.Territory_Name__c);
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds a consistent territory lookup key
|
||||
* @param state The billing state
|
||||
* @param country The billing country
|
||||
* @return A normalized key string
|
||||
*/
|
||||
private static String buildTerritoryKey(String state, String country) {
|
||||
return (state ?? '').toUpperCase() + ':' + (country ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Chunks a list of Accounts into sublists of the given size
|
||||
* @param accounts The accounts to chunk
|
||||
* @param chunkSize Maximum chunk size
|
||||
* @return List of account sublists
|
||||
*/
|
||||
private static List<List<Account>> chunkAccounts(List<Account> accounts, Integer chunkSize) {
|
||||
List<List<Account>> chunks = new List<List<Account>>();
|
||||
List<Account> current = new List<Account>();
|
||||
|
||||
for (Account acct : accounts) {
|
||||
current.add(acct);
|
||||
if (current.size() == chunkSize) {
|
||||
chunks.add(current);
|
||||
current = new List<Account>();
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
chunks.add(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Counts successful results from a DML operation
|
||||
* @param results List of Database.SaveResult
|
||||
* @return Count of successful operations
|
||||
*/
|
||||
private static Integer countSuccesses(List<Database.SaveResult> results) {
|
||||
Integer count = 0;
|
||||
for (Database.SaveResult sr : results) {
|
||||
if (sr.isSuccess()) {
|
||||
count++;
|
||||
} else {
|
||||
for (Database.Error err : sr.getErrors()) {
|
||||
System.debug(LoggingLevel.WARN,
|
||||
'Update failed for ' + sr.getId() + ': ' + err.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for AccountService errors
|
||||
*/
|
||||
public class AccountServiceException extends Exception {}
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
/**
|
||||
* @description Abstract base class for {describe the family of classes this serves}.
|
||||
* Provides common behavior and defines extension points for subclasses.
|
||||
* Subclasses must implement the abstract methods to provide specific behavior.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Extending this abstract class:
|
||||
* public class SalesforceIntegrationService extends {ClassName} {
|
||||
* protected override String getEndpoint() {
|
||||
* return 'callout:Salesforce_API/services/data/v62.0';
|
||||
* }
|
||||
*
|
||||
* protected override Map<String, String> getHeaders() {
|
||||
* return new Map<String, String>{
|
||||
* 'Content-Type' => 'application/json'
|
||||
* };
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public abstract with sharing class {ClassName} {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
// ─── Protected State ─────────────────────────────────────────────────
|
||||
protected Integer timeoutMs;
|
||||
|
||||
// ─── Constructor ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Initializes the base class with default configuration
|
||||
*/
|
||||
protected {ClassName}() {
|
||||
this.timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ─── Abstract Methods (must be implemented by subclasses) ────────────
|
||||
|
||||
/**
|
||||
* @description Returns the endpoint URL for this integration.
|
||||
* Subclasses must provide their specific endpoint.
|
||||
* @return The endpoint URL as a String
|
||||
*/
|
||||
protected abstract String getEndpoint();
|
||||
|
||||
/**
|
||||
* @description Returns the HTTP headers for this integration.
|
||||
* Subclasses define their own required headers.
|
||||
* @return Map of header name to header value
|
||||
*/
|
||||
protected abstract Map<String, String> getHeaders();
|
||||
|
||||
// ─── Virtual Methods (can be overridden by subclasses) ───────────────
|
||||
|
||||
/**
|
||||
* @description Hook called before the main operation executes.
|
||||
* Override to add pre-processing logic.
|
||||
* Default implementation does nothing.
|
||||
* @param context Map of contextual data
|
||||
*/
|
||||
protected virtual void beforeExecute(Map<String, Object> context) {
|
||||
// Default: no-op — override in subclass if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Hook called after the main operation completes.
|
||||
* Override to add post-processing logic.
|
||||
* Default implementation does nothing.
|
||||
* @param context Map of contextual data
|
||||
* @param result The result from the operation
|
||||
*/
|
||||
protected virtual void afterExecute(Map<String, Object> context, Object result) {
|
||||
// Default: no-op — override in subclass if needed
|
||||
}
|
||||
|
||||
// ─── Template Method (common workflow) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Executes the operation using the template method pattern.
|
||||
* Calls beforeExecute → doExecute → afterExecute in sequence.
|
||||
* @param context Map of data needed for the operation
|
||||
* @return The result of the operation
|
||||
*/
|
||||
public Object execute(Map<String, Object> context) {
|
||||
beforeExecute(context);
|
||||
|
||||
Object result;
|
||||
try {
|
||||
result = doExecute(context);
|
||||
} catch (Exception e) {
|
||||
handleError(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
afterExecute(context, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Protected Helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Core execution logic — override this for the main operation.
|
||||
* Default implementation throws — subclass must provide implementation.
|
||||
* @param context Map of data needed for the operation
|
||||
* @return The result of the operation
|
||||
*/
|
||||
protected virtual Object doExecute(Map<String, Object> context) {
|
||||
throw new UnsupportedOperationException(
|
||||
'Subclass must override doExecute()'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Error handler called when doExecute throws.
|
||||
* Override to customize error handling (e.g., logging, retry).
|
||||
* @param e The exception that was thrown
|
||||
*/
|
||||
protected virtual void handleError(Exception e) {
|
||||
System.debug(LoggingLevel.ERROR,
|
||||
this.toString() + ' error: ' + e.getMessage() + '\n' + e.getStackTraceString()
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
public class UnsupportedOperationException extends Exception {}
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
/**
|
||||
* @description Batch Apex class for {describe the batch operation}.
|
||||
* Processes {SObject} records in configurable batch sizes.
|
||||
* Implements Database.Stateful to track cumulative results across chunks.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Execute with default batch size
|
||||
* Database.executeBatch(new {ClassName}());
|
||||
*
|
||||
* // Execute with custom batch size
|
||||
* Database.executeBatch(new {ClassName}(), 100);
|
||||
*/
|
||||
public with sharing class {ClassName} implements Database.Batchable<SObject>, Database.Stateful {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer DEFAULT_BATCH_SIZE = 200;
|
||||
|
||||
// ─── Stateful Tracking ───────────────────────────────────────────────
|
||||
private Integer totalProcessed = 0;
|
||||
private Integer totalErrors = 0;
|
||||
private List<String> errorMessages = new List<String>();
|
||||
|
||||
// ─── Constructor ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Default constructor
|
||||
*/
|
||||
public {ClassName}() {
|
||||
// Default configuration
|
||||
}
|
||||
|
||||
// ─── Batchable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Defines the scope of records to process.
|
||||
* Uses Database.QueryLocator for efficient large-dataset processing.
|
||||
* @param bc The batch context
|
||||
* @return QueryLocator for the records to process
|
||||
*/
|
||||
public Database.QueryLocator start(Database.BatchableContext bc) {
|
||||
return Database.getQueryLocator([
|
||||
SELECT Id, Name
|
||||
// TODO: Add fields needed for processing
|
||||
FROM {SObject}
|
||||
// TODO: Add WHERE clause to scope the records
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Processes each batch of records.
|
||||
* Uses Database.update with allOrNone=false for partial success handling.
|
||||
* @param bc The batch context
|
||||
* @param scope List of {SObject} records in the current batch
|
||||
*/
|
||||
public void execute(Database.BatchableContext bc, List<{SObject}> scope) {
|
||||
List<{SObject}> recordsToUpdate = new List<{SObject}>();
|
||||
|
||||
for ({SObject} record : scope) {
|
||||
// TODO: Apply business logic to each record
|
||||
recordsToUpdate.add(record);
|
||||
}
|
||||
|
||||
if (!recordsToUpdate.isEmpty()) {
|
||||
List<Database.SaveResult> results = Database.update(recordsToUpdate, false);
|
||||
processResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Performs post-processing after all batches complete.
|
||||
* Logs a summary of the batch execution.
|
||||
* @param bc The batch context
|
||||
*/
|
||||
public void finish(Database.BatchableContext bc) {
|
||||
String summary = String.format(
|
||||
'{0} completed. Processed: {1}, Errors: {2}',
|
||||
new List<Object>{
|
||||
{ClassName}.class.getName(),
|
||||
totalProcessed,
|
||||
totalErrors
|
||||
}
|
||||
);
|
||||
|
||||
System.debug(LoggingLevel.INFO, summary);
|
||||
|
||||
if (!errorMessages.isEmpty()) {
|
||||
System.debug(LoggingLevel.ERROR, 'Error details: ' + String.join(errorMessages, '\n'));
|
||||
}
|
||||
|
||||
// TODO: Send completion notification email or post to chatter if needed
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Processes Database.SaveResult list, tracking successes and failures
|
||||
* @param results List of SaveResult from a DML operation
|
||||
*/
|
||||
private void processResults(List<Database.SaveResult> results) {
|
||||
for (Database.SaveResult result : results) {
|
||||
if (result.isSuccess()) {
|
||||
totalProcessed++;
|
||||
} else {
|
||||
totalErrors++;
|
||||
for (Database.Error err : result.getErrors()) {
|
||||
errorMessages.add(
|
||||
'Record ' + result.getId() + ': ' +
|
||||
err.getStatusCode() + ' - ' + err.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Static Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Convenience method to execute with default batch size
|
||||
* @return The batch job Id
|
||||
*/
|
||||
public static Id run() {
|
||||
return Database.executeBatch(new {ClassName}(), DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @description Domain class for {SObject}.
|
||||
* Encapsulates field-level defaults, derivations, and validations.
|
||||
* Operates only on in-memory SObject data — no SOQL or DML.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Domain {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
// TODO: Add constants for default values, statuses, etc.
|
||||
|
||||
// ─── Field Defaults ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Applies default field values to new {SObject} records.
|
||||
* Call this before insert to ensure consistent defaults.
|
||||
* @param records List of {SObject} records to apply defaults to
|
||||
*/
|
||||
public static void applyDefaults(List<{SObject}> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for ({SObject} record : records) {
|
||||
// TODO: Set default field values
|
||||
// Example:
|
||||
// if (record.Status__c == null) {
|
||||
// record.Status__c = DEFAULT_STATUS;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Derivations ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Derives calculated field values based on other fields.
|
||||
* Call this before insert and before update.
|
||||
* @param records List of {SObject} records to derive values for
|
||||
*/
|
||||
public static void deriveFields(List<{SObject}> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for ({SObject} record : records) {
|
||||
// TODO: Derive calculated field values
|
||||
// Example:
|
||||
// record.FullAddress__c = buildFullAddress(record);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validations ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Validates {SObject} records and adds errors for any violations.
|
||||
* Call this before insert and before update.
|
||||
* @param records List of {SObject} records to validate
|
||||
*/
|
||||
public static void validate(List<{SObject}> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for ({SObject} record : records) {
|
||||
// TODO: Add validation rules
|
||||
// Example:
|
||||
// if (String.isBlank(record.Name)) {
|
||||
// record.addError('Name is required.');
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comparisons ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Determines which fields have changed between old and new record versions.
|
||||
* Useful in before update context.
|
||||
* @param oldRecord The previous version of the record
|
||||
* @param newRecord The current version of the record
|
||||
* @return Set of field API names that have changed
|
||||
*/
|
||||
public static Set<String> getChangedFields({SObject} oldRecord, {SObject} newRecord) {
|
||||
Set<String> changedFields = new Set<String>();
|
||||
|
||||
if (oldRecord == null || newRecord == null) {
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.{SObject}.fields.getMap();
|
||||
for (String fieldName : fieldMap.keySet()) {
|
||||
if (oldRecord.get(fieldName) != newRecord.get(fieldName)) {
|
||||
changedFields.add(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add private helper methods as needed
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
/**
|
||||
* @description Data Transfer Object for {describe the data this DTO represents}.
|
||||
* Used to pass structured data between layers without exposing SObjects.
|
||||
* Serialization-friendly for use with JSON.serialize/deserialize and API responses.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Create from constructor
|
||||
* {ClassName} dto = new {ClassName}('value1', 42);
|
||||
*
|
||||
* // Deserialize from JSON
|
||||
* {ClassName} dto = ({ClassName}) JSON.deserialize(jsonString, {ClassName}.class);
|
||||
*/
|
||||
public with sharing class {ClassName} {
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────────────────
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public String name { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public Id recordId { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public Boolean isActive { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public List<String> tags { get; set; }
|
||||
|
||||
// TODO: Add additional properties as needed
|
||||
|
||||
// ─── Constructors ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description No-arg constructor for deserialization compatibility
|
||||
*/
|
||||
public {ClassName}() {
|
||||
this.tags = new List<String>();
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parameterized constructor for convenience
|
||||
* @param name The name value
|
||||
* @param recordId The associated record Id
|
||||
*/
|
||||
public {ClassName}(String name, Id recordId) {
|
||||
this();
|
||||
this.name = name;
|
||||
this.recordId = recordId;
|
||||
}
|
||||
|
||||
// ─── Factory Methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Creates a DTO instance from an SObject record
|
||||
* @param record The source {SObject} record
|
||||
* @return A populated {ClassName} instance
|
||||
*/
|
||||
public static {ClassName} fromSObject(SObject record) {
|
||||
if (record == null) {
|
||||
return new {ClassName}();
|
||||
}
|
||||
|
||||
{ClassName} dto = new {ClassName}();
|
||||
dto.recordId = record.Id;
|
||||
dto.name = (String) record.get('Name');
|
||||
// TODO: Map additional fields
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a list of DTOs from a list of SObject records
|
||||
* @param records The source records
|
||||
* @return List of populated {ClassName} instances
|
||||
*/
|
||||
public static List<{ClassName}> fromSObjects(List<SObject> records) {
|
||||
List<{ClassName}> dtos = new List<{ClassName}>();
|
||||
if (records == null) {
|
||||
return dtos;
|
||||
}
|
||||
|
||||
for (SObject record : records) {
|
||||
dtos.add(fromSObject(record));
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
// ─── Utility Methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Serializes this DTO to a JSON string
|
||||
* @return JSON representation of this DTO
|
||||
*/
|
||||
public String toJson() {
|
||||
return JSON.serialize(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deserializes a JSON string into a {ClassName} instance
|
||||
* @param jsonString The JSON string to deserialize
|
||||
* @return A {ClassName} instance
|
||||
*/
|
||||
public static {ClassName} fromJson(String jsonString) {
|
||||
return ({ClassName}) JSON.deserialize(jsonString, {ClassName}.class);
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
/**
|
||||
* @description Custom exception for {describe when this exception is thrown}.
|
||||
* Use this exception to signal domain-specific errors that callers
|
||||
* can catch and handle distinctly from system exceptions.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* throw new {ClassName}('Account merge failed: duplicate detected.');
|
||||
*
|
||||
* // Wrap a caught exception
|
||||
* try {
|
||||
* // ... risky operation
|
||||
* } catch (DmlException e) {
|
||||
* throw new {ClassName}('DML failed during account merge: ' + e.getMessage());
|
||||
* }
|
||||
*/
|
||||
public with sharing class {ClassName} extends Exception {
|
||||
// Apex custom exceptions automatically inherit:
|
||||
// - getMessage()
|
||||
// - getCause()
|
||||
// - getStackTraceString()
|
||||
// - setMessage(String)
|
||||
// - initCause(Exception)
|
||||
//
|
||||
// And support these constructor patterns:
|
||||
// - new {ClassName}()
|
||||
// - new {ClassName}('message')
|
||||
// - new {ClassName}(causeException)
|
||||
// - new {ClassName}('message', causeException)
|
||||
//
|
||||
// Note: Apex does NOT support custom constructors on Exception subclasses.
|
||||
// To add context, use the message string or create a wrapper pattern:
|
||||
//
|
||||
// Example wrapper pattern (if you need structured error data):
|
||||
//
|
||||
// public class {ClassName}Detail {
|
||||
// public String errorCode;
|
||||
// public List<Id> failedRecordIds;
|
||||
// public String detail;
|
||||
//
|
||||
// public {ClassName}Detail(String errorCode, List<Id> failedRecordIds, String detail) {
|
||||
// this.errorCode = errorCode;
|
||||
// this.failedRecordIds = failedRecordIds;
|
||||
// this.detail = detail;
|
||||
// }
|
||||
//
|
||||
// public override String toString() {
|
||||
// return '[' + errorCode + '] ' + detail + ' (Records: ' + failedRecordIds + ')';
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @description Interface for {describe the capability or contract this interface defines}.
|
||||
* Implement this interface to provide {describe what implementations do}.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* public class EmailNotificationService implements {InterfaceName} {
|
||||
* public void execute(Map<String, Object> params) {
|
||||
* // Implementation
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public interface {InterfaceName} {
|
||||
|
||||
/**
|
||||
* @description {Describe what this method should do}
|
||||
* @param params {Describe the parameter}
|
||||
* @return {Describe the return value}
|
||||
*/
|
||||
// TODO: Define interface methods
|
||||
// Example:
|
||||
// void execute(Map<String, Object> params);
|
||||
// Boolean isEligible(SObject record);
|
||||
// List<SObject> process(List<SObject> records);
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
/**
|
||||
* @description Queueable Apex class for {describe the async operation}.
|
||||
* Accepts data through the constructor for stateful processing.
|
||||
* Optionally implements Database.AllowsCallouts for external integrations.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Enqueue the job
|
||||
* Id jobId = System.enqueueJob(new {ClassName}(recordIds));
|
||||
*/
|
||||
public with sharing class {ClassName} implements Queueable /*, Database.AllowsCallouts */ {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer MAX_CHAIN_DEPTH = 5;
|
||||
|
||||
// ─── Instance Variables (Stateful) ───────────────────────────────────
|
||||
private Set<Id> recordIds;
|
||||
private Integer chainDepth;
|
||||
|
||||
// ─── Constructors ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Creates a new queueable job to process the specified records
|
||||
* @param recordIds Set of record Ids to process
|
||||
*/
|
||||
public {ClassName}(Set<Id> recordIds) {
|
||||
this(recordIds, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a new queueable job with chain depth tracking
|
||||
* @param recordIds Set of record Ids to process
|
||||
* @param chainDepth Current depth in the queueable chain
|
||||
*/
|
||||
public {ClassName}(Set<Id> recordIds, Integer chainDepth) {
|
||||
this.recordIds = recordIds ?? new Set<Id>();
|
||||
this.chainDepth = chainDepth;
|
||||
}
|
||||
|
||||
// ─── Queueable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Executes the asynchronous work
|
||||
* @param context The queueable context
|
||||
*/
|
||||
public void execute(QueueableContext context) {
|
||||
if (this.recordIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement the async processing logic
|
||||
// List<{SObject}> records = {SObject}Selector.selectByIds(this.recordIds);
|
||||
// ... process records ...
|
||||
|
||||
// Chain to next job if there's more work and we haven't hit the depth limit
|
||||
chainIfNeeded();
|
||||
|
||||
} catch (Exception e) {
|
||||
handleError(context.getJobId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Chains to the next queueable job if needed, with depth guard
|
||||
*/
|
||||
private void chainIfNeeded() {
|
||||
// TODO: Determine if chaining is needed (e.g., remaining records to process)
|
||||
Set<Id> remainingIds = new Set<Id>();
|
||||
|
||||
if (!remainingIds.isEmpty() && this.chainDepth < MAX_CHAIN_DEPTH) {
|
||||
if (!Test.isRunningTest()) {
|
||||
System.enqueueJob(new {ClassName}(remainingIds, this.chainDepth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handles errors during execution
|
||||
* @param jobId The async job Id
|
||||
* @param e The exception that occurred
|
||||
*/
|
||||
private void handleError(Id jobId, Exception e) {
|
||||
System.debug(LoggingLevel.ERROR,
|
||||
'{ClassName} failed (Job: ' + jobId + '): ' +
|
||||
e.getMessage() + '\n' + e.getStackTraceString()
|
||||
);
|
||||
// TODO: Persist error to a log object or send notification
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @description Schedulable Apex class for {describe the scheduled operation}.
|
||||
* Delegates heavy processing to a Batch or Queueable job.
|
||||
* Keep execute() lightweight — it should only launch other jobs.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Schedule to run daily at 2 AM
|
||||
* String jobId = System.schedule(
|
||||
* '{ClassName} - Daily',
|
||||
* {ClassName}.CRON_DAILY_2AM,
|
||||
* new {ClassName}()
|
||||
* );
|
||||
*
|
||||
* // Or use the convenience method
|
||||
* String jobId = {ClassName}.scheduleDaily();
|
||||
*/
|
||||
public with sharing class {ClassName} implements Schedulable {
|
||||
|
||||
// ─── CRON Expressions ────────────────────────────────────────────────
|
||||
// Seconds Minutes Hours Day_of_month Month Day_of_week Optional_year
|
||||
|
||||
/** @description Runs daily at 2:00 AM */
|
||||
public static final String CRON_DAILY_2AM = '0 0 2 * * ?';
|
||||
|
||||
/** @description Runs every weekday at 6:00 AM */
|
||||
public static final String CRON_WEEKDAYS_6AM = '0 0 6 ? * MON-FRI';
|
||||
|
||||
/** @description Runs hourly at the top of the hour */
|
||||
public static final String CRON_HOURLY = '0 0 * * * ?';
|
||||
|
||||
// ─── Schedulable Interface ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Entry point for the scheduled execution.
|
||||
* Delegates to a Batch or Queueable for the actual work.
|
||||
* @param sc The schedulable context
|
||||
*/
|
||||
public void execute(SchedulableContext sc) {
|
||||
// Option A: Launch a Batch job
|
||||
// Database.executeBatch(new {BatchClassName}(), 200);
|
||||
|
||||
// Option B: Launch a Queueable job
|
||||
// System.enqueueJob(new {QueueableClassName}(params));
|
||||
|
||||
// TODO: Implement delegation to appropriate async job
|
||||
}
|
||||
|
||||
// ─── Convenience Scheduling Methods ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Schedules this job to run daily at 2 AM
|
||||
* @return The scheduled job Id
|
||||
*/
|
||||
public static String scheduleDaily() {
|
||||
return System.schedule(
|
||||
'{ClassName} - Daily 2AM',
|
||||
CRON_DAILY_2AM,
|
||||
new {ClassName}()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Aborts this scheduled job by name
|
||||
* @param jobName The name used when scheduling
|
||||
*/
|
||||
public static void abort(String jobName) {
|
||||
for (CronTrigger ct : [
|
||||
SELECT Id FROM CronTrigger
|
||||
WHERE CronJobDetail.Name = :jobName
|
||||
]) {
|
||||
System.abortJob(ct.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
/**
|
||||
* @description Selector class for {SObject} queries.
|
||||
* Encapsulates all SOQL for {SObject} records.
|
||||
* All methods return bulkified results (Lists or Maps).
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Selector {
|
||||
|
||||
// ─── Field Lists ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Returns the default set of fields to query for {SObject}.
|
||||
* Centralizes field references to keep queries DRY.
|
||||
* @return Comma-separated field list as a String
|
||||
*/
|
||||
private static String getDefaultFields() {
|
||||
return String.join(
|
||||
new List<String>{
|
||||
'Id',
|
||||
'Name',
|
||||
'CreatedDate',
|
||||
'LastModifiedDate'
|
||||
// TODO: Add additional fields here
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Query Methods ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Selects {SObject} records by their Ids
|
||||
* @param recordIds Set of {SObject} Ids to query
|
||||
* @return List of {SObject} records matching the provided Ids
|
||||
* @example
|
||||
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
|
||||
* List<{SObject}> results = {SObject}Selector.selectByIds(ids);
|
||||
*/
|
||||
public static List<{SObject}> selectByIds(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<{SObject}>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM {SObject}' +
|
||||
' WHERE Id IN :recordIds'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects {SObject} records as a Map keyed by Id
|
||||
* @param recordIds Set of {SObject} Ids to query
|
||||
* @return Map of Id to {SObject}
|
||||
*/
|
||||
public static Map<Id, {SObject}> selectMapByIds(Set<Id> recordIds) {
|
||||
return new Map<Id, {SObject}>(selectByIds(recordIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects {SObject} records by a specific field value
|
||||
* @param fieldName API name of the field to filter on
|
||||
* @param values Set of values to match
|
||||
* @return List of matching {SObject} records
|
||||
* @example
|
||||
* List<{SObject}> results = {SObject}Selector.selectByField('Status__c', new Set<String>{ 'Active' });
|
||||
*/
|
||||
public static List<{SObject}> selectByField(String fieldName, Set<String> values) {
|
||||
if (String.isBlank(fieldName) || values == null || values.isEmpty()) {
|
||||
return new List<{SObject}>();
|
||||
}
|
||||
|
||||
// Validate field name to prevent SOQL injection
|
||||
Schema.SObjectField field = Schema.SObjectType.{SObject}.fields.getMap().get(fieldName);
|
||||
if (field == null) {
|
||||
throw new QueryException('Invalid field name: ' + fieldName);
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM {SObject}' +
|
||||
' WHERE ' + String.escapeSingleQuotes(fieldName) + ' IN :values'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for query errors
|
||||
*/
|
||||
public class QueryException extends Exception {}
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
/**
|
||||
* @description Service class for {SObject} business logic.
|
||||
* Follows separation of concerns: delegates queries to {SObject}Selector
|
||||
* and SObject manipulation to {SObject}Domain where applicable.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Service {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description {Describe the primary operation}
|
||||
* @param recordIds Set of {SObject} Ids to process
|
||||
* @return List of processed {SObject} records
|
||||
* @throws {SObject}ServiceException if processing fails
|
||||
* @example
|
||||
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
|
||||
* List<{SObject}> results = {SObject}Service.process{SObject}s(ids);
|
||||
*/
|
||||
public static List<{SObject}> process{SObject}s(Set<Id> recordIds) {
|
||||
// Guard clause
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
throw new {SObject}ServiceException(ERROR_NULL_INPUT);
|
||||
}
|
||||
|
||||
// Query via Selector
|
||||
List<{SObject}> records = {SObject}Selector.selectByIds(recordIds);
|
||||
|
||||
// Apply business logic
|
||||
// TODO: Implement business logic here
|
||||
|
||||
// DML
|
||||
try {
|
||||
update records;
|
||||
} catch (DmlException e) {
|
||||
throw new {SObject}ServiceException(
|
||||
'Failed to update {SObject} records: ' + e.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ─── Convenience Overloads ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Single-record convenience overload
|
||||
* @param recordId The {SObject} Id to process
|
||||
* @return The processed {SObject} record
|
||||
*/
|
||||
public static {SObject} process{SObject}(Id recordId) {
|
||||
List<{SObject}> results = process{SObject}s(new Set<Id>{ recordId });
|
||||
return results.isEmpty() ? null : results[0];
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add private helper methods as needed
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for {SObject}Service errors
|
||||
*/
|
||||
public class {SObject}ServiceException extends Exception {}
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
/**
|
||||
* @description Utility class for {describe the category of utilities: String, Date, Collection, etc.}.
|
||||
* All methods are static and side-effect-free (no SOQL, no DML).
|
||||
* Private constructor prevents instantiation.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {ClassName} {
|
||||
|
||||
// ─── Private Constructor ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Prevents instantiation — use static methods only
|
||||
*/
|
||||
@TestVisible
|
||||
private {ClassName}() {
|
||||
// Utility class — do not instantiate
|
||||
}
|
||||
|
||||
// ─── Public Methods ──────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add utility methods below. Examples:
|
||||
|
||||
/**
|
||||
* @description Safely converts a String to an Integer, returning a default if parsing fails
|
||||
* @param value The String to parse
|
||||
* @param defaultValue The fallback value if parsing fails
|
||||
* @return The parsed Integer or the default value
|
||||
* @example
|
||||
* Integer result = {ClassName}.safeParseInteger('42', 0); // returns 42
|
||||
* Integer result = {ClassName}.safeParseInteger('abc', 0); // returns 0
|
||||
*/
|
||||
public static Integer safeParseInteger(String value, Integer defaultValue) {
|
||||
if (String.isBlank(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.trim());
|
||||
} catch (TypeException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Chunks a list into smaller sublists of the specified size.
|
||||
* Useful for processing records in governor-limit-safe batches.
|
||||
* @param items The list to chunk
|
||||
* @param chunkSize The maximum size of each chunk
|
||||
* @return A list of sublists, each containing up to chunkSize elements
|
||||
* @example
|
||||
* List<List<String>> chunks = {ClassName}.chunkList(myList, 200);
|
||||
*/
|
||||
public static List<List<Object>> chunkList(List<Object> items, Integer chunkSize) {
|
||||
List<List<Object>> chunks = new List<List<Object>>();
|
||||
if (items == null || items.isEmpty() || chunkSize <= 0) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
List<Object> currentChunk = new List<Object>();
|
||||
for (Object item : items) {
|
||||
currentChunk.add(item);
|
||||
if (currentChunk.size() == chunkSize) {
|
||||
chunks.add(currentChunk);
|
||||
currentChunk = new List<Object>();
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentChunk.isEmpty()) {
|
||||
chunks.add(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Extracts a Set of non-null field values from a list of SObjects
|
||||
* @param records The SObject records to extract from
|
||||
* @param fieldName The API name of the field to extract
|
||||
* @return A Set of non-null String values
|
||||
* @example
|
||||
* Set<String> emails = {ClassName}.pluckStrings(contacts, 'Email');
|
||||
*/
|
||||
public static Set<String> pluckStrings(List<SObject> records, String fieldName) {
|
||||
Set<String> values = new Set<String>();
|
||||
if (records == null || String.isBlank(fieldName)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
for (SObject record : records) {
|
||||
Object val = record.get(fieldName);
|
||||
if (val != null) {
|
||||
values.add(String.valueOf(val));
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
---
|
||||
name: apex-test-class
|
||||
description: Apex test class generation with TestDataFactory patterns, bulk testing (200+ records), mocking strategies for callouts and async operations, and assertion best practices. Use when creating new Apex test classes, improving test coverage, refactoring existing tests, or implementing proper testing patterns for triggers, services, controllers, batch jobs, queueables, and integrations.
|
||||
---
|
||||
|
||||
# Apex Test Class Skill
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Bulkify tests** - Always test with 200+ records to catch governor limit issues
|
||||
2. **Isolate test data** - Use `@TestSetup` and TestDataFactory; never rely on org data
|
||||
3. **Assert meaningfully** - Test behavior, not just coverage; include failure messages
|
||||
4. **Mock external dependencies** - Use `HttpCalloutMock`, `Test.setMock()` for integrations
|
||||
5. **Test negative paths** - Validate error handling, not just happy paths
|
||||
|
||||
## Test Class Structure
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private class MyServiceTest {
|
||||
|
||||
@TestSetup
|
||||
static void setupTestData() {
|
||||
// Create shared test data using TestDataFactory
|
||||
List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void shouldPerformExpectedBehavior_WhenValidInput() {
|
||||
// Given: Setup specific test state
|
||||
List<Account> accounts = [SELECT Id, Name FROM Account];
|
||||
|
||||
// When: Execute the code under test
|
||||
Test.startTest();
|
||||
MyService.processAccounts(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Assert expected outcomes
|
||||
List<Account> updated = [SELECT Id, Status__c FROM Account];
|
||||
System.Assert.areEqual(200, updated.size(), 'All accounts should be processed');
|
||||
for (Account acc : updated) {
|
||||
System.Assert.areEqual('Processed', acc.Status__c, 'Status should be updated');
|
||||
}
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void shouldThrowException_WhenInvalidInput() {
|
||||
// Given
|
||||
List<Account> emptyList = new List<Account>();
|
||||
|
||||
// When/Then
|
||||
Test.startTest();
|
||||
try {
|
||||
MyService.processAccounts(emptyList);
|
||||
System.Assert.fail('Expected MyCustomException to be thrown');
|
||||
} catch (MyCustomException e) {
|
||||
System.Assert.isTrue(e.getMessage().contains('cannot be empty'),
|
||||
'Exception message should indicate empty input');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
Use descriptive method names: `should[ExpectedBehavior]_When[Condition]`
|
||||
|
||||
Examples:
|
||||
- `shouldCreateContact_WhenAccountIsActive`
|
||||
- `shouldThrowException_WhenEmailIsInvalid`
|
||||
- `shouldSendNotification_WhenOpportunityClosedWon`
|
||||
- `shouldBypassTrigger_WhenRunningAsBatch`
|
||||
|
||||
## Test.startTest() / Test.stopTest()
|
||||
|
||||
Always wrap the code under test:
|
||||
- Resets governor limits for accurate limit testing
|
||||
- Executes async operations synchronously (queueables, batch, future)
|
||||
- Fires scheduled jobs immediately
|
||||
|
||||
## Reference Files
|
||||
|
||||
Detailed patterns for specific scenarios:
|
||||
|
||||
- **[references/test-data-factory.md](references/test-data-factory.md)** - TestDataFactory class patterns and field defaults
|
||||
- **[references/assertion-patterns.md](references/assertion-patterns.md)** - Assertion best practices and common pitfalls
|
||||
- **[references/mocking-patterns.md](references/mocking-patterns.md)** - HttpCalloutMock, Test.setMock(), stubbing
|
||||
- **[references/async-testing.md](references/async-testing.md)** - Batch, Queueable, Future, Scheduled job testing
|
||||
|
||||
## Quick Reference: What to Test
|
||||
|
||||
| Component | Key Test Scenarios |
|
||||
|-----------|-------------------|
|
||||
| Trigger | Bulk insert/update/delete, recursion, field changes |
|
||||
| Service | Valid/invalid inputs, bulk operations, exceptions |
|
||||
| Controller | Page load, action methods, view state |
|
||||
| Batch | Start/execute/finish, chunking, error records |
|
||||
| Queueable | Chaining, bulkification, error handling |
|
||||
| Callout | Success response, error response, timeout |
|
||||
| Scheduled | Execution, CRON validation |
|
||||
@ -1,209 +0,0 @@
|
||||
# Assertion Patterns
|
||||
|
||||
## Assertion Methods
|
||||
|
||||
The `System.Assert` class provides methods to assert various conditions in test methods. All methods support an optional message parameter for better error reporting.
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `System.Assert.areEqual(expected, actual, msg)` | Exact equality |
|
||||
| `System.Assert.areNotEqual(notExpected, actual, msg)` | Value should differ |
|
||||
| `System.Assert.isTrue(condition, msg)` | Boolean condition is true |
|
||||
| `System.Assert.isFalse(condition, msg)` | Boolean condition is false |
|
||||
| `System.Assert.isNull(value, msg)` | Value is null |
|
||||
| `System.Assert.isNotNull(value, msg)` | Value is not null |
|
||||
| `System.Assert.isInstanceOfType(instance, expectedType, msg)` | Instance is of specified type |
|
||||
| `System.Assert.isNotInstanceOfType(instance, notExpectedType, msg)` | Instance is not of specified type |
|
||||
| `System.Assert.fail(msg)` | Explicitly fail the test |
|
||||
|
||||
**Always include the message parameter** - Makes test failures meaningful and easier to debug.
|
||||
|
||||
**Note:** Assertion failures are fatal errors that halt code execution. You cannot catch assertion failures using try/catch blocks, even though they're logged as exceptions.
|
||||
|
||||
|
||||
**Note:** Call `startTest()` and `stopTest()` only once per test method. Wrap only the code under test between these calls, not setup or verification code.
|
||||
|
||||
## Good vs Bad Assertions
|
||||
|
||||
### ❌ Bad: No message, tests coverage not behavior
|
||||
|
||||
```apex
|
||||
System.Assert.areEqual(true, result);
|
||||
System.Assert.isTrue(accounts.size() > 0);
|
||||
```
|
||||
|
||||
### ✅ Good: Descriptive message, tests specific behavior
|
||||
|
||||
```apex
|
||||
System.Assert.areEqual(true, result, 'Service should return true for valid input');
|
||||
System.Assert.areEqual(200, accounts.size(), 'All 200 accounts should be processed');
|
||||
```
|
||||
|
||||
## Common Assertion Patterns
|
||||
|
||||
### Collection Size
|
||||
|
||||
```apex
|
||||
// Exact count
|
||||
System.Assert.areEqual(200, results.size(), 'Should process all 200 records');
|
||||
|
||||
// Not empty
|
||||
System.Assert.isFalse(results.isEmpty(), 'Results should not be empty');
|
||||
|
||||
// Empty
|
||||
System.Assert.isTrue(results.isEmpty(), 'No results expected for invalid input');
|
||||
```
|
||||
|
||||
### Field Values
|
||||
|
||||
```apex
|
||||
// Single record
|
||||
System.Assert.areEqual('Processed', acc.Status__c, 'Account status should be updated to Processed');
|
||||
|
||||
// All records in collection
|
||||
for (Account acc : updatedAccounts) {
|
||||
System.Assert.areEqual('Active', acc.Status__c,
|
||||
'Account ' + acc.Name + ' should have Active status');
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Testing
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldThrowException_WhenInputInvalid() {
|
||||
Boolean exceptionThrown = false;
|
||||
String exceptionMessage = '';
|
||||
|
||||
Test.startTest();
|
||||
try {
|
||||
MyService.process(null);
|
||||
} catch (MyCustomException e) {
|
||||
exceptionThrown = true;
|
||||
exceptionMessage = e.getMessage();
|
||||
}
|
||||
Test.stopTest();
|
||||
|
||||
System.Assert.isTrue(exceptionThrown, 'MyCustomException should be thrown for null input');
|
||||
System.Assert.isTrue(exceptionMessage.contains('cannot be null'),
|
||||
'Exception message should mention null input');
|
||||
}
|
||||
```
|
||||
|
||||
### DML Results
|
||||
|
||||
```apex
|
||||
// Insert success
|
||||
Database.SaveResult[] results = Database.insert(accounts, false);
|
||||
for (Database.SaveResult sr : results) {
|
||||
System.Assert.isTrue(sr.isSuccess(), 'Insert should succeed: ' + sr.getErrors());
|
||||
}
|
||||
|
||||
// Expected failures
|
||||
Database.SaveResult sr = Database.insert(invalidAccount, false);
|
||||
System.Assert.isFalse(sr.isSuccess(), 'Insert should fail for invalid data');
|
||||
System.Assert.isTrue(sr.getErrors()[0].getMessage().contains('REQUIRED_FIELD_MISSING'),
|
||||
'Error should indicate missing required field');
|
||||
```
|
||||
|
||||
### Comparing Objects
|
||||
|
||||
```apex
|
||||
// Compare specific fields, not entire objects
|
||||
System.Assert.areEqual(expected.Name, actual.Name, 'Names should match');
|
||||
System.Assert.areEqual(expected.Status__c, actual.Status__c, 'Status should match');
|
||||
|
||||
// Or use JSON for deep comparison (use sparingly)
|
||||
System.Assert.areEqual(
|
||||
JSON.serialize(expected),
|
||||
JSON.serialize(actual),
|
||||
'Objects should be identical'
|
||||
);
|
||||
```
|
||||
|
||||
### Date/DateTime Assertions
|
||||
|
||||
```apex
|
||||
// Exact date
|
||||
System.Assert.areEqual(Date.today(), record.CreatedDate__c, 'Should be created today');
|
||||
|
||||
// Date within range
|
||||
System.Assert.isTrue(record.DueDate__c >= Date.today(), 'Due date should be in the future');
|
||||
System.Assert.isTrue(record.DueDate__c <= Date.today().addDays(30),
|
||||
'Due date should be within 30 days');
|
||||
```
|
||||
|
||||
### Null Checks
|
||||
|
||||
```apex
|
||||
// Should be null
|
||||
System.Assert.isNull(result.ErrorMessage__c, 'No error expected for valid input');
|
||||
|
||||
// Should not be null
|
||||
System.Assert.isNotNull(result.Id, 'Record should have been inserted');
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
|
||||
```apex
|
||||
// Verify instance is of expected type
|
||||
Object result = MyService.processData();
|
||||
System.Assert.isInstanceOfType(result, MyCustomClass.class,
|
||||
'Result should be an instance of MyCustomClass');
|
||||
|
||||
// Verify instance is not of a specific type
|
||||
Object handler = HandlerFactory.create('Account');
|
||||
System.Assert.isNotInstanceOfType(handler, ContactHandler.class,
|
||||
'Account handler should not be a ContactHandler');
|
||||
```
|
||||
|
||||
### Explicit Test Failures
|
||||
|
||||
```apex
|
||||
// Use Assert.fail() when an exception should have been thrown but wasn't
|
||||
@IsTest
|
||||
private static void shouldThrowException_WhenInputInvalid() {
|
||||
try {
|
||||
MyService.process(null);
|
||||
System.Assert.fail('Expected MyCustomException to be thrown for null input');
|
||||
} catch (MyCustomException e) {
|
||||
// Exception was thrown as expected, test passes
|
||||
System.Assert.isTrue(e.getMessage().contains('cannot be null'),
|
||||
'Exception message should mention null input');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Testing implementation, not behavior
|
||||
|
||||
```apex
|
||||
// Bad: Testing that a specific method was called
|
||||
System.Assert.isTrue(MyClass.methodWasCalled, 'Method should be called');
|
||||
|
||||
// Good: Testing the observable outcome
|
||||
System.Assert.areEqual('Expected Value', record.Field__c, 'Field should be updated');
|
||||
```
|
||||
|
||||
### ❌ Overly generic assertions
|
||||
|
||||
```apex
|
||||
// Bad: Passes for any non-empty result
|
||||
System.Assert.isTrue(results.size() > 0);
|
||||
|
||||
// Good: Verifies exact expected count
|
||||
System.Assert.areEqual(200, results.size(), 'All 200 records should be returned');
|
||||
```
|
||||
|
||||
### ❌ Missing negative test assertions
|
||||
|
||||
```apex
|
||||
// Bad: Only tests that no exception occurred
|
||||
MyService.process(data); // Test passes if no exception
|
||||
|
||||
// Good: Verifies the actual outcome
|
||||
Result r = MyService.process(data);
|
||||
System.Assert.areEqual('Success', r.status, 'Processing should succeed');
|
||||
System.Assert.areEqual(0, r.errorCount, 'No errors should occur');
|
||||
```
|
||||
@ -1,276 +0,0 @@
|
||||
# Async Testing Patterns
|
||||
|
||||
## Key Principle
|
||||
|
||||
`Test.stopTest()` forces all async operations to execute synchronously, allowing assertions on their results.
|
||||
|
||||
## Batch Apex Testing
|
||||
|
||||
### Basic Batch Test
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldProcessAllRecords_WhenBatchExecutes() {
|
||||
// Given: Create test data
|
||||
List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
|
||||
// When: Execute batch
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Id batchId = Database.executeBatch(batch, 200);
|
||||
Test.stopTest(); // Forces batch to complete
|
||||
|
||||
// Then: Verify results
|
||||
List<Account> updated = [SELECT Id, Status__c FROM Account];
|
||||
for (Account acc : updated) {
|
||||
System.Assert.areEqual('Processed', acc.Status__c,
|
||||
'Batch should update all account statuses');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Batch with Failures
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldLogErrors_WhenRecordsFail() {
|
||||
// Given: Create mix of valid and invalid records
|
||||
List<Account> accounts = TestDataFactory.createAccounts(198, true);
|
||||
|
||||
// Create 2 accounts that will fail processing
|
||||
List<Account> invalidAccounts = new List<Account>();
|
||||
for (Integer i = 0; i < 2; i++) {
|
||||
invalidAccounts.add(new Account(
|
||||
Name = 'Invalid Account ' + i,
|
||||
Invalid_Field__c = 'triggers_validation_error'
|
||||
));
|
||||
}
|
||||
insert invalidAccounts;
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Database.executeBatch(batch, 50);
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
List<Error_Log__c> errors = [SELECT Id, Message__c FROM Error_Log__c];
|
||||
System.Assert.areEqual(2, errors.size(), 'Should log 2 failed records');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Batch Scope
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldRespectBatchSize() {
|
||||
// Given
|
||||
List<Account> accounts = TestDataFactory.createAccounts(250, true);
|
||||
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Database.executeBatch(batch, 50); // 5 batches of 50
|
||||
Test.stopTest();
|
||||
|
||||
// Note: In tests, all batches execute but you can verify total processing
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.Assert.areEqual(250, processed.size(), 'All records should be processed');
|
||||
}
|
||||
```
|
||||
|
||||
## Queueable Testing
|
||||
|
||||
### Basic Queueable Test
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldCompleteProcessing_WhenQueueableEnqueued() {
|
||||
// Given
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyQueueableClass queueable = new MyQueueableClass(acc.Id);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest(); // Forces queueable to complete
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id];
|
||||
System.Assert.areEqual('Processed', updated.Status__c,
|
||||
'Queueable should update account status');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Queueable Chaining
|
||||
|
||||
Chained queueables only execute the first job in tests:
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldChainNextJob_WhenMoreRecordsExist() {
|
||||
// Given: More records than one queueable can process
|
||||
List<Account> accounts = TestDataFactory.createAccounts(500, true);
|
||||
|
||||
Test.startTest();
|
||||
// First queueable processes batch 1 and chains next
|
||||
MyChainedQueueable queueable = new MyChainedQueueable(0, 100);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify first batch processed
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.Assert.areEqual(100, processed.size(), 'First batch should process 100 records');
|
||||
|
||||
// Verify chain was enqueued (check AsyncApexJob)
|
||||
List<AsyncApexJob> jobs = [
|
||||
SELECT Id, Status, JobType
|
||||
FROM AsyncApexJob
|
||||
WHERE ApexClass.Name = 'MyChainedQueueable'
|
||||
];
|
||||
System.Assert.isTrue(jobs.size() >= 1, 'Chained job should be enqueued');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Queueable with Callouts
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldMakeCallout_WhenQueueableWithCallout() {
|
||||
// Given
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"status":"ok"}'));
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyQueueableWithCallout queueable = new MyQueueableWithCallout(acc.Id);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, External_Status__c FROM Account WHERE Id = :acc.Id];
|
||||
System.Assert.areEqual('Synced', updated.External_Status__c,
|
||||
'Should update status after successful callout');
|
||||
}
|
||||
```
|
||||
|
||||
## Future Method Testing
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldExecuteFutureMethod() {
|
||||
// Given
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyClass.processFuture(acc.Id); // @future method
|
||||
Test.stopTest(); // Forces future to complete
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id];
|
||||
System.Assert.areEqual(true, updated.Processed__c, 'Future should process record');
|
||||
}
|
||||
```
|
||||
|
||||
## Scheduled Apex Testing
|
||||
|
||||
### Testing Scheduled Execution
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldExecuteScheduledJob() {
|
||||
// Given
|
||||
List<Account> accounts = TestDataFactory.createAccounts(50, true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
String cronExp = '0 0 0 1 1 ? 2099'; // Arbitrary future time
|
||||
String jobId = System.schedule('Test Job', cronExp, new MyScheduledClass());
|
||||
|
||||
// Execute the scheduled job immediately
|
||||
MyScheduledClass scheduled = new MyScheduledClass();
|
||||
scheduled.execute(null); // Pass null SchedulableContext in tests
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.Assert.areEqual(50, processed.size(), 'Scheduled job should process records');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Schedule Registration
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldScheduleJob() {
|
||||
Test.startTest();
|
||||
String cronExp = '0 0 6 * * ?'; // Daily at 6 AM
|
||||
String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass());
|
||||
Test.stopTest();
|
||||
|
||||
// Verify job is scheduled
|
||||
CronTrigger ct = [
|
||||
SELECT Id, CronExpression, State
|
||||
FROM CronTrigger
|
||||
WHERE Id = :jobId
|
||||
];
|
||||
System.Assert.areEqual('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
|
||||
System.Assert.areEqual('WAITING', ct.State, 'Job should be waiting');
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async Limits
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldNotExceedQueueableLimits() {
|
||||
// Given: Setup that might enqueue multiple jobs
|
||||
List<Account> accounts = TestDataFactory.createAccounts(100, true);
|
||||
|
||||
Test.startTest();
|
||||
Integer queueablesBefore = Limits.getQueueableJobs();
|
||||
|
||||
MyService.processWithQueueables(accounts);
|
||||
|
||||
Integer queueablesUsed = Limits.getQueueableJobs() - queueablesBefore;
|
||||
Test.stopTest();
|
||||
|
||||
// Verify limit not exceeded (50 in synchronous context, 1 in queueable)
|
||||
System.Assert.isTrue(queueablesUsed <= 50,
|
||||
'Should not exceed queueable limit. Used: ' + queueablesUsed);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Forgetting Test.stopTest()
|
||||
|
||||
```apex
|
||||
// Bad: Async never executes
|
||||
Test.startTest();
|
||||
System.enqueueJob(new MyQueueable());
|
||||
// Missing Test.stopTest()!
|
||||
|
||||
List<Account> results = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.Assert.areEqual(100, results.size()); // FAILS - queueable didn't run
|
||||
```
|
||||
|
||||
### ❌ Testing chained jobs without understanding limits
|
||||
|
||||
```apex
|
||||
// Only the FIRST chained queueable runs in tests
|
||||
// Design tests to verify:
|
||||
// 1. First job completes correctly
|
||||
// 2. Chain is properly enqueued (check AsyncApexJob)
|
||||
// 3. Each job works independently
|
||||
```
|
||||
|
||||
### ❌ Not mocking callouts in async
|
||||
|
||||
```apex
|
||||
// Async with callouts MUST have mock set BEFORE Test.startTest()
|
||||
Test.setMock(HttpCalloutMock.class, new MockResponse()); // Before startTest!
|
||||
Test.startTest();
|
||||
System.enqueueJob(new QueueableWithCallout());
|
||||
Test.stopTest();
|
||||
```
|
||||
@ -1,219 +0,0 @@
|
||||
# Mocking Patterns
|
||||
|
||||
## HTTP Callout Mocking
|
||||
|
||||
Apex doesn't allow real HTTP callouts in tests. Use `HttpCalloutMock` interface.
|
||||
|
||||
### Basic Mock Implementation
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
public class MockHttpResponse implements HttpCalloutMock {
|
||||
|
||||
private Integer statusCode;
|
||||
private String body;
|
||||
|
||||
public MockHttpResponse(Integer statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(statusCode);
|
||||
res.setBody(body);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Mock
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldProcessApiResponse_WhenCalloutSucceeds() {
|
||||
// Given
|
||||
String mockResponse = '{"status": "success", "data": [{"id": "123"}]}';
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, mockResponse));
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
List<ExternalRecord> results = MyIntegrationService.fetchRecords();
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
System.Assert.areEqual(1, results.size(), 'Should parse one record from response');
|
||||
System.Assert.areEqual('123', results[0].externalId, 'Should extract correct ID');
|
||||
}
|
||||
|
||||
@IsTest
|
||||
private static void shouldHandleError_WhenCalloutFails() {
|
||||
// Given
|
||||
String errorResponse = '{"error": "Unauthorized"}';
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(401, errorResponse));
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
CalloutResult result = MyIntegrationService.fetchRecords();
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
System.Assert.areEqual(false, result.isSuccess, 'Should indicate failure');
|
||||
System.Assert.isTrue(result.errorMessage.contains('Unauthorized'), 'Should capture error');
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Request Mock
|
||||
|
||||
For services making multiple callouts:
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
public class MultiRequestMock implements HttpCalloutMock {
|
||||
|
||||
private Map<String, HttpResponse> endpointResponses;
|
||||
|
||||
public MultiRequestMock(Map<String, HttpResponse> responses) {
|
||||
this.endpointResponses = responses;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
String endpoint = req.getEndpoint();
|
||||
|
||||
for (String key : endpointResponses.keySet()) {
|
||||
if (endpoint.contains(key)) {
|
||||
return endpointResponses.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Default 404 if no match
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(404);
|
||||
res.setBody('{"error": "Not found"}');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
Map<String, HttpResponse> mocks = new Map<String, HttpResponse>();
|
||||
|
||||
HttpResponse authResponse = new HttpResponse();
|
||||
authResponse.setStatusCode(200);
|
||||
authResponse.setBody('{"token": "abc123"}');
|
||||
mocks.put('/oauth/token', authResponse);
|
||||
|
||||
HttpResponse dataResponse = new HttpResponse();
|
||||
dataResponse.setStatusCode(200);
|
||||
dataResponse.setBody('{"records": []}');
|
||||
mocks.put('/api/records', dataResponse);
|
||||
|
||||
Test.setMock(HttpCalloutMock.class, new MultiRequestMock(mocks));
|
||||
```
|
||||
|
||||
## StaticResourceCalloutMock
|
||||
|
||||
For complex response bodies, store JSON in Static Resources:
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldParseComplexResponse() {
|
||||
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
|
||||
mock.setStaticResource('TestApiResponse'); // Static Resource name
|
||||
mock.setStatusCode(200);
|
||||
mock.setHeader('Content-Type', 'application/json');
|
||||
|
||||
Test.setMock(HttpCalloutMock.class, mock);
|
||||
|
||||
Test.startTest();
|
||||
Result r = MyService.callExternalApi();
|
||||
Test.stopTest();
|
||||
|
||||
System.Assert.isNotNull(r, 'Should parse response');
|
||||
}
|
||||
```
|
||||
|
||||
## Stub API (Enterprise Pattern)
|
||||
|
||||
For mocking Apex class dependencies using `System.StubProvider`:
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
public class MyServiceMock implements System.StubProvider {
|
||||
|
||||
public Object handleMethodCall(
|
||||
Object stubbedObject,
|
||||
String stubbedMethodName,
|
||||
Type returnType,
|
||||
List<Type> paramTypes,
|
||||
List<String> paramNames,
|
||||
List<Object> args
|
||||
) {
|
||||
if (stubbedMethodName == 'getAccountData') {
|
||||
return new AccountData('Mock Account', 'Active');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in test:
|
||||
@IsTest
|
||||
private static void shouldUseAccountData() {
|
||||
MyServiceMock mockProvider = new MyServiceMock();
|
||||
IMyService mockService = (IMyService)Test.createStub(IMyService.class, mockProvider);
|
||||
|
||||
// Inject mock into class under test
|
||||
MyController controller = new MyController(mockService);
|
||||
|
||||
Test.startTest();
|
||||
String result = controller.displayAccountInfo();
|
||||
Test.stopTest();
|
||||
|
||||
System.Assert.isTrue(result.contains('Mock Account'), 'Should use mocked data');
|
||||
}
|
||||
```
|
||||
|
||||
## Email Mocking
|
||||
|
||||
Apex sends real emails by default. Use limits to verify:
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldSendEmail_WhenTriggered() {
|
||||
Integer emailsBefore = Limits.getEmailInvocations();
|
||||
|
||||
Test.startTest();
|
||||
MyService.sendNotification(testContact);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify email was queued (not actually sent in tests)
|
||||
System.Assert.areEqual(
|
||||
emailsBefore + 1,
|
||||
Limits.getEmailInvocations(),
|
||||
'One email should be sent'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Platform Event Testing
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
private static void shouldPublishEvent_WhenRecordCreated() {
|
||||
Test.startTest();
|
||||
|
||||
// Enable event delivery in test context
|
||||
Test.enableChangeDataCapture();
|
||||
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// Deliver events
|
||||
Test.getEventBus().deliver();
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// Query platform event trigger results
|
||||
List<EventLog__c> logs = [SELECT Id FROM EventLog__c WHERE AccountId__c = :acc.Id];
|
||||
System.Assert.areEqual(1, logs.size(), 'Event handler should create log record');
|
||||
}
|
||||
```
|
||||
@ -1,176 +0,0 @@
|
||||
# TestDataFactory Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
TestDataFactory is a centralized utility class for creating test records with sensible defaults. It ensures consistent test data across all test classes and reduces duplication.
|
||||
|
||||
## Base Template
|
||||
|
||||
```apex
|
||||
@IsTest
|
||||
public class TestDataFactory {
|
||||
|
||||
// ============ ACCOUNTS ============
|
||||
|
||||
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
|
||||
List<Account> accounts = new List<Account>();
|
||||
for (Integer i = 0; i < count; i++) {
|
||||
accounts.add(new Account(
|
||||
Name = 'Test Account ' + i,
|
||||
BillingStreet = '123 Test St',
|
||||
BillingCity = 'San Francisco',
|
||||
BillingState = 'CA',
|
||||
BillingPostalCode = '94105',
|
||||
BillingCountry = 'USA',
|
||||
Industry = 'Technology',
|
||||
Type = 'Customer'
|
||||
));
|
||||
}
|
||||
if (doInsert) insert accounts;
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public static Account createAccount(Boolean doInsert) {
|
||||
return createAccounts(1, doInsert)[0];
|
||||
}
|
||||
|
||||
// ============ CONTACTS ============
|
||||
|
||||
public static List<Contact> createContacts(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Contact> contacts = new List<Contact>();
|
||||
Integer index = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
contacts.add(new Contact(
|
||||
FirstName = 'Test',
|
||||
LastName = 'Contact ' + index,
|
||||
Email = 'test.contact' + index + '@example.com',
|
||||
Phone = '555-000-' + String.valueOf(index).leftPad(4, '0'),
|
||||
AccountId = acc.Id
|
||||
));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert contacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
// ============ OPPORTUNITIES ============
|
||||
|
||||
public static List<Opportunity> createOpportunities(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Opportunity> opps = new List<Opportunity>();
|
||||
Integer index = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
opps.add(new Opportunity(
|
||||
Name = 'Test Opportunity ' + index,
|
||||
AccountId = acc.Id,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000 + (index * 1000)
|
||||
));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert opps;
|
||||
return opps;
|
||||
}
|
||||
|
||||
// ============ USERS ============
|
||||
|
||||
public static User createUser(String profileName, Boolean doInsert) {
|
||||
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
|
||||
String uniqueKey = String.valueOf(DateTime.now().getTime());
|
||||
|
||||
User u = new User(
|
||||
FirstName = 'Test',
|
||||
LastName = 'User ' + uniqueKey,
|
||||
Email = 'testuser' + uniqueKey + '@example.com',
|
||||
Username = 'testuser' + uniqueKey + '@example.com.test',
|
||||
Alias = 'tuser',
|
||||
TimeZoneSidKey = 'America/Los_Angeles',
|
||||
LocaleSidKey = 'en_US',
|
||||
EmailEncodingKey = 'UTF-8',
|
||||
LanguageLocaleKey = 'en_US',
|
||||
ProfileId = p.Id
|
||||
);
|
||||
if (doInsert) insert u;
|
||||
return u;
|
||||
}
|
||||
|
||||
// ============ CUSTOM OBJECTS ============
|
||||
|
||||
// Add methods for your custom objects following the same pattern:
|
||||
// public static List<MyObject__c> createMyObjects(Integer count, Boolean doInsert) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Field Override Pattern
|
||||
|
||||
Allow callers to override default values:
|
||||
|
||||
```apex
|
||||
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
Industry = 'Technology'
|
||||
);
|
||||
|
||||
// Apply overrides
|
||||
for (String fieldName : fieldOverrides.keySet()) {
|
||||
acc.put(fieldName, fieldOverrides.get(fieldName));
|
||||
}
|
||||
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Usage:
|
||||
Account acc = TestDataFactory.createAccount(new Map<String, Object>{
|
||||
'Name' => 'Custom Name',
|
||||
'Industry' => 'Healthcare'
|
||||
}, true);
|
||||
```
|
||||
|
||||
## Handling Required Fields and Validation Rules
|
||||
|
||||
```apex
|
||||
public static Account createAccountWithRequiredFields(Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
// Required custom fields
|
||||
External_Id__c = 'EXT-' + String.valueOf(DateTime.now().getTime()),
|
||||
// Fields required by validation rules
|
||||
Phone = '555-123-4567',
|
||||
Website = 'https://example.com'
|
||||
);
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## Record Type Support
|
||||
|
||||
```apex
|
||||
public static Account createAccountByRecordType(String recordTypeName, Boolean doInsert) {
|
||||
Id recordTypeId = Schema.SObjectType.Account
|
||||
.getRecordTypeInfosByDeveloperName()
|
||||
.get(recordTypeName)
|
||||
.getRecordTypeId();
|
||||
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
RecordTypeId = recordTypeId
|
||||
);
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always include doInsert parameter** - Allows flexibility for tests that need to modify records before insert
|
||||
2. **Use unique identifiers** - Include index or timestamp in Name/Email fields to avoid duplicates
|
||||
3. **Set all required fields** - Include all fields required by validation rules
|
||||
4. **Return the created records** - Enables chaining and further manipulation
|
||||
5. **Create bulk methods first** - Single record methods should call bulk methods with count=1
|
||||
@ -1,257 +0,0 @@
|
||||
---
|
||||
name: deployment-readiness-check
|
||||
description: Comprehensive pre-deployment validation checklist for Salesforce releases. Use before deploying to production to catch metadata issues, test coverage gaps, and configuration errors.
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Salesforce CLI, jq, bash
|
||||
metadata:
|
||||
author: afv-library
|
||||
version: "1.0"
|
||||
allowed-tools: Bash Read
|
||||
---
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill before deploying Salesforce metadata to production (or higher environments) to:
|
||||
- Validate metadata quality and completeness
|
||||
- Check test coverage meets organizational standards
|
||||
- Verify security settings and permissions
|
||||
- Identify configuration issues before deployment
|
||||
- Generate deployment documentation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Salesforce CLI installed and authenticated to target org
|
||||
- `jq` command-line JSON processor installed
|
||||
- Bash shell (Linux, macOS, or WSL on Windows)
|
||||
- Source metadata in SFDX project format
|
||||
|
||||
## Step 1: Run Metadata Validation
|
||||
|
||||
Execute the validation script to check for common metadata issues:
|
||||
|
||||
```bash
|
||||
bash scripts/check_metadata.sh
|
||||
```
|
||||
|
||||
The script validates:
|
||||
- **Metadata format** - Ensures all XML is well-formed
|
||||
- **API versions** - Checks for outdated API versions
|
||||
- **Deprecated features** - Identifies deprecated components
|
||||
- **Naming conventions** - Validates standard naming patterns
|
||||
- **File completeness** - Ensures meta.xml files are present
|
||||
|
||||
Review the output for any warnings or errors before proceeding.
|
||||
|
||||
## Step 2: Verify Test Coverage
|
||||
|
||||
Check that your Apex test coverage meets organizational standards:
|
||||
|
||||
```bash
|
||||
# Run all tests and generate coverage report
|
||||
sf apex test run --test-level RunLocalTests --result-format human --code-coverage --wait 10
|
||||
|
||||
# Check coverage percentage
|
||||
sf apex get test --test-run-id <test-run-id> --code-coverage --result-format json | jq '.summary.testRunCoverage'
|
||||
```
|
||||
|
||||
**Minimum requirements**:
|
||||
- Overall org coverage: ≥75% (Salesforce minimum)
|
||||
- Individual class coverage: ≥75% (recommended)
|
||||
- No classes with 0% coverage
|
||||
|
||||
If coverage is below threshold:
|
||||
1. Identify uncovered classes using the coverage report
|
||||
2. Add test methods to increase coverage
|
||||
3. Rerun tests until requirements are met
|
||||
|
||||
## Step 3: Security and Permissions Review
|
||||
|
||||
Review security settings and permissions to ensure proper access controls:
|
||||
|
||||
1. **Profile and Permission Set Review**
|
||||
- Check that custom profiles/permission sets follow least-privilege principle
|
||||
- Verify admin permissions are not granted to standard users
|
||||
- Ensure sensitive objects have appropriate FLS
|
||||
|
||||
2. **Sharing Rules and OWD**
|
||||
- Review Organization-Wide Defaults are appropriate
|
||||
- Validate sharing rules grant necessary access without over-sharing
|
||||
- Check for public groups with excessive membership
|
||||
|
||||
3. **API Access**
|
||||
- Verify Connected Apps have appropriate scopes
|
||||
- Check Named Credentials use secure authentication
|
||||
- Review Remote Site Settings are necessary
|
||||
|
||||
Consult the [security checklist reference](references/security_checklist.md) for detailed guidance.
|
||||
|
||||
## Step 4: Configuration Validation
|
||||
|
||||
Verify configuration settings are deployment-ready:
|
||||
|
||||
```bash
|
||||
# Check for hardcoded URLs or IDs
|
||||
grep -r "https://.*\.salesforce\.com" force-app/main/default/
|
||||
grep -r "[a-zA-Z0-9]{15,18}" force-app/main/default/ | grep -v "meta.xml"
|
||||
|
||||
# Validate Custom Settings and Custom Metadata
|
||||
sf project retrieve start --metadata CustomObject:*__c
|
||||
```
|
||||
|
||||
**Configuration checklist**:
|
||||
- [ ] No hardcoded production URLs in code
|
||||
- [ ] No hardcoded record IDs
|
||||
- [ ] Custom Settings configured correctly for target org
|
||||
- [ ] Custom Metadata Types populated appropriately
|
||||
- [ ] Email templates reference correct org email
|
||||
- [ ] Reports and Dashboards folders have correct permissions
|
||||
|
||||
## Step 5: Review Dependencies
|
||||
|
||||
Check for dependency conflicts or missing components:
|
||||
|
||||
```bash
|
||||
# Generate dependency report
|
||||
sf project deploy validate --manifest package.xml --test-level RunLocalTests --verbose
|
||||
|
||||
# Check for missing dependencies
|
||||
grep -i "error.*component" deployment_log.txt
|
||||
```
|
||||
|
||||
Common dependency issues:
|
||||
- Missing Custom Fields referenced in code
|
||||
- Validation Rules referencing deleted fields
|
||||
- Workflows or Process Builders using deprecated actions
|
||||
- Lightning Components with missing design resources
|
||||
|
||||
See [dependency troubleshooting guide](references/dependency_resolution.md) for solutions.
|
||||
|
||||
## Step 6: Generate Deployment Checklist
|
||||
|
||||
Use the deployment checklist template to document your release:
|
||||
|
||||
```bash
|
||||
cp assets/deployment_checklist.md deployment_checklist_$(date +%Y%m%d).md
|
||||
```
|
||||
|
||||
Fill out the checklist with:
|
||||
- [ ] Deployment date and time window
|
||||
- [ ] Components being deployed (attach package.xml)
|
||||
- [ ] Test execution results and coverage
|
||||
- [ ] Backup verification (data and metadata)
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified
|
||||
- [ ] Post-deployment validation steps
|
||||
- [ ] Monitoring plan for 24-48 hours
|
||||
|
||||
## Step 7: Execute Pre-Deployment Validation
|
||||
|
||||
Run a validation-only deployment to catch issues before actual deployment:
|
||||
|
||||
```bash
|
||||
# Validate deployment without committing
|
||||
sf project deploy validate \
|
||||
--manifest package.xml \
|
||||
--test-level RunLocalTests \
|
||||
--verbose
|
||||
|
||||
# Save the validation ID for quick deploy
|
||||
sf project deploy start --use-most-recent-validation --async
|
||||
```
|
||||
|
||||
**Benefits of validation**:
|
||||
- Tests run in production environment
|
||||
- Identifies environment-specific issues
|
||||
- Generates Quick Deploy ID for faster deployment
|
||||
- No changes committed until you confirm
|
||||
|
||||
Review validation results and address any failures before scheduling deployment.
|
||||
|
||||
## Step 8: Prepare Rollback Plan
|
||||
|
||||
Document rollback procedures before deploying:
|
||||
|
||||
1. **Backup current state**
|
||||
```bash
|
||||
sf project retrieve start --manifest package.xml --target-org production
|
||||
git tag pre-deployment-$(date +%Y%m%d) && git push --tags
|
||||
```
|
||||
|
||||
2. **Test rollback procedure** in sandbox:
|
||||
- Deploy previous version of metadata
|
||||
- Verify functionality is restored
|
||||
- Document any data cleanup required
|
||||
|
||||
3. **Establish rollback criteria**:
|
||||
- Critical bugs found within 2 hours
|
||||
- Core functionality broken
|
||||
- Performance degradation >50%
|
||||
- Data integrity issues
|
||||
|
||||
See [rollback procedures reference](references/rollback_procedures.md) for detailed steps.
|
||||
|
||||
## Post-Deployment Validation
|
||||
|
||||
After successful deployment, verify the release:
|
||||
|
||||
1. **Smoke tests** - Execute critical user workflows
|
||||
2. **Monitor logs** - Check debug logs for errors (24-48 hours)
|
||||
3. **Query test data** - Verify triggers and automation work correctly
|
||||
4. **User acceptance** - Confirm with stakeholders functionality works
|
||||
5. **Performance check** - Review governor limit usage in logs
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Test Coverage Drops Below 75%
|
||||
|
||||
**Cause**: New Apex classes added without sufficient tests.
|
||||
|
||||
**Solution**:
|
||||
1. Run `sf apex test run --code-coverage` to identify gaps
|
||||
2. Add test methods covering uncovered lines
|
||||
3. Revalidate coverage
|
||||
|
||||
### Issue: Validation Fails with "Component Not Found"
|
||||
|
||||
**Cause**: Missing dependency in package.xml.
|
||||
|
||||
**Solution**:
|
||||
1. Review error message for missing component
|
||||
2. Add component to package.xml
|
||||
3. Retrieve component from source org if needed
|
||||
4. Revalidate
|
||||
|
||||
### Issue: Permission Errors After Deployment
|
||||
|
||||
**Cause**: FLS or object permissions not deployed correctly.
|
||||
|
||||
**Solution**:
|
||||
1. Verify profiles/permission sets are in package.xml
|
||||
2. Check that CustomObject metadata includes field permissions
|
||||
3. Deploy profiles separately if needed
|
||||
4. Use Permission Set Groups for complex permission hierarchies
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always validate before deploying** - Never deploy directly to production without validation
|
||||
2. **Run full test suite** - Use RunLocalTests, not NoTestRun
|
||||
3. **Deploy during maintenance windows** - Minimize impact on users
|
||||
4. **Communicate with stakeholders** - Notify before, during, and after deployment
|
||||
5. **Monitor post-deployment** - Watch logs and user feedback for 24-48 hours
|
||||
6. **Document everything** - Maintain deployment logs and decisions
|
||||
7. **Use version control tags** - Tag releases for easy rollback
|
||||
|
||||
## References
|
||||
|
||||
- [Security Checklist](references/security_checklist.md) - Detailed security review steps
|
||||
- [Dependency Resolution Guide](references/dependency_resolution.md) - Solve dependency conflicts
|
||||
- [Rollback Procedures](references/rollback_procedures.md) - Step-by-step rollback guide
|
||||
- [Deployment Checklist Template](assets/deployment_checklist.md) - Reusable checklist
|
||||
|
||||
## Automation Opportunities
|
||||
|
||||
Consider automating this skill:
|
||||
- **CI/CD Integration** - Run validation script in pipeline
|
||||
- **Scheduled Coverage Checks** - Monitor test coverage daily
|
||||
- **Auto-generated Documentation** - Create deployment notes from package.xml
|
||||
- **Slack/Email Notifications** - Alert team of deployment status
|
||||
@ -1,286 +0,0 @@
|
||||
# Salesforce Deployment Checklist
|
||||
|
||||
**Deployment Date**: _________________
|
||||
**Deployment Time Window**: _________ to _________
|
||||
**Environment**: ☐ Production ☐ Sandbox ☐ UAT
|
||||
**Deployment Lead**: _________________
|
||||
**Release Version**: _________________
|
||||
|
||||
---
|
||||
|
||||
## Pre-Deployment
|
||||
|
||||
### 1. Code & Configuration Review
|
||||
|
||||
- [ ] All code reviewed and approved via pull request
|
||||
- [ ] Code follows organizational coding standards
|
||||
- [ ] No debugging statements or console.logs in production code
|
||||
- [ ] API versions are current (API 56.0+)
|
||||
- [ ] Hard-coded IDs/URLs removed (use Custom Settings or Custom Metadata)
|
||||
- [ ] Comments and documentation are up-to-date
|
||||
|
||||
### 2. Testing
|
||||
|
||||
- [ ] All unit tests pass locally
|
||||
- [ ] Test coverage meets minimum requirements (≥75% org-wide)
|
||||
- [ ] Integration tests executed successfully
|
||||
- [ ] User acceptance testing (UAT) completed
|
||||
- [ ] Regression testing performed for existing features
|
||||
- [ ] Performance testing validates no degradation
|
||||
|
||||
**Test Results**:
|
||||
- Total classes: _____
|
||||
- Test coverage: _____%
|
||||
- Failed tests: _____
|
||||
- Test run ID: _________________
|
||||
|
||||
### 3. Security Review
|
||||
|
||||
- [ ] Profile/permission set changes reviewed
|
||||
- [ ] Field-level security validated
|
||||
- [ ] Sharing rules appropriate for data sensitivity
|
||||
- [ ] No admin permissions granted to standard users
|
||||
- [ ] Connected Apps use appropriate OAuth scopes
|
||||
- [ ] Named Credentials use secure authentication
|
||||
- [ ] Sensitive data is encrypted or masked
|
||||
|
||||
### 4. Dependencies
|
||||
|
||||
- [ ] All metadata dependencies identified
|
||||
- [ ] Missing components added to package.xml
|
||||
- [ ] External system dependencies documented
|
||||
- [ ] API integrations tested in target environment
|
||||
- [ ] Third-party packages compatible with deployment
|
||||
|
||||
### 5. Backup & Rollback
|
||||
|
||||
- [ ] Pre-deployment backup completed
|
||||
- Metadata backup: ☐ Yes ☐ N/A
|
||||
- Data backup: ☐ Yes ☐ N/A
|
||||
- Backup location: _________________
|
||||
- [ ] Rollback procedure documented and tested
|
||||
- [ ] Rollback criteria defined
|
||||
- [ ] Emergency contacts list current
|
||||
|
||||
### 6. Documentation
|
||||
|
||||
- [ ] Release notes prepared
|
||||
- [ ] Deployment guide created
|
||||
- [ ] User training materials ready (if applicable)
|
||||
- [ ] Help documentation updated
|
||||
- [ ] Known issues documented
|
||||
- [ ] FAQ prepared for support team
|
||||
|
||||
### 7. Communication
|
||||
|
||||
- [ ] Stakeholders notified of deployment schedule
|
||||
- [ ] Users notified of system downtime (if applicable)
|
||||
- [ ] Support team briefed on changes
|
||||
- [ ] Change advisory board approval obtained
|
||||
- [ ] Post-deployment communication template ready
|
||||
|
||||
### 8. Validation
|
||||
|
||||
- [ ] Validation deployment successful in production
|
||||
- [ ] Quick Deploy ID obtained: _________________
|
||||
- [ ] Validation results reviewed and approved
|
||||
- [ ] All deployment warnings addressed
|
||||
|
||||
**Validation Command**:
|
||||
```bash
|
||||
sf project deploy validate --manifest package.xml --test-level RunLocalTests --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Execution
|
||||
|
||||
### 9. Pre-Deployment Actions
|
||||
|
||||
- [ ] Announced maintenance window to users
|
||||
- [ ] Disabled scheduled jobs/batch processes (if required)
|
||||
- Jobs disabled: _________________
|
||||
- [ ] Created deployment tag in git: _________________
|
||||
- [ ] Verified target org connectivity
|
||||
- [ ] Team on standby for deployment
|
||||
|
||||
### 10. Deployment
|
||||
|
||||
**Deployment Start Time**: _________
|
||||
|
||||
- [ ] Deployment initiated via validated package or change set
|
||||
- [ ] Deployment progress monitored
|
||||
- [ ] Test execution in progress
|
||||
- [ ] Any deployment errors addressed immediately
|
||||
|
||||
**Deployment Command**:
|
||||
```bash
|
||||
sf project deploy start --use-most-recent-validation
|
||||
# OR
|
||||
sf project deploy start --manifest package.xml --test-level RunLocalTests
|
||||
```
|
||||
|
||||
**Deployment ID**: _________________
|
||||
|
||||
**Deployment End Time**: _________
|
||||
|
||||
**Duration**: _________ minutes
|
||||
|
||||
### 11. Post-Deployment Verification
|
||||
|
||||
- [ ] Deployment completed successfully
|
||||
- [ ] All tests passed in production
|
||||
- [ ] Deployment results reviewed (no warnings/errors)
|
||||
- [ ] Smoke tests executed successfully
|
||||
- [ ] Critical user workflows verified:
|
||||
- [ ] Workflow 1: _________________
|
||||
- [ ] Workflow 2: _________________
|
||||
- [ ] Workflow 3: _________________
|
||||
- [ ] Re-enabled scheduled jobs/batch processes
|
||||
- [ ] Debug logs show no new errors
|
||||
|
||||
### 12. Post-Deployment Actions
|
||||
|
||||
- [ ] Stakeholders notified of successful deployment
|
||||
- [ ] Users notified system is available
|
||||
- [ ] Release notes published
|
||||
- [ ] Support team provided with deployment summary
|
||||
- [ ] Monitoring dashboard reviewed
|
||||
|
||||
---
|
||||
|
||||
## Post-Deployment Monitoring (24-48 Hours)
|
||||
|
||||
### 13. System Monitoring
|
||||
|
||||
- [ ] **Day 1** - Monitor debug logs for errors
|
||||
- Errors found: ☐ Yes ☐ No
|
||||
- If yes, describe: _________________
|
||||
- [ ] **Day 1** - Review governor limit usage
|
||||
- Limits exceeded: ☐ Yes ☐ No
|
||||
- [ ] **Day 1** - Check user-reported issues
|
||||
- Issues reported: ☐ Yes ☐ No
|
||||
- [ ] **Day 2** - Confirm automation working correctly
|
||||
- [ ] **Day 2** - Validate data integrity
|
||||
- [ ] **Day 2** - Review performance metrics
|
||||
|
||||
### 14. User Acceptance
|
||||
|
||||
- [ ] Key users confirmed functionality works
|
||||
- [ ] No critical bugs reported
|
||||
- [ ] User feedback collected
|
||||
- [ ] Support tickets categorized by severity
|
||||
|
||||
**Feedback Summary**:
|
||||
_________________
|
||||
|
||||
---
|
||||
|
||||
## Rollback (If Required)
|
||||
|
||||
**Rollback Decision**: ☐ Yes ☐ No
|
||||
|
||||
**Reason**: _________________
|
||||
|
||||
- [ ] Stakeholder approval for rollback obtained
|
||||
- [ ] Users notified of rollback
|
||||
- [ ] Rollback procedure executed
|
||||
- [ ] Rollback verified successful
|
||||
- [ ] Post-rollback communication sent
|
||||
|
||||
**Rollback Time**: _________
|
||||
|
||||
---
|
||||
|
||||
## Deployment Summary
|
||||
|
||||
### Components Deployed
|
||||
|
||||
| Component Type | Count | Examples |
|
||||
|----------------|-------|----------|
|
||||
| Apex Classes | | |
|
||||
| Apex Triggers | | |
|
||||
| LWC | | |
|
||||
| Flows | | |
|
||||
| Custom Objects | | |
|
||||
| Custom Fields | | |
|
||||
| Profiles | | |
|
||||
| Perm Sets | | |
|
||||
| Other | | |
|
||||
|
||||
**Total Components**: _____
|
||||
|
||||
### Deployment Metrics
|
||||
|
||||
- Validation time: _________ minutes
|
||||
- Deployment time: _________ minutes
|
||||
- Test execution time: _________ minutes
|
||||
- Total elapsed time: _________ minutes
|
||||
- Downtime (if any): _________ minutes
|
||||
|
||||
### Issues Encountered
|
||||
|
||||
| Issue | Severity | Resolution | Time to Resolve |
|
||||
|-------|----------|------------|-----------------|
|
||||
| | | | |
|
||||
| | | | |
|
||||
|
||||
**Total Issues**: _____
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
### Deployment Team
|
||||
|
||||
**Deployment Lead**: _________________ Date: _________
|
||||
**Developer(s)**: _________________ Date: _________
|
||||
**QA Lead**: _________________ Date: _________
|
||||
**DevOps Engineer**: _________________ Date: _________
|
||||
|
||||
### Stakeholders
|
||||
|
||||
**Product Owner**: _________________ Date: _________
|
||||
**Business Analyst**: _________________ Date: _________
|
||||
**Release Manager**: _________________ Date: _________
|
||||
|
||||
---
|
||||
|
||||
## Post-Deployment Review
|
||||
|
||||
**Review Date**: _________________
|
||||
|
||||
### What Went Well
|
||||
1. _________________
|
||||
2. _________________
|
||||
3. _________________
|
||||
|
||||
### What Could Be Improved
|
||||
1. _________________
|
||||
2. _________________
|
||||
3. _________________
|
||||
|
||||
### Action Items
|
||||
- [ ] Action: _________________ Owner: _________ Due: _________
|
||||
- [ ] Action: _________________ Owner: _________ Due: _________
|
||||
- [ ] Action: _________________ Owner: _________ Due: _________
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
- [ ] package.xml
|
||||
- [ ] Test results report
|
||||
- [ ] Deployment log
|
||||
- [ ] Release notes
|
||||
- [ ] Rollback procedure (if executed)
|
||||
- [ ] Post-deployment monitoring reports
|
||||
|
||||
**Storage Location**: _________________
|
||||
|
||||
---
|
||||
|
||||
**Notes**:
|
||||
_________________
|
||||
_________________
|
||||
_________________
|
||||
@ -1,308 +0,0 @@
|
||||
# Rollback Procedures
|
||||
|
||||
This guide provides step-by-step instructions for rolling back a Salesforce deployment if issues are discovered after release.
|
||||
|
||||
## When to Rollback
|
||||
|
||||
Execute a rollback when:
|
||||
- **Critical functionality is broken** and cannot be hotfixed quickly
|
||||
- **Data integrity issues** are discovered
|
||||
- **Performance degradation** exceeds 50% of baseline
|
||||
- **Security vulnerabilities** are introduced
|
||||
- **Stakeholder approval** to rollback is obtained
|
||||
|
||||
Do NOT rollback for:
|
||||
- Minor UI issues that don't impact functionality
|
||||
- Issues that can be hotfixed in < 2 hours
|
||||
- Edge cases affecting < 5% of users
|
||||
- Cosmetic problems
|
||||
|
||||
## Pre-Rollback Checklist
|
||||
|
||||
Before initiating rollback:
|
||||
|
||||
- [ ] Confirm the issue severity justifies rollback
|
||||
- [ ] Obtain stakeholder approval
|
||||
- [ ] Notify all users of upcoming rollback
|
||||
- [ ] Backup current production state (post-deployment)
|
||||
- [ ] Verify pre-deployment backup is available
|
||||
- [ ] Review rollback plan with team
|
||||
- [ ] Prepare communication for completion
|
||||
|
||||
## Rollback Methods
|
||||
|
||||
### Method 1: Redeploy Previous Version (Recommended)
|
||||
|
||||
**Best for**: Metadata-only deployments with no data changes.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Retrieve pre-deployment state from version control**:
|
||||
```bash
|
||||
git checkout pre-deployment-YYYYMMDD
|
||||
```
|
||||
|
||||
2. **Validate rollback deployment**:
|
||||
```bash
|
||||
sf project deploy validate \
|
||||
--manifest manifest/package.xml \
|
||||
--test-level RunLocalTests \
|
||||
--target-org production
|
||||
```
|
||||
|
||||
3. **Deploy previous version**:
|
||||
```bash
|
||||
sf project deploy start \
|
||||
--manifest manifest/package.xml \
|
||||
--test-level RunLocalTests \
|
||||
--target-org production
|
||||
```
|
||||
|
||||
4. **Verify rollback**:
|
||||
- Test critical user flows
|
||||
- Check for errors in debug logs
|
||||
- Confirm with stakeholders
|
||||
|
||||
**Timeline**: 30-60 minutes
|
||||
|
||||
### Method 2: Selective Component Rollback
|
||||
|
||||
**Best for**: When only specific components are problematic.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Identify problematic components**:
|
||||
- Review error logs
|
||||
- Isolate failing functionality
|
||||
- List components to rollback
|
||||
|
||||
2. **Create targeted package.xml**:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<types>
|
||||
<members>ProblematicClass</members>
|
||||
<name>ApexClass</name>
|
||||
</types>
|
||||
<version>59.0</version>
|
||||
</Package>
|
||||
```
|
||||
|
||||
3. **Retrieve previous version of components**:
|
||||
```bash
|
||||
git show pre-deployment-YYYYMMDD:force-app/main/default/classes/ProblematicClass.cls > temp/ProblematicClass.cls
|
||||
```
|
||||
|
||||
4. **Deploy only those components**:
|
||||
```bash
|
||||
sf project deploy start \
|
||||
--manifest rollback-package.xml \
|
||||
--target-org production
|
||||
```
|
||||
|
||||
**Timeline**: 15-30 minutes
|
||||
|
||||
### Method 3: Destructive Changes
|
||||
|
||||
**Best for**: When new components must be removed entirely.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Create destructiveChanges.xml**:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<types>
|
||||
<members>NewComponentToDelete</members>
|
||||
<name>ApexClass</name>
|
||||
</types>
|
||||
<version>59.0</version>
|
||||
</Package>
|
||||
```
|
||||
|
||||
2. **Create empty package.xml**:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<version>59.0</version>
|
||||
</Package>
|
||||
```
|
||||
|
||||
3. **Deploy destructive changes**:
|
||||
```bash
|
||||
sf project deploy start \
|
||||
--manifest package.xml \
|
||||
--pre-destructive-changes destructiveChanges.xml \
|
||||
--test-level RunLocalTests \
|
||||
--target-org production
|
||||
```
|
||||
|
||||
4. **Verify deletion**:
|
||||
```bash
|
||||
sf org list metadata --metadata-type ApexClass --target-org production
|
||||
```
|
||||
|
||||
**Timeline**: 20-40 minutes
|
||||
|
||||
## Data Rollback Considerations
|
||||
|
||||
If the deployment included data changes:
|
||||
|
||||
### Option A: Restore from Backup
|
||||
|
||||
1. **Locate pre-deployment data backup**:
|
||||
- Data Export Service snapshot
|
||||
- Backup tool (OwnBackup, Spanning, etc.)
|
||||
- Manual CSV exports
|
||||
|
||||
2. **Restore data**:
|
||||
```bash
|
||||
sf data import tree --plan data-backup/data-plan.json --target-org production
|
||||
```
|
||||
|
||||
3. **Validate data integrity**:
|
||||
- Run data quality checks
|
||||
- Verify record counts
|
||||
- Check relationships
|
||||
|
||||
**Timeline**: 1-4 hours (depending on data volume)
|
||||
|
||||
### Option B: Reverse Data Changes (Scripted)
|
||||
|
||||
1. **Identify affected records**:
|
||||
```bash
|
||||
sf data query --query "SELECT Id FROM Account WHERE LastModifiedDate >= YESTERDAY"
|
||||
```
|
||||
|
||||
2. **Apply reverse operations**:
|
||||
- Create Apex script to reverse changes
|
||||
- Test in sandbox first
|
||||
- Execute in production
|
||||
|
||||
**Timeline**: 2-6 hours
|
||||
|
||||
## Post-Rollback Steps
|
||||
|
||||
After rollback is complete:
|
||||
|
||||
1. **Verify Functionality**:
|
||||
- [ ] Execute smoke tests
|
||||
- [ ] Confirm critical workflows work
|
||||
- [ ] Check automation (triggers, flows) operates correctly
|
||||
- [ ] Review debug logs for errors
|
||||
|
||||
2. **Communication**:
|
||||
- [ ] Notify users rollback is complete
|
||||
- [ ] Send post-mortem summary to stakeholders
|
||||
- [ ] Update status page / internal wiki
|
||||
|
||||
3. **Root Cause Analysis**:
|
||||
- [ ] Document what went wrong
|
||||
- [ ] Identify why issues weren't caught pre-deployment
|
||||
- [ ] Update deployment checklist to prevent recurrence
|
||||
- [ ] Schedule retrospective with team
|
||||
|
||||
4. **Next Steps**:
|
||||
- [ ] Fix issues in development environment
|
||||
- [ ] Add test cases to catch similar issues
|
||||
- [ ] Re-validate deployment in sandbox
|
||||
- [ ] Schedule new deployment with fixes
|
||||
|
||||
## Emergency Contacts
|
||||
|
||||
**Production Issues**:
|
||||
- On-call DevOps: [contact info]
|
||||
- Salesforce Support: [premier support phone]
|
||||
- Release Manager: [contact info]
|
||||
|
||||
**Stakeholder Notifications**:
|
||||
- Product Owner: [contact info]
|
||||
- Business Analyst: [contact info]
|
||||
- Executive Sponsor: [contact info]
|
||||
|
||||
## Rollback Decision Matrix
|
||||
|
||||
| Severity | Impact | Rollback? | Timeline |
|
||||
|----------|--------|-----------|----------|
|
||||
| **Critical** | >50% users affected, core functionality broken | Yes | Immediate (< 1 hour) |
|
||||
| **High** | 10-50% users affected, workaround exists | Consider | Within 2-4 hours |
|
||||
| **Medium** | <10% users affected, non-critical features | No, hotfix instead | Plan fix for next release |
|
||||
| **Low** | Edge case, cosmetic issues | No | Address in backlog |
|
||||
|
||||
## Lessons Learned Template
|
||||
|
||||
After each rollback, document lessons learned:
|
||||
|
||||
```markdown
|
||||
# Rollback Post-Mortem: [Date]
|
||||
|
||||
## Deployment Summary
|
||||
- Deployment date/time: [timestamp]
|
||||
- Components deployed: [list]
|
||||
- Rollback date/time: [timestamp]
|
||||
- Rollback method used: [method]
|
||||
|
||||
## Issue Description
|
||||
[Describe what went wrong]
|
||||
|
||||
## Root Cause
|
||||
[Why did the issue occur?]
|
||||
|
||||
## Detection
|
||||
- How was the issue discovered?
|
||||
- How long after deployment?
|
||||
- Who reported it?
|
||||
|
||||
## Impact
|
||||
- Number of users affected: [count]
|
||||
- Business processes impacted: [list]
|
||||
- Duration of impact: [timespan]
|
||||
|
||||
## Resolution
|
||||
- Rollback timeline: [start - end]
|
||||
- Additional fixes required: [list]
|
||||
|
||||
## Prevention
|
||||
- What tests would have caught this?
|
||||
- Process improvements needed:
|
||||
- Deployment checklist updates:
|
||||
|
||||
## Action Items
|
||||
- [ ] [Action item with owner]
|
||||
- [ ] [Action item with owner]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Practice rollbacks in sandbox** - Don't wait for an emergency to learn the process
|
||||
2. **Maintain detailed backups** - Automate metadata and data backups before every deployment
|
||||
3. **Use version control tags** - Tag every production deployment for easy identification
|
||||
4. **Document everything** - Keep a deployment log with timestamps and decisions
|
||||
5. **Communicate proactively** - Keep stakeholders informed throughout the process
|
||||
6. **Set time limits** - If rollback takes >2 hours, consider alternative approaches
|
||||
7. **Test the rollback** - Validate in sandbox that the rollback process works
|
||||
|
||||
## Rollback Scripts Repository
|
||||
|
||||
Keep commonly-used rollback scripts in version control:
|
||||
|
||||
```
|
||||
scripts/rollback/
|
||||
├── rollback-apex.sh # Rollback Apex classes
|
||||
├── rollback-lwc.sh # Rollback Lightning Web Components
|
||||
├── rollback-flows.sh # Rollback flows and processes
|
||||
├── rollback-data.sh # Restore data from backup
|
||||
└── verify-rollback.sh # Post-rollback verification
|
||||
```
|
||||
|
||||
## Testing Rollback Procedures
|
||||
|
||||
**Quarterly rollback drill**:
|
||||
1. Deploy a test change to sandbox
|
||||
2. Wait 1 hour
|
||||
3. Execute full rollback procedure
|
||||
4. Time the process
|
||||
5. Document any issues
|
||||
6. Update procedures as needed
|
||||
|
||||
This ensures the team is prepared when a real rollback is needed.
|
||||
@ -1,207 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Metadata validation script for Salesforce deployments
|
||||
# Checks for common issues before deployment
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
echo "=========================================="
|
||||
echo "Salesforce Metadata Validation"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if we're in an SFDX project
|
||||
if [ ! -f "sfdx-project.json" ]; then
|
||||
echo -e "${RED}ERROR: Not in an SFDX project directory${NC}"
|
||||
echo "Please run this script from your project root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Found SFDX project"
|
||||
echo ""
|
||||
|
||||
# 1. Check XML format
|
||||
echo "Checking XML format..."
|
||||
XML_ERRORS=0
|
||||
|
||||
find force-app -name "*.xml" -o -name "*.object" -o -name "*.layout" | while read file; do
|
||||
if ! xmllint --noout "$file" 2>/dev/null; then
|
||||
echo -e "${RED}✗ Invalid XML: $file${NC}"
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $XML_ERRORS -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All XML files are well-formed${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Found $XML_ERRORS XML errors${NC}"
|
||||
ERRORS=$((ERRORS + XML_ERRORS))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. Check API versions
|
||||
echo "Checking API versions..."
|
||||
MIN_API_VERSION=56.0
|
||||
|
||||
find force-app -name "*.cls-meta.xml" -o -name "*.trigger-meta.xml" | while read file; do
|
||||
VERSION=$(grep -o '<apiVersion>[0-9.]*</apiVersion>' "$file" | grep -o '[0-9.]*')
|
||||
if [ -n "$VERSION" ] && (( $(echo "$VERSION < $MIN_API_VERSION" | bc -l) )); then
|
||||
echo -e "${YELLOW}⚠ Old API version $VERSION in: $file${NC}"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}✓ API version check complete${NC}"
|
||||
echo ""
|
||||
|
||||
# 3. Check for deprecated features
|
||||
echo "Checking for deprecated features..."
|
||||
|
||||
# Check for old-style custom settings
|
||||
DEPRECATED_SETTINGS=$(find force-app -name "*.object-meta.xml" -exec grep -l "customSettingsType>List" {} \; | wc -l)
|
||||
if [ $DEPRECATED_SETTINGS -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠ Found $DEPRECATED_SETTINGS list custom settings (consider Custom Metadata Types)${NC}"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Check for old Aura components (should migrate to LWC)
|
||||
AURA_COMPONENTS=$(find force-app -name "*.cmp" | wc -l)
|
||||
if [ $AURA_COMPONENTS -gt 5 ]; then
|
||||
echo -e "${YELLOW}⚠ Found $AURA_COMPONENTS Aura components (consider migrating to LWC)${NC}"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Deprecation check complete${NC}"
|
||||
echo ""
|
||||
|
||||
# 4. Check naming conventions
|
||||
echo "Checking naming conventions..."
|
||||
|
||||
# Check for spaces in API names (not allowed)
|
||||
SPACE_ERRORS=$(find force-app -name "*.xml" -exec grep -l "fullName>.*\s.*<" {} \; | wc -l)
|
||||
if [ $SPACE_ERRORS -gt 0 ]; then
|
||||
echo -e "${RED}✗ Found $SPACE_ERRORS files with spaces in API names${NC}"
|
||||
ERRORS=$((ERRORS + SPACE_ERRORS))
|
||||
fi
|
||||
|
||||
# Check for lowercase custom object names (should be PascalCase)
|
||||
LOWERCASE_OBJECTS=$(find force-app/main/default/objects -name "*.object-meta.xml" -exec basename {} \; | grep -E "^[a-z]" | wc -l)
|
||||
if [ $LOWERCASE_OBJECTS -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠ Found $LOWERCASE_OBJECTS custom objects with lowercase names${NC}"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Naming convention check complete${NC}"
|
||||
echo ""
|
||||
|
||||
# 5. Check for hardcoded IDs or URLs
|
||||
echo "Checking for hardcoded values..."
|
||||
|
||||
# Check for record IDs (15 or 18 character Salesforce IDs)
|
||||
HARDCODED_IDS=$(grep -r -E "'[a-zA-Z0-9]{15,18}'" force-app/main/default/classes force-app/main/default/triggers 2>/dev/null | wc -l)
|
||||
if [ $HARDCODED_IDS -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠ Found $HARDCODED_IDS potential hardcoded record IDs in Apex${NC}"
|
||||
echo " Review and replace with custom settings or custom metadata"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Check for hardcoded URLs
|
||||
HARDCODED_URLS=$(grep -r "https://.*\.salesforce\.com" force-app/main/default/ 2>/dev/null | wc -l)
|
||||
if [ $HARDCODED_URLS -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠ Found $HARDCODED_URLS hardcoded Salesforce URLs${NC}"
|
||||
echo " Consider using Named Credentials or Custom Settings"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Hardcoded value check complete${NC}"
|
||||
echo ""
|
||||
|
||||
# 6. Check meta.xml files
|
||||
echo "Checking for missing meta.xml files..."
|
||||
|
||||
MISSING_META=0
|
||||
find force-app -name "*.cls" | while read file; do
|
||||
if [ ! -f "${file}-meta.xml" ]; then
|
||||
echo -e "${RED}✗ Missing meta.xml for: $file${NC}"
|
||||
MISSING_META=$((MISSING_META + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $MISSING_META -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All source files have meta.xml files${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Found $MISSING_META missing meta.xml files${NC}"
|
||||
ERRORS=$((ERRORS + MISSING_META))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7. Check for test classes
|
||||
echo "Checking test coverage..."
|
||||
|
||||
TOTAL_CLASSES=$(find force-app -name "*.cls" | wc -l)
|
||||
TEST_CLASSES=$(find force-app -name "*Test.cls" -o -name "*_Test.cls" | wc -l)
|
||||
|
||||
if [ $TOTAL_CLASSES -gt 0 ]; then
|
||||
TEST_RATIO=$(echo "scale=2; $TEST_CLASSES / $TOTAL_CLASSES * 100" | bc)
|
||||
echo "Test class ratio: $TEST_CLASSES/$TOTAL_CLASSES (${TEST_RATIO}%)"
|
||||
|
||||
if (( $(echo "$TEST_RATIO < 50" | bc -l) )); then
|
||||
echo -e "${YELLOW}⚠ Low test class coverage (${TEST_RATIO}%)${NC}"
|
||||
echo " Consider adding more test classes"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓ Good test class coverage (${TEST_RATIO}%)${NC}"
|
||||
fi
|
||||
else
|
||||
echo "No Apex classes found"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 8. Check package.xml
|
||||
echo "Checking package.xml..."
|
||||
|
||||
if [ -f "manifest/package.xml" ]; then
|
||||
echo -e "${GREEN}✓ Found package.xml${NC}"
|
||||
|
||||
# Validate it's well-formed XML
|
||||
if xmllint --noout manifest/package.xml 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ package.xml is valid XML${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ package.xml is invalid XML${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠ No package.xml found in manifest/ directory${NC}"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo "=========================================="
|
||||
echo "Validation Summary"
|
||||
echo "=========================================="
|
||||
echo -e "Errors: ${RED}$ERRORS${NC}"
|
||||
echo -e "Warnings: ${YELLOW}$WARNINGS${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -eq 0 ] && [ $WARNINGS -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Metadata validation passed!${NC}"
|
||||
echo "Your metadata is ready for deployment."
|
||||
exit 0
|
||||
elif [ $ERRORS -eq 0 ]; then
|
||||
echo -e "${YELLOW}⚠ Validation passed with warnings${NC}"
|
||||
echo "Review warnings before deploying."
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Validation failed${NC}"
|
||||
echo "Fix errors before deploying."
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Reference in New Issue
Block a user