From 28de60cd53895956d10f187245271149102394e1 Mon Sep 17 00:00:00 2001 From: Jeff Douglas Date: Wed, 25 Mar 2026 13:29:44 -0400 Subject: [PATCH] added apex and apex test --- skills/generating-apex-test/SKILL.md | 119 ++++++++ .../assets/test-class-template.cls | 124 ++++++++ .../assets/test-data-factory-template.cls | 112 +++++++ .../references/assertion-patterns.md | 165 +++++++++++ .../references/async-testing.md | 276 ++++++++++++++++++ .../references/mocking-patterns.md | 219 ++++++++++++++ .../references/test-data-factory.md | 176 +++++++++++ skills/generating-apex/SKILL.md | 267 +++++++++++++++++ skills/generating-apex/assets/abstract.cls | 128 ++++++++ skills/generating-apex/assets/batch.cls | 125 ++++++++ skills/generating-apex/assets/domain.cls | 102 +++++++ skills/generating-apex/assets/dto.cls | 108 +++++++ skills/generating-apex/assets/exception.cls | 51 ++++ skills/generating-apex/assets/interface.cls | 25 ++ skills/generating-apex/assets/queueable.cls | 92 ++++++ skills/generating-apex/assets/schedulable.cls | 75 +++++ skills/generating-apex/assets/selector.cls | 92 ++++++ skills/generating-apex/assets/service.cls | 69 +++++ skills/generating-apex/assets/utility.cls | 97 ++++++ .../references/AccountDeduplicationBatch.cls | 148 ++++++++++ .../references/AccountSelector.cls | 193 ++++++++++++ .../references/AccountService.cls | 201 +++++++++++++ 22 files changed, 2964 insertions(+) create mode 100644 skills/generating-apex-test/SKILL.md create mode 100644 skills/generating-apex-test/assets/test-class-template.cls create mode 100644 skills/generating-apex-test/assets/test-data-factory-template.cls create mode 100644 skills/generating-apex-test/references/assertion-patterns.md create mode 100644 skills/generating-apex-test/references/async-testing.md create mode 100644 skills/generating-apex-test/references/mocking-patterns.md create mode 100644 skills/generating-apex-test/references/test-data-factory.md create mode 100644 skills/generating-apex/SKILL.md create mode 100644 skills/generating-apex/assets/abstract.cls create mode 100644 skills/generating-apex/assets/batch.cls create mode 100644 skills/generating-apex/assets/domain.cls create mode 100644 skills/generating-apex/assets/dto.cls create mode 100644 skills/generating-apex/assets/exception.cls create mode 100644 skills/generating-apex/assets/interface.cls create mode 100644 skills/generating-apex/assets/queueable.cls create mode 100644 skills/generating-apex/assets/schedulable.cls create mode 100644 skills/generating-apex/assets/selector.cls create mode 100644 skills/generating-apex/assets/service.cls create mode 100644 skills/generating-apex/assets/utility.cls create mode 100644 skills/generating-apex/references/AccountDeduplicationBatch.cls create mode 100644 skills/generating-apex/references/AccountSelector.cls create mode 100644 skills/generating-apex/references/AccountService.cls diff --git a/skills/generating-apex-test/SKILL.md b/skills/generating-apex-test/SKILL.md new file mode 100644 index 0000000..6ef92df --- /dev/null +++ b/skills/generating-apex-test/SKILL.md @@ -0,0 +1,119 @@ +--- +name: generating-apex-test +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. + + Trigger on any request to create, write, or generate an Apex test class — + even when the user doesn't say "test" explicitly. Activate on phrases like: + "write tests for," "add coverage for," "test this class," "create a mock," + "I need test data," "improve coverage," or any mention of Apex testing, + assertions, or test factories. +--- + +# 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 accounts = TestDataFactory.createAccounts(200, true); + } + + @isTest + static void shouldPerformExpectedBehavior_WhenValidInput() { + // Given: Setup specific test state + List accounts = [SELECT Id, Name FROM Account]; + + // When: Execute the code under test + Test.startTest(); + MyService.processAccounts(accounts); + Test.stopTest(); + + // Then: Assert expected outcomes + List updated = [SELECT Id, Status__c FROM Account]; + System.assertEquals(200, updated.size(), 'All accounts should be processed'); + for (Account acc : updated) { + System.assertEquals('Processed', acc.Status__c, 'Status should be updated'); + } + } + + @isTest + static void shouldThrowException_WhenInvalidInput() { + // Given + List emptyList = new List(); + + // When/Then + Test.startTest(); + try { + MyService.processAccounts(emptyList); + System.assert(false, 'Expected MyCustomException to be thrown'); + } catch (MyCustomException e) { + System.assert(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 + +## Asset Templates + +Ready-to-use scaffolds for common test patterns: + +- **[assets/test-class-template.cls](assets/test-class-template.cls)** - Starter test class with positive, negative, bulk, and governor limit test stubs +- **[assets/test-data-factory-template.cls](assets/test-data-factory-template.cls)** - TestDataFactory with Account, Contact, Opportunity, User factories and field override support + +## 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 | \ No newline at end of file diff --git a/skills/generating-apex-test/assets/test-class-template.cls b/skills/generating-apex-test/assets/test-class-template.cls new file mode 100644 index 0000000..e759fd3 --- /dev/null +++ b/skills/generating-apex-test/assets/test-class-template.cls @@ -0,0 +1,124 @@ +/** + * @description Test class for {ClassUnderTest}. + * Tests bulk operations (200+ records), positive/negative paths, + * and exception handling. + * @author Generated by Apex Test Writer Skill + */ +@isTest +private class {ClassUnderTest}Test { + + // ─── Test Setup ─────────────────────────────────────────────────────── + + @TestSetup + static void setupTestData() { + // Create shared test data using TestDataFactory + // List accounts = TestDataFactory.createAccounts(200, true); + } + + // ─── Positive Tests ─────────────────────────────────────────────────── + + @isTest + static void shouldPerformExpectedBehavior_WhenValidInput() { + // Given: Setup specific test state + // List accounts = [SELECT Id, Name FROM Account]; + + // When: Execute the code under test + Test.startTest(); + // {ClassUnderTest}.methodUnderTest(params); + Test.stopTest(); + + // Then: Assert expected outcomes + // System.assertEquals(expected, actual, 'Descriptive failure message'); + } + + @isTest + static void shouldHandleBulkRecords_WhenProcessing200() { + // Given: 200+ records to verify bulkification + // List accounts = [SELECT Id FROM Account]; + // System.assertEquals(200, accounts.size(), 'Should have 200 test records'); + + // When + Test.startTest(); + // {ClassUnderTest}.bulkMethod(accounts); + Test.stopTest(); + + // Then: Verify all records processed + // List results = [SELECT Id, Status__c FROM Account]; + // for (Account acc : results) { + // System.assertEquals('Processed', acc.Status__c, 'All records should be processed'); + // } + } + + // ─── Negative Tests ─────────────────────────────────────────────────── + + @isTest + static void shouldThrowException_WhenNullInput() { + Boolean exceptionThrown = false; + String exceptionMessage = ''; + + Test.startTest(); + try { + // {ClassUnderTest}.methodUnderTest(null); + } catch (Exception e) { + exceptionThrown = true; + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Exception should be thrown for null input'); + System.assert(exceptionMessage.contains('cannot be null'), + 'Exception message should mention null input'); + } + + @isTest + static void shouldReturnEmpty_WhenEmptyInput() { + Test.startTest(); + // List results = {ClassUnderTest}.methodUnderTest(new List()); + Test.stopTest(); + + // System.assert(results.isEmpty(), 'Should return empty list for empty input'); + } + + // ─── Edge Case Tests ────────────────────────────────────────────────── + + @isTest + static void shouldHandleMixedRecords_WhenSomeQualify() { + // Given: Mix of qualifying and non-qualifying records + // List accounts = [SELECT Id, Status__c FROM Account]; + // Integer half = accounts.size() / 2; + // for (Integer i = 0; i < half; i++) { + // accounts[i].Status__c = 'Qualifying'; + // } + // update accounts; + + // When + Test.startTest(); + // {ClassUnderTest}.conditionalMethod(accounts); + Test.stopTest(); + + // Then: Only qualifying records should be affected + // List qualifying = [SELECT Id FROM Account WHERE Processed__c = true]; + // System.assertEquals(half, qualifying.size(), 'Only qualifying records should be processed'); + } + + // ─── Governor Limit Tests ───────────────────────────────────────────── + + @isTest + static void shouldNotExceedGovernorLimits_WhenBulkProcessing() { + // Given + // List accounts = [SELECT Id FROM Account]; + + Test.startTest(); + // {ClassUnderTest}.heavyMethod(accounts); + Test.stopTest(); + + System.assert(Limits.getDmlStatements() < Limits.getLimitDmlStatements(), + 'Should not exceed DML statement limit'); + System.assert(Limits.getQueries() < Limits.getLimitQueries(), + 'Should not exceed SOQL query limit'); + } + + // ─── Helper Methods ─────────────────────────────────────────────────── + + // Add test-specific helper methods here +} diff --git a/skills/generating-apex-test/assets/test-data-factory-template.cls b/skills/generating-apex-test/assets/test-data-factory-template.cls new file mode 100644 index 0000000..bd0304c --- /dev/null +++ b/skills/generating-apex-test/assets/test-data-factory-template.cls @@ -0,0 +1,112 @@ +/** + * @description Centralized factory for creating test data with sensible defaults. + * All methods accept a doInsert flag for flexibility. + * Bulk methods create multiple records; single-record methods delegate to bulk. + * @author Generated by Apex Test Writer Skill + */ +@isTest +public class TestDataFactory { + + // ─── Accounts ───────────────────────────────────────────────────────── + + public static List createAccounts(Integer count, Boolean doInsert) { + List accounts = new List(); + for (Integer i = 0; i < count; i++) { + accounts.add(new Account( + Name = 'Test Account ' + i, + BillingCity = 'San Francisco', + BillingState = 'CA', + 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 createContacts(List accounts, Integer countPerAccount, Boolean doInsert) { + List contacts = new List(); + Integer idx = 0; + for (Account acc : accounts) { + for (Integer i = 0; i < countPerAccount; i++) { + contacts.add(new Contact( + FirstName = 'Test', + LastName = 'Contact ' + idx, + Email = 'test.contact' + idx + '@example.com', + AccountId = acc.Id + )); + idx++; + } + } + if (doInsert) insert contacts; + return contacts; + } + + // ─── Opportunities ──────────────────────────────────────────────────── + + public static List createOpportunities(List accounts, Integer countPerAccount, Boolean doInsert) { + List opps = new List(); + Integer idx = 0; + for (Account acc : accounts) { + for (Integer i = 0; i < countPerAccount; i++) { + opps.add(new Opportunity( + Name = 'Test Opportunity ' + idx, + AccountId = acc.Id, + StageName = 'Prospecting', + CloseDate = Date.today().addDays(30), + Amount = 10000 + (idx * 1000) + )); + idx++; + } + } + 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; + } + + // ─── Field Override Pattern ──────────────────────────────────────────── + + public static Account createAccount(Map fieldOverrides, Boolean doInsert) { + Account acc = new Account( + Name = 'Test Account', + Industry = 'Technology' + ); + for (String fieldName : fieldOverrides.keySet()) { + acc.put(fieldName, fieldOverrides.get(fieldName)); + } + if (doInsert) insert acc; + return acc; + } + + // ─── Custom Objects ─────────────────────────────────────────────────── + // Add methods for your custom objects following the same pattern: + // public static List createMyObjects(Integer count, Boolean doInsert) { ... } +} diff --git a/skills/generating-apex-test/references/assertion-patterns.md b/skills/generating-apex-test/references/assertion-patterns.md new file mode 100644 index 0000000..f74bc84 --- /dev/null +++ b/skills/generating-apex-test/references/assertion-patterns.md @@ -0,0 +1,165 @@ +# Assertion Patterns + +## Assertion Methods + +| Method | Use Case | +|--------|----------| +| `System.assertEquals(expected, actual, msg)` | Exact equality | +| `System.assertNotEquals(expected, actual, msg)` | Value should differ | +| `System.assert(condition, msg)` | Boolean condition | + +**Always include the third parameter (message)** - Makes test failures meaningful. + +## Good vs Bad Assertions + +### ❌ Bad: No message, tests coverage not behavior + +```apex +System.assertEquals(true, result); +System.assert(accounts.size() > 0); +``` + +### ✅ Good: Descriptive message, tests specific behavior + +```apex +System.assertEquals(true, result, 'Service should return true for valid input'); +System.assertEquals(200, accounts.size(), 'All 200 accounts should be processed'); +``` + +## Common Assertion Patterns + +### Collection Size + +```apex +// Exact count +System.assertEquals(200, results.size(), 'Should process all 200 records'); + +// Not empty +System.assert(!results.isEmpty(), 'Results should not be empty'); + +// Empty +System.assert(results.isEmpty(), 'No results expected for invalid input'); +``` + +### Field Values + +```apex +// Single record +System.assertEquals('Processed', acc.Status__c, 'Account status should be updated to Processed'); + +// All records in collection +for (Account acc : updatedAccounts) { + System.assertEquals('Active', acc.Status__c, + 'Account ' + acc.Name + ' should have Active status'); +} +``` + +### Exception Testing + +```apex +@isTest +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(exceptionThrown, 'MyCustomException should be thrown for null input'); + System.assert(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(sr.isSuccess(), 'Insert should succeed: ' + sr.getErrors()); +} + +// Expected failures +Database.SaveResult sr = Database.insert(invalidAccount, false); +System.assert(!sr.isSuccess(), 'Insert should fail for invalid data'); +System.assert(sr.getErrors()[0].getMessage().contains('REQUIRED_FIELD_MISSING'), + 'Error should indicate missing required field'); +``` + +### Comparing Objects + +```apex +// Compare specific fields, not entire objects +System.assertEquals(expected.Name, actual.Name, 'Names should match'); +System.assertEquals(expected.Status__c, actual.Status__c, 'Status should match'); + +// Or use JSON for deep comparison (use sparingly) +System.assertEquals( + JSON.serialize(expected), + JSON.serialize(actual), + 'Objects should be identical' +); +``` + +### Date/DateTime Assertions + +```apex +// Exact date +System.assertEquals(Date.today(), record.CreatedDate__c, 'Should be created today'); + +// Date within range +System.assert(record.DueDate__c >= Date.today(), 'Due date should be in the future'); +System.assert(record.DueDate__c <= Date.today().addDays(30), + 'Due date should be within 30 days'); +``` + +### Null Checks + +```apex +// Should be null +System.assertEquals(null, result.ErrorMessage__c, 'No error expected for valid input'); + +// Should not be null +System.assertNotEquals(null, result.Id, 'Record should have been inserted'); +``` + +## Anti-Patterns to Avoid + +### ❌ Testing implementation, not behavior + +```apex +// Bad: Testing that a specific method was called +System.assert(MyClass.methodWasCalled, 'Method should be called'); + +// Good: Testing the observable outcome +System.assertEquals('Expected Value', record.Field__c, 'Field should be updated'); +``` + +### ❌ Overly generic assertions + +```apex +// Bad: Passes for any non-empty result +System.assert(results.size() > 0); + +// Good: Verifies exact expected count +System.assertEquals(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.assertEquals('Success', r.status, 'Processing should succeed'); +System.assertEquals(0, r.errorCount, 'No errors should occur'); +``` diff --git a/skills/generating-apex-test/references/async-testing.md b/skills/generating-apex-test/references/async-testing.md new file mode 100644 index 0000000..db651f9 --- /dev/null +++ b/skills/generating-apex-test/references/async-testing.md @@ -0,0 +1,276 @@ +# 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 +static void shouldProcessAllRecords_WhenBatchExecutes() { + // Given: Create test data + List 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 updated = [SELECT Id, Status__c FROM Account]; + for (Account acc : updated) { + System.assertEquals('Processed', acc.Status__c, + 'Batch should update all account statuses'); + } +} +``` + +### Testing Batch with Failures + +```apex +@isTest +static void shouldLogErrors_WhenRecordsFail() { + // Given: Create mix of valid and invalid records + List accounts = TestDataFactory.createAccounts(198, true); + + // Create 2 accounts that will fail processing + List invalidAccounts = new List(); + 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 errors = [SELECT Id, Message__c FROM Error_Log__c]; + System.assertEquals(2, errors.size(), 'Should log 2 failed records'); +} +``` + +### Testing Batch Scope + +```apex +@isTest +static void shouldRespectBatchSize() { + // Given + List 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 processed = [SELECT Id FROM Account WHERE Processed__c = true]; + System.assertEquals(250, processed.size(), 'All records should be processed'); +} +``` + +## Queueable Testing + +### Basic Queueable Test + +```apex +@isTest +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.assertEquals('Processed', updated.Status__c, + 'Queueable should update account status'); +} +``` + +### Testing Queueable Chaining + +Chained queueables only execute the first job in tests: + +```apex +@isTest +static void shouldChainNextJob_WhenMoreRecordsExist() { + // Given: More records than one queueable can process + List 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 processed = [SELECT Id FROM Account WHERE Processed__c = true]; + System.assertEquals(100, processed.size(), 'First batch should process 100 records'); + + // Verify chain was enqueued (check AsyncApexJob) + List jobs = [ + SELECT Id, Status, JobType + FROM AsyncApexJob + WHERE ApexClass.Name = 'MyChainedQueueable' + ]; + System.assert(jobs.size() >= 1, 'Chained job should be enqueued'); +} +``` + +### Testing Queueable with Callouts + +```apex +@isTest +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.assertEquals('Synced', updated.External_Status__c, + 'Should update status after successful callout'); +} +``` + +## Future Method Testing + +```apex +@isTest +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.assertEquals(true, updated.Processed__c, 'Future should process record'); +} +``` + +## Scheduled Apex Testing + +### Testing Scheduled Execution + +```apex +@isTest +static void shouldExecuteScheduledJob() { + // Given + List 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 processed = [SELECT Id FROM Account WHERE Processed__c = true]; + System.assertEquals(50, processed.size(), 'Scheduled job should process records'); +} +``` + +### Testing Schedule Registration + +```apex +@isTest +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.assertEquals('0 0 6 * * ?', ct.CronExpression, 'CRON should match'); + System.assertEquals('WAITING', ct.State, 'Job should be waiting'); +} +``` + +## Testing Async Limits + +```apex +@isTest +static void shouldNotExceedQueueableLimits() { + // Given: Setup that might enqueue multiple jobs + List 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(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 results = [SELECT Id FROM Account WHERE Processed__c = true]; +System.assertEquals(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(); +``` diff --git a/skills/generating-apex-test/references/mocking-patterns.md b/skills/generating-apex-test/references/mocking-patterns.md new file mode 100644 index 0000000..a308227 --- /dev/null +++ b/skills/generating-apex-test/references/mocking-patterns.md @@ -0,0 +1,219 @@ +# 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 +static void shouldProcessApiResponse_WhenCalloutSucceeds() { + // Given + String mockResponse = '{"status": "success", "data": [{"id": "123"}]}'; + Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, mockResponse)); + + // When + Test.startTest(); + List results = MyIntegrationService.fetchRecords(); + Test.stopTest(); + + // Then + System.assertEquals(1, results.size(), 'Should parse one record from response'); + System.assertEquals('123', results[0].externalId, 'Should extract correct ID'); +} + +@isTest +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.assertEquals(false, result.isSuccess, 'Should indicate failure'); + System.assert(result.errorMessage.contains('Unauthorized'), 'Should capture error'); +} +``` + +### Multi-Request Mock + +For services making multiple callouts: + +```apex +@isTest +public class MultiRequestMock implements HttpCalloutMock { + + private Map endpointResponses; + + public MultiRequestMock(Map 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 mocks = new Map(); + +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 +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.assertNotEquals(null, 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 paramTypes, + List paramNames, + List args + ) { + if (stubbedMethodName == 'getAccountData') { + return new AccountData('Mock Account', 'Active'); + } + return null; + } +} + +// Usage in test: +@isTest +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(result.contains('Mock Account'), 'Should use mocked data'); +} +``` + +## Email Mocking + +Apex sends real emails by default. Use limits to verify: + +```apex +@isTest +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.assertEquals( + emailsBefore + 1, + Limits.getEmailInvocations(), + 'One email should be sent' + ); +} +``` + +## Platform Event Testing + +```apex +@isTest +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 logs = [SELECT Id FROM EventLog__c WHERE AccountId__c = :acc.Id]; + System.assertEquals(1, logs.size(), 'Event handler should create log record'); +} +``` diff --git a/skills/generating-apex-test/references/test-data-factory.md b/skills/generating-apex-test/references/test-data-factory.md new file mode 100644 index 0000000..b4cc3df --- /dev/null +++ b/skills/generating-apex-test/references/test-data-factory.md @@ -0,0 +1,176 @@ +# 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 createAccounts(Integer count, Boolean doInsert) { + List accounts = new List(); + 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 createContacts(List accounts, Integer countPerAccount, Boolean doInsert) { + List contacts = new List(); + 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 createOpportunities(List accounts, Integer countPerAccount, Boolean doInsert) { + List opps = new List(); + 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 createMyObjects(Integer count, Boolean doInsert) { ... } +} +``` + +## Field Override Pattern + +Allow callers to override default values: + +```apex +public static Account createAccount(Map 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{ + '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 diff --git a/skills/generating-apex/SKILL.md b/skills/generating-apex/SKILL.md new file mode 100644 index 0000000..e070a13 --- /dev/null +++ b/skills/generating-apex/SKILL.md @@ -0,0 +1,267 @@ +--- +name: generating-apex +description: > + Generate production-ready Apex classes for Salesforce following enterprise + best practices. Covers all standard class types: service, selector, domain, + batch, queueable, schedulable, DTO, utility, interface, abstract, and custom + exception classes. Also applies when refactoring or restructuring existing + Apex classes. + + SCOPE: Apex classes only. Does not apply to triggers or unit tests. + + Trigger on any request to create, write, build, generate, or scaffold an + Apex class — even when the user doesn't say "Apex class" explicitly. + Activate 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 description of Salesforce server-side logic + that implies a class solution. +--- + +# 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` 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 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 + + + {API_VERSION} + Active + +``` + +Default `apiVersion` is `62.0` unless the user specifies otherwise. + +--- + +## Class Type–Specific Instructions + +### Service Classes +- Read and follow: `assets/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: `assets/selector.cls` +- One Selector per SObject (or per logical query domain) +- Return `List` or `Map` +- 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: `assets/domain.cls` +- Encapsulate field-level defaults, derivations, and validations +- Operate on `List` — designed to be called from triggers or services +- No SOQL or DML — only in-memory SObject manipulation + +### Batch Classes +- Read and follow: `assets/batch.cls` +- Implement `Database.Batchable` 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: `assets/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: `assets/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: `assets/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: `assets/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: `assets/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: `assets/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: `assets/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 `assets/` +3. Read relevant examples from `references/` 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 diff --git a/skills/generating-apex/assets/abstract.cls b/skills/generating-apex/assets/abstract.cls new file mode 100644 index 0000000..cf2716a --- /dev/null +++ b/skills/generating-apex/assets/abstract.cls @@ -0,0 +1,128 @@ +/** + * @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 getHeaders() { + * return new Map{ + * '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 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 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 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 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 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 {} +} diff --git a/skills/generating-apex/assets/batch.cls b/skills/generating-apex/assets/batch.cls new file mode 100644 index 0000000..07807d5 --- /dev/null +++ b/skills/generating-apex/assets/batch.cls @@ -0,0 +1,125 @@ +/** + * @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, Database.Stateful { + + // ─── Constants ─────────────────────────────────────────────────────── + private static final Integer DEFAULT_BATCH_SIZE = 200; + + // ─── Stateful Tracking ─────────────────────────────────────────────── + private Integer totalProcessed = 0; + private Integer totalErrors = 0; + private List errorMessages = new List(); + + // ─── 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 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{ + {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 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); + } +} diff --git a/skills/generating-apex/assets/domain.cls b/skills/generating-apex/assets/domain.cls new file mode 100644 index 0000000..e32615a --- /dev/null +++ b/skills/generating-apex/assets/domain.cls @@ -0,0 +1,102 @@ +/** + * @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 getChangedFields({SObject} oldRecord, {SObject} newRecord) { + Set changedFields = new Set(); + + if (oldRecord == null || newRecord == null) { + return changedFields; + } + + Map 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 +} diff --git a/skills/generating-apex/assets/dto.cls b/skills/generating-apex/assets/dto.cls new file mode 100644 index 0000000..7477663 --- /dev/null +++ b/skills/generating-apex/assets/dto.cls @@ -0,0 +1,108 @@ +/** + * @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 tags { get; set; } + + // TODO: Add additional properties as needed + + // ─── Constructors ──────────────────────────────────────────────────── + + /** + * @description No-arg constructor for deserialization compatibility + */ + public {ClassName}() { + this.tags = new List(); + 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 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); + } +} diff --git a/skills/generating-apex/assets/exception.cls b/skills/generating-apex/assets/exception.cls new file mode 100644 index 0000000..1d14d94 --- /dev/null +++ b/skills/generating-apex/assets/exception.cls @@ -0,0 +1,51 @@ +/** + * @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 failedRecordIds; + // public String detail; + // + // public {ClassName}Detail(String errorCode, List failedRecordIds, String detail) { + // this.errorCode = errorCode; + // this.failedRecordIds = failedRecordIds; + // this.detail = detail; + // } + // + // public override String toString() { + // return '[' + errorCode + '] ' + detail + ' (Records: ' + failedRecordIds + ')'; + // } + // } +} diff --git a/skills/generating-apex/assets/interface.cls b/skills/generating-apex/assets/interface.cls new file mode 100644 index 0000000..fc1da63 --- /dev/null +++ b/skills/generating-apex/assets/interface.cls @@ -0,0 +1,25 @@ +/** + * @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 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 params); + // Boolean isEligible(SObject record); + // List process(List records); +} diff --git a/skills/generating-apex/assets/queueable.cls b/skills/generating-apex/assets/queueable.cls new file mode 100644 index 0000000..eaa224a --- /dev/null +++ b/skills/generating-apex/assets/queueable.cls @@ -0,0 +1,92 @@ +/** + * @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 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 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 recordIds, Integer chainDepth) { + this.recordIds = recordIds ?? new Set(); + 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 remainingIds = new Set(); + + 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 + } +} diff --git a/skills/generating-apex/assets/schedulable.cls b/skills/generating-apex/assets/schedulable.cls new file mode 100644 index 0000000..1e9f932 --- /dev/null +++ b/skills/generating-apex/assets/schedulable.cls @@ -0,0 +1,75 @@ +/** + * @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); + } + } +} diff --git a/skills/generating-apex/assets/selector.cls b/skills/generating-apex/assets/selector.cls new file mode 100644 index 0000000..3512465 --- /dev/null +++ b/skills/generating-apex/assets/selector.cls @@ -0,0 +1,92 @@ +/** + * @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{ + '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 ids = new Set{ '001xx000003DGbY' }; + * List<{SObject}> results = {SObject}Selector.selectByIds(ids); + */ + public static List<{SObject}> selectByIds(Set 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 selectMapByIds(Set recordIds) { + return new Map(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{ 'Active' }); + */ + public static List<{SObject}> selectByField(String fieldName, Set 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 {} +} diff --git a/skills/generating-apex/assets/service.cls b/skills/generating-apex/assets/service.cls new file mode 100644 index 0000000..5f3a6c3 --- /dev/null +++ b/skills/generating-apex/assets/service.cls @@ -0,0 +1,69 @@ +/** + * @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 ids = new Set{ '001xx000003DGbY' }; + * List<{SObject}> results = {SObject}Service.process{SObject}s(ids); + */ + public static List<{SObject}> process{SObject}s(Set 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{ 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 {} +} diff --git a/skills/generating-apex/assets/utility.cls b/skills/generating-apex/assets/utility.cls new file mode 100644 index 0000000..9f3f9fc --- /dev/null +++ b/skills/generating-apex/assets/utility.cls @@ -0,0 +1,97 @@ +/** + * @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> chunks = {ClassName}.chunkList(myList, 200); + */ + public static List> chunkList(List items, Integer chunkSize) { + List> chunks = new List>(); + if (items == null || items.isEmpty() || chunkSize <= 0) { + return chunks; + } + + List currentChunk = new List(); + for (Object item : items) { + currentChunk.add(item); + if (currentChunk.size() == chunkSize) { + chunks.add(currentChunk); + currentChunk = new List(); + } + } + + 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 emails = {ClassName}.pluckStrings(contacts, 'Email'); + */ + public static Set pluckStrings(List records, String fieldName) { + Set values = new Set(); + 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; + } +} diff --git a/skills/generating-apex/references/AccountDeduplicationBatch.cls b/skills/generating-apex/references/AccountDeduplicationBatch.cls new file mode 100644 index 0000000..30ba97d --- /dev/null +++ b/skills/generating-apex/references/AccountDeduplicationBatch.cls @@ -0,0 +1,148 @@ +/** + * @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, 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 errorMessages = new List(); + + // ─── 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 scope) { + totalScanned += scope.size(); + + // Build duplicate keys for this batch + Map> dupeKeyMap = new Map>(); + for (Account acct : scope) { + String key = buildDuplicateKey(acct); + if (String.isNotBlank(key)) { + if (!dupeKeyMap.containsKey(key)) { + dupeKeyMap.put(key, new List()); + } + dupeKeyMap.get(key).add(acct); + } + } + + // Flag records that share a key with another record + List toUpdate = new List(); + for (String key : dupeKeyMap.keySet()) { + List 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 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{ 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 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); + } +} diff --git a/skills/generating-apex/references/AccountSelector.cls b/skills/generating-apex/references/AccountSelector.cls new file mode 100644 index 0000000..990f043 --- /dev/null +++ b/skills/generating-apex/references/AccountSelector.cls @@ -0,0 +1,193 @@ +/** + * @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{ + '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{ + '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 ids = new Set{ '001xx000003DGbY' }; + * List results = AccountSelector.selectByIds(ids); + */ + public static List selectByIds(Set recordIds) { + if (recordIds == null || recordIds.isEmpty()) { + return new List(); + } + + 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 selectMapByIds(Set recordIds) { + return new Map(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 selectWithBillingAddress(Set recordIds) { + if (recordIds == null || recordIds.isEmpty()) { + return new List(); + } + + 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 prospects = AccountSelector.selectByType( + * new Set{ 'Prospect', 'Customer - Direct' } + * ); + */ + public static List selectByType(Set accountTypes) { + if (accountTypes == null || accountTypes.isEmpty()) { + return new List(); + } + + 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 techAccounts = AccountSelector.selectByIndustryAndRevenue( + * new Set{ 'Technology' }, + * 1000000 + * ); + */ + public static List selectByIndustryAndRevenue(Set industries, Decimal minRevenue) { + if (industries == null || industries.isEmpty()) { + return new List(); + } + + 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 selectWithContacts(Set recordIds) { + if (recordIds == null || recordIds.isEmpty()) { + return new List(); + } + + 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 results = AccountSelector.countByIndustry(); + * for (AggregateResult ar : results) { + * System.debug(ar.get('Industry') + ': ' + ar.get('cnt')); + * } + */ + public static List countByIndustry() { + return [ + SELECT Industry, COUNT(Id) cnt + FROM Account + WHERE Industry != NULL + GROUP BY Industry + ORDER BY COUNT(Id) DESC + ]; + } +} diff --git a/skills/generating-apex/references/AccountService.cls b/skills/generating-apex/references/AccountService.cls new file mode 100644 index 0000000..eafee0a --- /dev/null +++ b/skills/generating-apex/references/AccountService.cls @@ -0,0 +1,201 @@ +/** + * @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> mergeMap = new Map>{ + * masterAcctId => new Set{ dupeId1, dupeId2 } + * }; + * List mergedIds = AccountService.mergeAccounts(mergeMap); + */ + public static List mergeAccounts(Map> mergeMap) { + if (mergeMap == null || mergeMap.isEmpty()) { + throw new AccountServiceException(ERROR_NULL_INPUT); + } + + // Collect all Ids for a single query + Set allIds = new Set(); + allIds.addAll(mergeMap.keySet()); + for (Set dupeIds : mergeMap.values()) { + allIds.addAll(dupeIds); + } + + // Query all records at once via Selector + Map accountMap = AccountSelector.selectMapByIds(allIds); + + List successfulMerges = new List(); + List errors = new List(); + + for (Id masterId : mergeMap.keySet()) { + Account master = accountMap.get(masterId); + if (master == null) { + errors.add('Master account not found: ' + masterId); + continue; + } + + List duplicates = new List(); + 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 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 accountIds) { + if (accountIds == null || accountIds.isEmpty()) { + throw new AccountServiceException(ERROR_NULL_INPUT); + } + + List accounts = AccountSelector.selectWithBillingAddress(accountIds); + Map territoryMap = loadTerritoryMappings(); + + List toUpdate = new List(); + 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 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{ accountId }); + } + + // ─── Private Helpers ───────────────────────────────────────────────── + + /** + * @description Loads territory mappings from Custom Metadata + * @return Map of territory key (State:Country) to territory name + */ + private static Map loadTerritoryMappings() { + Map mappings = new Map(); + 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> chunkAccounts(List accounts, Integer chunkSize) { + List> chunks = new List>(); + List current = new List(); + + for (Account acct : accounts) { + current.add(acct); + if (current.size() == chunkSize) { + chunks.add(current); + current = new List(); + } + } + 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 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 {} +}