diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1555ecd..496d70b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -198,4 +198,4 @@ A maintainer will merge your PR after approval. - Push the branch. - Open a PR. - Respond to feedback. -- Keep your fork updated. +- Keep your fork updated. \ No newline at end of file diff --git a/skills/generating-apex-test/CREDITS.md b/skills/generating-apex-test/CREDITS.md new file mode 100644 index 0000000..5db0496 --- /dev/null +++ b/skills/generating-apex-test/CREDITS.md @@ -0,0 +1,30 @@ +# Credits & Acknowledgments + +This skill was influenced by the [sf-skills](https://github.com/Jaganpro/sf-skills) repository and built upon the collective wisdom of the Salesforce developer community. We gratefully acknowledge the following authors and resources whose ideas, patterns, and best practices have shaped this skill. + +--- + +## Authors & Contributors + +### Jag Valaiyapathy (**[Jaganpro)](https://github.com/Jaganpro)** + +**[sf-skills](https://github.com/Jaganpro/sf-skills)** + +Key contributions influencing this skill: + +- Pioneering open-source Salesforce skills for agentic coding tools +- Apex code generation and review patterns +- Best practices, anti-patterns, and design patterns reference material +- Template library for common Apex class types + +This skill was influenced by the [sf-skills](https://github.com/Jaganpro/sf-skills) repository. + +--- + +## Special Thanks + +To the entire Salesforce developer community for sharing knowledge, writing blogs, creating open-source tools, and helping each other build better solutions. + +--- + +*If we've missed anyone whose work influenced these skills, please let us know so we can add proper attribution.* \ No newline at end of file diff --git a/skills/generating-apex-test/SKILL.md b/skills/generating-apex-test/SKILL.md index cc314e4..bbe360c 100644 --- a/skills/generating-apex-test/SKILL.md +++ b/skills/generating-apex-test/SKILL.md @@ -1,108 +1,199 @@ --- name: generating-apex-test -description: Apex test class generation with TestDataFactory patterns, bulk testing (200+ records), mocking strategies, and assertion best practices. Use this skill when the user asks to create, write, or improve Apex test classes, add coverage, build mocks, or implement testing patterns for triggers, services, batch jobs, queueables, and integrations. +description: Generate and validate Apex test classes with TestDataFactory patterns, bulk testing (251+ records), mocking strategies, assertion best practices, and disciplined test-fix loops. Use this skill when creating new Apex test classes, improving test coverage, debugging and fixing failing Apex tests, running test execution and coverage analysis, or implementing testing patterns for triggers, services, controllers, batch jobs, queueables, and integrations. Triggers on *Test.cls, *_Test.cls files, sf apex run test workflows, coverage reports, test-fix loops. Do NOT trigger for production Apex code (use generating-apex) or Jest/LWC tests. --- -# Apex Test Class Skill +# Generating Apex Tests + +Generate production-ready Apex test classes and run disciplined test-fix loops with coverage analysis. ## 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` +1. **One behavior per method** — each test method validates a single scenario. Separate positive, negative, and bulk tests. NEVER combine related-but-distinct inputs (e.g., null and empty) in one method — create `_NullInput_` and `_EmptyInput_` as separate test methods +2. **Bulkify tests** — test with 251+ records to cross the 200-record trigger batch boundary. **Batch Apex exception:** in test context only one `execute()` invocation runs, so set `batchSize >= testRecordCount`. See [references/async-testing.md](references/async-testing.md) +3. **Isolate test data** — every `@TestSetup` must delegate record creation to a `TestDataFactory` class. If none exists, create one first. Never build record lists inline in `@TestSetup`. Never rely on org data (`SeeAllData=false`) or hardcoded IDs. For duplicate rule handling, see [references/test-data-factory.md](references/test-data-factory.md) +4. **Assert meaningfully** — use exact expected values computed from test data setup. NEVER use range assertions or approximate counts when the value is deterministic. Always include failure messages. See [references/assertion-patterns.md](references/assertion-patterns.md) +5. **Use `Assert` class only** — `Assert.areEqual`, `Assert.isTrue`, `Assert.fail`, etc. Never use legacy `System.assert`, `System.assertEquals`, or `System.assertNotEquals` +6. **Mock external boundaries** — use `HttpCalloutMock` for callouts, `Test.setFixedSearchResults` for SOSL, DML mock classes for database isolation. Design for testability via constructor injection. See [references/mocking-patterns.md](references/mocking-patterns.md) +7. **Test negative paths** — validate error handling and exception scenarios, not just happy paths +8. **Wrap with start/stop** — pair `Test.startTest()` with `Test.stopTest()` to reset governor limits and force async execution ## Test.startTest() / Test.stopTest() -Always wrap the code under test: -- Resets governor limits for accurate limit testing -- Executes async operations synchronously (queueables, batch, future) +Always wrap the code under test in `Test.startTest()` / `Test.stopTest()`: + +- Resets governor limits so the test measures only the code under test +- Executes async operations synchronously (queueables, batch, future methods) - Fires scheduled jobs immediately -## Asset Templates +## Test Code Anti-Patterns -Ready-to-use scaffolds for common test patterns: +| Anti-Pattern | Fix | +|---|---| +| SOQL/DML inside loops | Query once before the loop; use `Map` for lookups | +| Magic numbers in assertions | Derive expected values from setup constants | +| God test class (>500 lines) | Split into multiple test classes by behavior area | +| Long test methods (>30 lines) | Extract Given/When/Then into helper methods | +| Generic `Exception` catch | Catch the specific expected type (e.g., `DmlException`) | -- **[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 +## Workflow -## Reference Files +### Step 1 — Gather Context -Detailed patterns for specific scenarios: +Before generating or fixing tests, identify: -- **[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 +- the target production class(es) under test +- existing test classes, test data factories, and setup helpers +- desired test scope (single class, specific methods, suite, or local tests) +- coverage threshold (75% minimum for deploy, 90%+ recommended) +- org alias when running tests against an org -## Quick Reference: What to Test +### Step 2 — Generate the Test Class + +Apply the structure, naming conventions, and patterns from the asset templates and reference docs. + +**MANDATORY — File Deliverables:** For every test class, create BOTH files: +1. `{ClassName}Test.cls` — the test class (use [assets/test-class-template.cls](assets/test-class-template.cls) as starting point) +2. `{ClassName}Test.cls-meta.xml` — the metadata file: + +```xml + + + 66.0 + Active + +``` + +If no `TestDataFactory` exists in the project, create `TestDataFactory.cls` + `TestDataFactory.cls-meta.xml` using [assets/test-data-factory-template.cls](assets/test-data-factory-template.cls). + +#### @TestSetup Example + +```apex +@TestSetup +static void setupTestData() { + List accounts = TestDataFactory.createAccounts(251, true); +} +``` + +#### Test Method Structure + +Use Given/When/Then: + +```apex +@isTest +static void shouldUpdateStatus_WhenValidInput() { + // Given + List accounts = [SELECT Id FROM Account]; + + // When + Test.startTest(); + MyService.processAccounts(accounts); + Test.stopTest(); + + // Then + List updated = [SELECT Id, Status__c FROM Account]; + Assert.areEqual(251, updated.size(), 'All accounts should be processed'); +} +``` + +#### Negative Test — Exception Pattern + +Use try/catch with `Assert.fail` to verify expected exceptions: + +```apex +@isTest +static void shouldThrowException_WhenInvalidInput() { + // Given + List emptyList = new List(); + + // When/Then + Test.startTest(); + try { + MyService.processAccounts(emptyList); + Assert.fail('Expected MyCustomException to be thrown'); + } catch (MyCustomException e) { + Assert.isTrue(e.getMessage().contains('cannot be empty'), + 'Exception message should indicate empty input'); + } + Test.stopTest(); +} +``` + +#### Naming Convention + +- `should[ExpectedResult]_When[Scenario]`: `shouldSendNotification_WhenOpportunityClosedWon` +- `[SubjectOrAction]_[Scenario]_[ExpectedResult]`: `AccountUpdate_ChangeName_Success` + +### Step 3 — Run Tests + +Start narrow when debugging; widen after the fix is stable. + +```bash +# Single test class +sf apex run test --class-names MyServiceTest --result-format human --code-coverage --target-org + +# Specific test methods +sf apex run test --tests MyServiceTest.shouldUpdateStatus_WhenValidInput --result-format human --target-org + +# All local tests +sf apex run test --test-level RunLocalTests --result-format human --code-coverage --target-org +``` + +### Step 4 — Analyze Results + +Focus on: + +- failing methods — exception types and stack traces +- uncovered lines and weak coverage areas +- whether failures indicate bad test data, brittle assertions, or broken production logic + +### Step 5 — Fix Loop + +When tests fail, run a disciplined fix loop (max 3 iterations — stop and surface root cause if still failing): + +1. Read the failing test class and the class under test +2. Identify root cause from error messages and stack traces +3. Apply fix — adjust test data or assertions for test-side issues; delegate production code issues to the `generating-apex` skill +4. Rerun the focused test before broader regression +5. Repeat until all tests pass, iteration limit reached, or root cause requires design change + +### Step 6 — Validate Coverage + +| Level | Coverage | Purpose | +|-------|----------|---------| +| Production deploy | 75% minimum | Required by Salesforce | +| Recommended | 90%+ | Best practice target | +| Critical paths | 100% | Business-critical code | + +Cover all paths: positive, negative/exception, bulk (251+ records), callout/async. + +## What to Test by Component | Component | Key Test Scenarios | |-----------|-------------------| -| Trigger | Bulk insert/update/delete, recursion, field changes | -| Service | Valid/invalid inputs, bulk operations, exceptions | +| Trigger | Bulk insert/update/delete, recursion guard, field change detection | +| Service | Valid/invalid inputs, bulk operations, exception handling | | Controller | Page load, action methods, view state | -| Batch | Start/execute/finish, chunking, error records | -| Queueable | Chaining, bulkification, error handling | +| Batch | start/execute/finish, scope matching (batch size >= record count), `Database.Stateful` tracking, error handling, chaining (separate methods — `finish()` calling `Database.executeBatch()` throws `UnexpectedException`) | +| Queueable | Chaining (only first job runs in tests), bulkification, error handling, callout mocks before `Test.startTest()` | | Callout | Success response, error response, timeout | -| Scheduled | Execution, CRON validation | \ No newline at end of file +| Selector | Valid/null/empty inputs, bulk (251+), field population, sort order, `WITH USER_MODE` via `System.runAs` | +| Scheduled | Direct execution via `execute(null)`, CRON registration via `CronTrigger` query | +| Platform Event | `Test.enableChangeDataCapture()`, `Test.getEventBus().deliver()`, verify subscriber side effects | + +## Output Expectations + +Deliverables per test class: +- `{ClassName}Test.cls` + `{ClassName}Test.cls-meta.xml` (match API version of class under test; default `66.0`) +- `TestDataFactory.cls` + `TestDataFactory.cls-meta.xml` (if not already present) + +## Reference Files + +Load on demand for detailed patterns: + +| Reference | When to use | +|-----------|-------------| +| [references/test-data-factory.md](references/test-data-factory.md) | TestDataFactory patterns, field overrides, duplicate rule handling | +| [references/assertion-patterns.md](references/assertion-patterns.md) | Assertion best practices, anti-patterns, common pitfalls | +| [references/mocking-patterns.md](references/mocking-patterns.md) | HttpCalloutMock, DML mocking, StubProvider, SOSL, Email, Platform Events | +| [references/async-testing.md](references/async-testing.md) | Batch, Queueable, Future, Scheduled job testing | diff --git a/skills/generating-apex-test/assets/test-class-template.cls b/skills/generating-apex-test/assets/test-class-template.cls index e759fd3..1cee33e 100644 --- a/skills/generating-apex-test/assets/test-class-template.cls +++ b/skills/generating-apex-test/assets/test-class-template.cls @@ -1,51 +1,47 @@ /** * @description Test class for {ClassUnderTest}. - * Tests bulk operations (200+ records), positive/negative paths, + * Tests bulk operations (251+ 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); + List accounts = TestDataFactory.createAccounts(251, true); } // ─── Positive Tests ─────────────────────────────────────────────────── @isTest static void shouldPerformExpectedBehavior_WhenValidInput() { - // Given: Setup specific test state - // List accounts = [SELECT Id, Name FROM Account]; + // Given + List accounts = [SELECT Id, Name FROM Account]; - // When: Execute the code under test + // When Test.startTest(); // {ClassUnderTest}.methodUnderTest(params); Test.stopTest(); - // Then: Assert expected outcomes - // System.assertEquals(expected, actual, 'Descriptive failure message'); + // Then + // Assert.areEqual(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'); + static void shouldHandleBulkRecords_WhenProcessing251() { + // Given + List accounts = [SELECT Id FROM Account]; + Assert.areEqual(251, accounts.size(), 'Should have 251 test records'); // When Test.startTest(); // {ClassUnderTest}.bulkMethod(accounts); Test.stopTest(); - // Then: Verify all records processed + // Then // List results = [SELECT Id, Status__c FROM Account]; // for (Account acc : results) { - // System.assertEquals('Processed', acc.Status__c, 'All records should be processed'); + // Assert.areEqual('Processed', acc.Status__c, 'All records should be processed'); // } } @@ -53,21 +49,15 @@ private class {ClassUnderTest}Test { @isTest static void shouldThrowException_WhenNullInput() { - Boolean exceptionThrown = false; - String exceptionMessage = ''; - Test.startTest(); try { // {ClassUnderTest}.methodUnderTest(null); - } catch (Exception e) { - exceptionThrown = true; - exceptionMessage = e.getMessage(); + Assert.fail('Expected exception for null input'); + } catch (MyCustomException e) { + Assert.isTrue(e.getMessage().contains('cannot be null'), + 'Exception message should mention null input'); } 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 @@ -76,16 +66,16 @@ private class {ClassUnderTest}Test { // List results = {ClassUnderTest}.methodUnderTest(new List()); Test.stopTest(); - // System.assert(results.isEmpty(), 'Should return empty list for empty input'); + // Assert.isTrue(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; + // Given + 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'; // } @@ -96,29 +86,8 @@ private class {ClassUnderTest}Test { // {ClassUnderTest}.conditionalMethod(accounts); Test.stopTest(); - // Then: Only qualifying records should be affected + // Then // List qualifying = [SELECT Id FROM Account WHERE Processed__c = true]; - // System.assertEquals(half, qualifying.size(), 'Only qualifying records should be processed'); + // Assert.areEqual(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 index bd0304c..29db7fb 100644 --- a/skills/generating-apex-test/assets/test-data-factory-template.cls +++ b/skills/generating-apex-test/assets/test-data-factory-template.cls @@ -2,7 +2,6 @@ * @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 { diff --git a/skills/generating-apex-test/references/assertion-patterns.md b/skills/generating-apex-test/references/assertion-patterns.md index f74bc84..90ba4a5 100644 --- a/skills/generating-apex-test/references/assertion-patterns.md +++ b/skills/generating-apex-test/references/assertion-patterns.md @@ -4,26 +4,30 @@ | Method | Use Case | |--------|----------| -| `System.assertEquals(expected, actual, msg)` | Exact equality | -| `System.assertNotEquals(expected, actual, msg)` | Value should differ | -| `System.assert(condition, msg)` | Boolean condition | +| `Assert.areEqual(expected, actual, msg)` | Exact equality | +| `Assert.areNotEqual(expected, actual, msg)` | Value should differ | +| `Assert.isTrue(condition, msg)` | Boolean condition | +| `Assert.isFalse(condition, msg)` | Negated boolean condition | +| `Assert.fail(msg)` | Force failure (e.g., expected exception not thrown) | +| `Assert.isNotNull(value, msg)` | Non-null check | +| `Assert.isNull(value, msg)` | Null check | -**Always include the third parameter (message)** - Makes test failures meaningful. +**Always include the message parameter** — makes test failures actionable. ## Good vs Bad Assertions -### ❌ Bad: No message, tests coverage not behavior +### Bad: No message, tests coverage not behavior ```apex -System.assertEquals(true, result); -System.assert(accounts.size() > 0); +Assert.isTrue(result); // no message +Assert.isTrue(accounts.size() > 0); // vague — use areEqual with exact count ``` -### ✅ Good: Descriptive message, tests specific behavior +### 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'); +Assert.areEqual(true, result, 'Service should return true for valid input'); +Assert.areEqual(200, accounts.size(), 'All 200 accounts should be processed'); ``` ## Common Assertion Patterns @@ -31,25 +35,18 @@ System.assertEquals(200, accounts.size(), 'All 200 accounts should be processed' ### 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'); +Assert.areEqual(200, results.size(), 'Should process all 200 records'); +Assert.isTrue(results.isEmpty(), 'No results expected for invalid input'); +Assert.isFalse(results.isEmpty(), 'Results should not be empty'); ``` ### Field Values ```apex -// Single record -System.assertEquals('Processed', acc.Status__c, 'Account status should be updated to Processed'); +Assert.areEqual('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, + Assert.areEqual('Active', acc.Status__c, 'Account ' + acc.Name + ' should have Active status'); } ``` @@ -59,21 +56,15 @@ for (Account acc : updatedAccounts) { ```apex @isTest static void shouldThrowException_WhenInputInvalid() { - Boolean exceptionThrown = false; - String exceptionMessage = ''; - Test.startTest(); try { MyService.process(null); + Assert.fail('Expected MyCustomException for null input'); } catch (MyCustomException e) { - exceptionThrown = true; - exceptionMessage = e.getMessage(); + Assert.isTrue(e.getMessage().contains('cannot be null'), + 'Exception message should mention null input'); } 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'); } ``` @@ -83,83 +74,35 @@ static void shouldThrowException_WhenInputInvalid() { // Insert success Database.SaveResult[] results = Database.insert(accounts, false); for (Database.SaveResult sr : results) { - System.assert(sr.isSuccess(), 'Insert should succeed: ' + sr.getErrors()); + Assert.isTrue(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'), +Assert.isFalse(sr.isSuccess(), 'Insert should fail for invalid data'); +Assert.isTrue(sr.getErrors()[0].getMessage().contains('REQUIRED_FIELD_MISSING'), 'Error should indicate missing required field'); ``` -### Comparing Objects - -```apex -// Compare specific fields, not entire objects -System.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'); +Assert.isNull(result.ErrorMessage__c, 'No error expected for valid input'); +Assert.isNotNull(result.Id, 'Record should have been inserted'); ``` -## Anti-Patterns to Avoid - -### ❌ Testing implementation, not behavior +### Date/DateTime ```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'); +Assert.areEqual(Date.today(), record.CreatedDate__c, 'Should be created today'); +Assert.isTrue(record.DueDate__c >= Date.today(), 'Due date should be in the future'); ``` -### ❌ Overly generic assertions +## Anti-Patterns -```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'); -``` +| Anti-Pattern | Fix | +|---|---| +| `Assert.isTrue(results.size() > 0)` | Use `Assert.areEqual(expectedCount, results.size(), ...)` | +| `Assert.isTrue(results.size() >= expected)` | Compute exact expected count, use `Assert.areEqual` | +| Testing implementation not behavior | Assert on observable outcomes (field values, record counts) | +| Missing negative test assertions | Verify the actual outcome, not just that no exception occurred | +| `Assert.isTrue(count != 0)` | Use `Assert.areEqual` with deterministic value from test data | diff --git a/skills/generating-apex-test/references/async-testing.md b/skills/generating-apex-test/references/async-testing.md index db651f9..c16d8c2 100644 --- a/skills/generating-apex-test/references/async-testing.md +++ b/skills/generating-apex-test/references/async-testing.md @@ -8,36 +8,33 @@ ### Basic Batch Test +**Critical:** In test context only one `execute()` invocation runs, so always set `batchSize >= testRecordCount` (e.g., `Database.executeBatch(batch, 200)` with 200 records). Never create more records than the batch size. + ```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 + Test.stopTest(); + List updated = [SELECT Id, Status__c FROM Account]; for (Account acc : updated) { - System.assertEquals('Processed', acc.Status__c, + Assert.areEqual('Processed', acc.Status__c, 'Batch should update all account statuses'); } } ``` -### Testing Batch with Failures +### 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( @@ -46,37 +43,20 @@ static void shouldLogErrors_WhenRecordsFail() { )); } insert invalidAccounts; - - // When + Test.startTest(); MyBatchClass batch = new MyBatchClass(); - Database.executeBatch(batch, 50); + Database.executeBatch(batch, 200); Test.stopTest(); - - // Then + List errors = [SELECT Id, Message__c FROM Error_Log__c]; - System.assertEquals(2, errors.size(), 'Should log 2 failed records'); + Assert.areEqual(2, errors.size(), 'Should log 2 failed records'); } ``` -### Testing Batch Scope +### Batch Chaining -```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'); -} -``` +Test `finish()` chaining in a **separate test method** — calling `Database.executeBatch()` inside `finish()` during a test can throw `UnexpectedException`. Verify the first batch independently, then test that `finish()` enqueues the next batch. ## Queueable Testing @@ -85,70 +65,65 @@ static void shouldRespectBatchSize() { ```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 + Test.stopTest(); + Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id]; - System.assertEquals('Processed', updated.Status__c, + Assert.areEqual('Processed', updated.Status__c, 'Queueable should update account status'); } ``` -### Testing Queueable Chaining +### Queueable Chaining -Chained queueables only execute the first job in tests: +Only the **first** chained queueable executes in tests. Design tests to verify: +1. First job completes correctly +2. Chain is properly enqueued (query `AsyncApexJob`) +3. Each job works independently in its own test method ```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) + Assert.areEqual(100, processed.size(), 'First batch should process 100 records'); + List jobs = [ - SELECT Id, Status, JobType - FROM AsyncApexJob + SELECT Id, Status, JobType + FROM AsyncApexJob WHERE ApexClass.Name = 'MyChainedQueueable' ]; - System.assert(jobs.size() >= 1, 'Chained job should be enqueued'); + Assert.isTrue(jobs.size() >= 1, 'Chained job should be enqueued'); } ``` -### Testing Queueable with Callouts +### Queueable with Callouts + +Set the callout mock **before** `Test.startTest()`: ```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, + Assert.areEqual('Synced', updated.External_Status__c, 'Should update status after successful callout'); } ``` @@ -158,119 +133,61 @@ static void shouldMakeCallout_WhenQueueableWithCallout() { ```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 + MyClass.processFuture(acc.Id); + Test.stopTest(); + Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id]; - System.assertEquals(true, updated.Processed__c, 'Future should process record'); + Assert.areEqual(true, updated.Processed__c, 'Future should process record'); } ``` ## Scheduled Apex Testing -### Testing Scheduled Execution +### Direct 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 + scheduled.execute(null); Test.stopTest(); - - // Then + List processed = [SELECT Id FROM Account WHERE Processed__c = true]; - System.assertEquals(50, processed.size(), 'Scheduled job should process records'); + Assert.areEqual(50, processed.size(), 'Scheduled job should process records'); } ``` -### Testing Schedule Registration +### CRON Registration ```apex @isTest static void shouldScheduleJob() { Test.startTest(); - String cronExp = '0 0 6 * * ?'; // Daily at 6 AM + String cronExp = '0 0 6 * * ?'; String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass()); Test.stopTest(); - - // Verify job is scheduled + CronTrigger ct = [ - SELECT Id, CronExpression, State - FROM CronTrigger + 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); + Assert.areEqual('0 0 6 * * ?', ct.CronExpression, 'CRON should match'); + Assert.areEqual('WAITING', ct.State, 'Job should be waiting'); } ``` ## 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(); -``` +| Pitfall | Impact | +|---|---| +| Missing `Test.stopTest()` | Async never executes, assertions fail silently | +| Expecting all chained queueables to run | Only the first runs; test each independently | +| Mock set after `Test.startTest()` | Callout mock must be set **before** `Test.startTest()` | +| Batch size < record count in tests | Only `batchSize` records processed; set `batchSize >= recordCount` | diff --git a/skills/generating-apex-test/references/mocking-patterns.md b/skills/generating-apex-test/references/mocking-patterns.md index a308227..4d27a0a 100644 --- a/skills/generating-apex-test/references/mocking-patterns.md +++ b/skills/generating-apex-test/references/mocking-patterns.md @@ -4,7 +4,7 @@ Apex doesn't allow real HTTP callouts in tests. Use `HttpCalloutMock` interface. -### Basic Mock Implementation +### Basic Mock ```apex @isTest @@ -33,34 +33,28 @@ public class MockHttpResponse implements HttpCalloutMock { ```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'); + + Assert.areEqual(1, results.size(), 'Should parse one record from response'); + Assert.areEqual('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'); + + Assert.areEqual(false, result.isSuccess, 'Should indicate failure'); + Assert.isTrue(result.errorMessage.contains('Unauthorized'), 'Should capture error'); } ``` @@ -71,83 +65,101 @@ 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 +### StaticResourceCalloutMock -For complex response bodies, store JSON in Static Resources: +Use when response JSON is large or complex: ```apex @isTest static void shouldParseComplexResponse() { StaticResourceCalloutMock mock = new StaticResourceCalloutMock(); - mock.setStaticResource('TestApiResponse'); // Static Resource name + mock.setStaticResource('TestApiResponse'); 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'); + + Assert.isNotNull(r, 'Should parse response'); } ``` -## Stub API (Enterprise Pattern) +## SOSL Mocking -For mocking Apex class dependencies using `System.StubProvider`: +SOSL returns empty results in tests by default. Call `Test.setFixedSearchResults(List)` before the search: + +```apex +@isTest +static void shouldReturnSearchResults() { + Account acc = TestDataFactory.createAccount(true); + Test.setFixedSearchResults(new List{ acc.Id }); + + Test.startTest(); + List results = MyService.searchAccounts('Test'); + Test.stopTest(); + + Assert.areEqual(1, results.size(), 'Should return mocked search result'); +} +``` + +## DML Mocking (Constructor Injection) + +Design for testability — use a public constructor for production and a `@TestVisible private` constructor that accepts mock interfaces: + +```apex +public class MyService { + private IDML dmlHandler; + + public MyService() { + this.dmlHandler = new DMLHandler(); + } + + @TestVisible + private MyService(IDML dmlHandler) { + this.dmlHandler = dmlHandler; + } + + public void createRecords(List accounts) { + dmlHandler.doInsert(accounts); + } +} +``` + +## Stub API (System.StubProvider) + +For mocking Apex class dependencies: ```apex @isTest public class MyServiceMock implements System.StubProvider { - + public Object handleMethodCall( - Object stubbedObject, - String stubbedMethodName, - Type returnType, - List paramTypes, - List paramNames, - List args + Object stubbedObject, String stubbedMethodName, Type returnType, + List paramTypes, List paramNames, List args ) { if (stubbedMethodName == 'getAccountData') { return new AccountData('Mock Account', 'Active'); @@ -156,42 +168,36 @@ public class MyServiceMock implements System.StubProvider { } } -// 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 + IMyService mockService = (IMyService) Test.createStub(IMyService.class, mockProvider); + MyController controller = new MyController(mockService); - + Test.startTest(); String result = controller.displayAccountInfo(); Test.stopTest(); - - System.assert(result.contains('Mock Account'), 'Should use mocked data'); + + Assert.isTrue(result.contains('Mock Account'), 'Should use mocked data'); } ``` -## Email Mocking +## Email Testing -Apex sends real emails by default. Use limits to verify: +Apex doesn't actually send emails in tests. 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' - ); + + Assert.areEqual(emailsBefore + 1, Limits.getEmailInvocations(), + 'One email should be sent'); } ``` @@ -201,19 +207,14 @@ static void shouldSendEmail_WhenTriggered() { @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'); + Assert.areEqual(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 index b4cc3df..1414ac3 100644 --- a/skills/generating-apex-test/references/test-data-factory.md +++ b/skills/generating-apex-test/references/test-data-factory.md @@ -1,113 +1,18 @@ # TestDataFactory Patterns -## Overview +For the base class template, see [assets/test-data-factory-template.cls](../assets/test-data-factory-template.cls). -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. +## Design Rules -## 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) { ... } -} -``` +1. **Always accept a `doInsert` flag** — lets callers modify records before insert +2. **Append loop index to all fields that participate in matching rules** — prevents `DUPLICATES_DETECTED` errors from active Duplicate Rules +3. **Single-record methods delegate to bulk** — e.g., `createAccount(doInsert)` calls `createAccounts(1, doInsert)[0]` +4. **Return created records** — enables chaining and further manipulation +5. **Set all required fields** — include fields enforced by validation rules, not just schema-required fields ## Field Override Pattern -Allow callers to override default values: +Allow callers to override default values without creating new factory methods: ```apex public static Account createAccount(Map fieldOverrides, Boolean doInsert) { @@ -115,12 +20,9 @@ public static Account createAccount(Map fieldOverrides, Boolean Name = 'Test Account', Industry = 'Technology' ); - - // Apply overrides for (String fieldName : fieldOverrides.keySet()) { acc.put(fieldName, fieldOverrides.get(fieldName)); } - if (doInsert) insert acc; return acc; } @@ -132,23 +34,6 @@ Account acc = TestDataFactory.createAccount(new Map{ }, 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 @@ -157,7 +42,7 @@ public static Account createAccountByRecordType(String recordTypeName, Boolean d .getRecordTypeInfosByDeveloperName() .get(recordTypeName) .getRecordTypeId(); - + Account acc = new Account( Name = 'Test Account', RecordTypeId = recordTypeId @@ -167,10 +52,24 @@ public static Account createAccountByRecordType(String recordTypeName, Boolean d } ``` -## Best Practices +## Handling Duplicate Rules -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 +When unique field values alone are not sufficient, use `Database.insert()` with a `DuplicateRuleHeader`: + +```apex +public static List createAccountsAllowDuplicates(Integer count, Boolean doInsert) { + List accounts = new List(); + for (Integer i = 0; i < count; i++) { + accounts.add(new Account( + Name = 'Test Account ' + i, + Phone = '555-000-' + String.valueOf(i).leftPad(4, '0') + )); + } + if (doInsert) { + Database.DMLOptions dml = new Database.DMLOptions(); + dml.DuplicateRuleHeader.allowSave = true; + Database.insert(accounts, dml); + } + return accounts; +} +``` diff --git a/skills/generating-apex/CREDITS.md b/skills/generating-apex/CREDITS.md new file mode 100644 index 0000000..5db0496 --- /dev/null +++ b/skills/generating-apex/CREDITS.md @@ -0,0 +1,30 @@ +# Credits & Acknowledgments + +This skill was influenced by the [sf-skills](https://github.com/Jaganpro/sf-skills) repository and built upon the collective wisdom of the Salesforce developer community. We gratefully acknowledge the following authors and resources whose ideas, patterns, and best practices have shaped this skill. + +--- + +## Authors & Contributors + +### Jag Valaiyapathy (**[Jaganpro)](https://github.com/Jaganpro)** + +**[sf-skills](https://github.com/Jaganpro/sf-skills)** + +Key contributions influencing this skill: + +- Pioneering open-source Salesforce skills for agentic coding tools +- Apex code generation and review patterns +- Best practices, anti-patterns, and design patterns reference material +- Template library for common Apex class types + +This skill was influenced by the [sf-skills](https://github.com/Jaganpro/sf-skills) repository. + +--- + +## Special Thanks + +To the entire Salesforce developer community for sharing knowledge, writing blogs, creating open-source tools, and helping each other build better solutions. + +--- + +*If we've missed anyone whose work influenced these skills, please let us know so we can add proper attribution.* \ No newline at end of file diff --git a/skills/generating-apex/SKILL.md b/skills/generating-apex/SKILL.md index 3d6ab5c..44b0a21 100644 --- a/skills/generating-apex/SKILL.md +++ b/skills/generating-apex/SKILL.md @@ -1,105 +1,209 @@ --- name: generating-apex -description: Generate production-ready Apex classes for Salesforce following enterprise best practices. Covers service, selector, domain, batch, queueable, schedulable, DTO, utility, interface, abstract, and custom exception classes. Use this skill when the user asks to create, build, generate, scaffold, or refactor an Apex class. SCOPE Apex classes only — does not apply to triggers or unit tests. +description: Primary Apex authoring skill for class generation, refactoring, and review. ALWAYS ACTIVATE when the user mentions Apex, .cls, triggers, or asks to create/refactor a class (service, selector, domain, batch, queueable, schedulable, invocable, DTO, utility, interface, abstract, exception, REST resource). Use this skill for requests involving SObject CRUD, mapping collections, fetching related records, scheduled jobs, batch jobs, trigger design, @AuraEnabled controllers, @RestResource endpoints, custom REST APIs, or code review of existing Apex. --- -# Apex Class +# Generating Apex -Generate well-structured, production-ready Apex classes for Salesforce development. +Use this skill for production-grade Apex: new classes, selectors, services, async jobs, +invocable methods, and triggers; and for evidence-based review of existing `.cls`. -## Scope +## Required Inputs -**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 +Gather or infer before authoring: -**Does NOT generate:** -- Triggers (use a trigger framework skill) -- Unit tests (use a test writer skill) -- Aura controllers -- LWC JavaScript controllers +- Class type (service, selector, domain, batch, queueable, schedulable, invocable, trigger, trigger action, DTO, utility, interface, abstract, exception, REST resource) +- Target object(s) and business goal +- Class name (derive using the naming table below) +- Net-new vs refactor/fix; any org/API constraints +- Deployment targets ---- - -## 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) +Defaults unless specified: +- Sharing: `with sharing` (see sharing rules per type below) +- Access: `public` (use `global` only when required by managed packages or `@InvocableMethod`) +- API version: `66.0` (minimum version) +- ApexDoc comments: yes If the user provides a clear, complete request, generate immediately without unnecessary back-and-forth. --- -## Code Standards — ALWAYS Follow These +## Workflow -### 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. +All steps are sequential. Do not skip, merge, or reorder. If blocked, stop and ask for missing context. If not applicable, mark `N/A` with a one-line justification in the report. -### 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. +### Phase 1 — Author -### 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. +1. **Discover project conventions** + - Service-Selector-Domain layering, logging utilities + - Existing classes/triggers and current trigger framework or handler pattern + - Whether Trigger Actions Framework (TAF) is already in use -### 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. +2. **Choose the smallest correct pattern** (see Type-Specific Guidance below) + +3. **Review templates and assets** + - Read the matching template from `assets/` before authoring (see Type-Specific Guidance for the file mapping) + - When a `references/` example exists for the type, read it as a concrete style guide + - For any test class work, always read and use `generating-apex-test` skill + +4. **Author with guardrails** -- apply every rule in the Rules section below + - Generate `{ClassName}.cls` with ApexDoc + - Generate `{ClassName}.cls-meta.xml` + +5. **Generate test classes** -- delegate to `generating-apex-test` to create `{ClassName}Test.cls` and `{ClassName}Test.cls-meta.xml`. Do not write test code in this skill. If the test skill is unavailable, record `test_skill=unavailable: ` in Step 8. + +### Phase 2 — Validate (required before reporting) + +Writing files is the midpoint, not the finish line. Steps 6 and 7 each require a tool invocation and produce output that must appear in the Step 8 report. Do not summarize or present the report until both steps have run and their output is captured. + +6. **Run code analyzer** + - Invoke MCP `run_code_analyzer` on all generated/updated `.cls` files. + - Remediate all `sev0`, `sev1`, and `sev2` violations; re-run until clean. + - Capture the final tool output verbatim for the report. + - Fallback: `sf code-analyzer run --target `. If both are unavailable, record `run_code_analyzer=unavailable: ` in the report. + +7. **Execute Apex tests** + - Run org tests including `{ClassName}Test` via `sf apex run test` or MCP. + - Delegate all test fixes/coverage work to `generating-apex-test`; iterate until green. + - Capture pass/fail counts and coverage percentage for the report. + - If unavailable, record `test_execution=unavailable: ` in the report. + +### Phase 3 — Report + +8. **Report** -- use the output format at the bottom of this file. + - The `Analyzer` line must contain the actual Step 6 tool output (or `run_code_analyzer=unavailable: ` after attempting invocation). + - The `Testing` line must contain the actual Step 7 results (or `test_execution=unavailable: ` after attempting invocation). + - A report missing either line is incomplete. Always attempt the tool invocation before recording unavailable. + +--- + +## Rules + +### Hard-Stop Constraints (Must Enforce) + +If any constraint would be violated in generated code, **stop and explain the problem** before proceeding: + +| Constraint | Rationale | +|---|---| +| Place all SOQL outside loops | Avoid query governor limits (100 queries) | +| Place all DML outside loops | Avoid DML governor limits (150 statements) | +| Declare a sharing keyword on every class | Prevent unintended `without sharing` defaults and data exposure | +| Use Custom Metadata/Labels/describe calls instead of hardcoded IDs | Ensure portability across orgs | +| Always handle exceptions (log, rethrow, or recover) | Prevent silent failures | +| Use bind variables for all dynamic SOQL with user input | Prevent SOQL injection | +| Use Apex-native collections (`List`, `Map`, `Set`) rather than Java types | Prevent compile errors | +| Verify methods exist in Apex before use | Prevent reliance on non-existent APIs | +| Prefer structured logging over `System.debug()` | Debug string concatenation consumes CPU even when not observed | +| Never use `@future` methods | Use Queueable with `System.Finalizer`; `@future` cannot chain, cannot be called from Batch, and cannot accept non-primitive types | + +### Bulkification & Governor Limits + +- All public APIs accept and process collections; single-record overloads delegate to the bulk method +- In batch/bulk flows, prefer partial-success DML (`Database.update(records, false)`) and process `SaveResult` for errors +- Use `Map` constructor for efficient ID-based lookups from query results +- Use `Map>` to group child records by parent; build the map in a single loop before processing +- Use `Set` for deduplication and membership checks; prefer `Set.contains()` over `List.contains()` +- Use relationship subqueries to fetch parent + child records in a single SOQL when both are needed +- Use `AggregateResult` with `GROUP BY` for rollup calculations instead of querying and counting in Apex +- Only DML records that actually changed — compare against `Trigger.oldMap` or prior state before adding to the update list +- Use `Limits.getQueries()`, `Limits.getDmlStatements()`, `Limits.getCpuTime()` to monitor consumption in complex transactions + +### SOQL Optimization + +- Use selective queries with proper `WHERE` clauses; use indexed fields (`Id`, `Name`, `OwnerId`, lookup/master-detail fields, `ExternalId` fields, custom indexes) in filters when possible +- `SELECT *` does not exist in SOQL -- always specify the exact fields needed +- Apply `LIMIT` clauses to bound result sets; use `ORDER BY` for deterministic results +- When querying Custom Metadata Types (objects ending with `__mdt`), do NOT use SOQL — use the built-in methods (`{CustomMdt__mdt}.getAll().values()`, `getInstance()`, etc.) + +### Caching + +- Use Platform Cache (`Cache.Org` / `Cache.Session`) for frequently accessed, rarely changed data; set a TTL and always handle cache misses — cache can be evicted at any time +- Use `private static Map` fields as transaction-scoped caches to prevent duplicate queries within the same execution context; lazy-initialize on first access + +### Security + +- Default to `with sharing`; document justification for `without sharing` or `inherited sharing` +- `WITH USER_MODE` in SOQL and `AccessLevel.USER_MODE` for `Database` DML for CRUD/FLS enforcement +- Validate dynamic field/operator names via allowlist or `Schema.describe` +- Named Credentials for all external credentials/API keys +- `AuraHandledException` for `@AuraEnabled` user-facing errors (no internal details) +- `without sharing` requires a Custom Permission check +- Isolate `without sharing` logic in dedicated helper classes; call from `with sharing` entry points to limit elevated-access scope +- Encrypt PII/sensitive data at rest via Platform Encryption; never expose PII in debug statements, error messages, or API responses + +### Security Verification + +Before finalizing, verify: CRUD/FLS enforced (SOQL + DML) · explicit sharing keyword on every class · no hardcoded secrets or Record IDs · PII excluded from logs and error messages · error messages sanitized for end users. + +### Error Handling + +- Catch specific exceptions before generic `Exception`; include context in messages +- Use `try/catch` only around code that can throw (DML, callouts, JSON parsing, casts); avoid defensive wrapping of simple assignments/collection ops/arithmetic +- Preserve exception cause chains: `new CustomException('message', cause)` (do not replace stack trace with concatenated messages) +- Provide a custom exception class per service domain when meaningful +- In `@AuraEnabled` methods, catch exceptions and rethrow as `AuraHandledException` + +### Null Safety + +- Add guard clauses for null/empty inputs at the top of every public method; match style to context: `return` early in private/trigger-handler methods, `throw` exceptions in public APIs, `record.addError()` in validation services +- Return empty collections instead of `null` +- Use safe navigation (`?.`) for chained property access +- Never dereference `map.get(key)` inline unless presence is guaranteed; use `containsKey`, assignment+null check, or safe navigation first +- Use null coalescing (`??`) for default values +- Prefer `String.isBlank(value)` over manual checks like `value == null || value.trim().isEmpty()` + +### Constants & Literals + +- Use enums over string constants whenever possible; enum values follow `UPPER_SNAKE_CASE` +- Extract repeated literal strings/numbers into `private static final` constants or a constants class +- Use `Label.` custom labels for user-facing strings +- Use Custom Metadata for configurable values (thresholds, mappings, feature flags) +- Never output HTML-escaped entities in code (e.g., `'`); use literal single quotes `'` in Apex string literals ### 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` | +| 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` | +| Interface | `I{Descriptive}` | `INotificationService` | +| Abstract | `Abstract{Descriptive}` | `AbstractIntegrationService` | +| Exception | `{Descriptive}Exception` | `AccountServiceException` | +| REST Resource | `{SObject}RestResource` | `AccountRestResource` | +| Trigger | `{SObject}Trigger` | `AccountTrigger` | +| Trigger Action | `TA_{SObject}_{Action}` | `TA_Account_SetDefaults` | -### ApexDoc Comments -Include ApexDoc on every `public` and `global` method and on the class itself: +Additional naming rules: +- Classes: `PascalCase` +- Methods: `camelCase`, start with a verb (`get`, `create`, `process`, `validate`, `is`, `has`, `can`) +- Variables: `camelCase`, descriptive nouns; Lists as plural nouns (e.g., `accounts`, `relatedContacts`); Maps as `{value}By{key}` (e.g., `accountsById`); Sets as `{noun}Ids` +- Constants: `UPPER_SNAKE_CASE` +- Use full descriptive names instead of abbreviations (`acc`, `tks`, `rec`) + +### ApexDoc + +- Required on the class header and every `public`/`global` method +- Include: brief description, `@param`, `@return`, `@throws`, `@example` where helpful + +Class-level format: ```apex /** - * @description Brief description of what the class does - * @author Generated by Apex Class Writer Skill + * @author Generated by Apex Skill */ ``` +Method-level format: + ```apex /** - * @description Brief description of what the method does * @param paramName Description of the parameter * @return Description of the return value * @example @@ -107,36 +211,153 @@ Include ApexDoc on every `public` and `global` method and on the class itself: */ ``` -### 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`. +### Code Structure & Architecture -### 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. +- Single responsibility per class; max 500 lines -- split when exceeded +- Return Early: validate preconditions at method top, return/throw immediately +- Extract private helpers for methods over ~40 lines +- Use Dependency Injection (constructor/method params) for testability +- Prefer composition and narrow interfaces over deep inheritance; extend via new implementations, not modifications +- Enforce single-level abstraction per method across layer boundaries: -### 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. +| Layer | Owns | Must NOT contain | +|---|---|---| +| Trigger | Event routing only | Business logic, orchestration | +| Handler/Service | Flow control, coordination | Inline SOQL/DML/HTTP/parsing | +| Domain | Business rules, validation | Queries, callouts, persistence details | +| Data/Integration | SOQL, DML, HTTP | Business decisions | -### 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. +- Disallowed: methods mixing orchestration with inline SOQL/DML/HTTP; business rules mixed with parsing internals; validation + persistence + cross-system plumbing in one method --- -## Output Format +## Async Decision Matrix -For every class, produce TWO files: +| Scenario | Default | Key Traits | +|---|---|---| +| Standard async work | **Queueable** | Job ID, chaining, non-primitive types, configurable delay (up to 10 min via `AsyncOptions`), dedup signatures | +| Very large datasets | **Batch Apex** | Chunked processing, max 5 concurrent; use `QueryLocator` for large scopes | +| Modern batch alternative | **CursorStep** (`Database.Cursor`) | 2000-record chunks, higher throughput, no 5-job limit | +| Recurring schedule | **Scheduled Flow** (preferred) or **Schedulable** | Schedulable has 100-job limit; use only when chaining to Batch or needing complex Apex logic | +| Post-job cleanup | **Finalizer** (`System.Finalizer`) | Runs regardless of Queueable success/failure | +| Long-running callouts | **Continuation** | Up to 3 per transaction, 3 parallel | +| Delays > 10 minutes | `System.scheduleBatch()` | Schedule a Batch job at a specific future time | +| Legacy fire-and-forget | `@future` | **Do not use in new code** — see Hard-Stop Constraints; replace with Queueable + Finalizer | -1. **`{ClassName}.cls`** — The Apex class source code -2. **`{ClassName}.cls-meta.xml`** — The metadata file +--- -### Meta XML Template +## Type-Specific Guidance + +### Service +- Template: `assets/service.cls` · Reference: `references/AccountService.cls` +- `with sharing`; stateless — no `public` fields or mutable instance state; keep public APIs focused and `static` where reasonable +- Delegate all SOQL to Selectors and SObject behavior to Domains +- Wrap business errors in a custom exception (e.g., `AccountServiceException`) + +### Selector +- Template: `assets/selector.cls` · Reference: `references/AccountSelector.cls` +- `inherited sharing`; one per SObject or query domain +- Return `List` or `Map`; use a shared base field list constant (no inline duplication) +- Accept filter parameters; always include `WITH USER_MODE` + +### Domain +- Template: `assets/domain.cls` +- `with sharing`; encapsulate field defaults, derivations, and validations +- Operate on in-memory lists only; no SOQL/DML (belongs in Services/Selectors) + +### Batch +- Template: `assets/batch.cls` · Reference: `references/AccountDeduplicationBatch.cls` +- `with sharing`; implement `Database.Batchable` (add `Database.Stateful` when tracking across chunks) +- `start()` = query definition; `execute()` = business logic; `finish()` = logging/notification +- Use `QueryLocator` for large datasets; handle partial failures via `Database.SaveResult` +- Accept filter parameters via constructor for reusability + +### Queueable +- Template: `assets/queueable.cls` +- `with sharing`; implement `Queueable` and optionally `Database.AllowsCallouts` when HTTP callouts are needed +- Accept data via constructor +- Add chain-depth guards to prevent infinite chains +- Optionally implement `Finalizer` for recovery/cleanup +- Use `AsyncOptions` for configurable delay (up to 10 min) and dedup signatures + +### Schedulable +- Template: `assets/schedulable.cls` +- `with sharing`; `execute()` delegates to Queueable or Batch +- Provide CRON constants and a convenience `scheduleDaily()` helper + +### DTO / Wrapper +- Template: `assets/dto.cls` +- No sharing keyword needed (pure data containers) +- Simple public properties; no-arg + parameterized constructors; `Comparable` when ordering matters +- Use `@JsonAccess` on private/protected inner DTOs that are serialized/deserialized + +### Utility +- Template: `assets/utility.cls` +- No sharing keyword needed; all methods `public static`; `private` constructor +- Pure, side-effect-free; no SOQL/DML + +### Interface +- Template: `assets/interface.cls` +- Define clear contracts with ApexDoc on each method signature + +### Abstract +- Template: `assets/abstract.cls` +- `with sharing`; offer default behavior via `virtual` methods +- Mark extension points `protected virtual` or `protected abstract` +- Include a concrete example in the ApexDoc showing how to extend the class + +### Custom Exception +- Template: `assets/exception.cls` +- No sharing keyword; extend `Exception` with descriptive names +- Supported constructors: `()`, `('msg')`, `(cause)`, `('msg', cause)` + +### Trigger +- Template: `assets/trigger.cls` +- One trigger per object; delegate all logic to handler/TAF action classes +- Include all relevant DML contexts; if TAF: `new MetadataTriggerHandler().run();` + +### Trigger Action (TAF) +- One class per concern per context; implement `TriggerAction.{Context}` +- Register via `Trigger_Action__mdt` (actions are inactive without registration) +- Name: `TA_{SObject}_{ActionName}`; prefer field-value comparison over static booleans for recursion + +### Invocable Method (`@InvocableMethod`) +- Template: `assets/invocable.cls` +- `with sharing`; inner `Request`/`Response` with `@InvocableVariable` +- Method must be `public static`; non-static or single-object signatures will not compile +- Accept `List`, return `List`; bulkify (SOQL/DML outside loops) +- Decorator parameters: `label` (required — Flow Builder display name), `description`, `category` (groups actions in Builder), `callout=true` (required when method makes HTTP callouts) +- `@InvocableVariable` parameters: `label` (required), `description`, `required=true/false` +- `@InvocableVariable` supports: primitives, `Id`, `SObject`, `List` only (no `Map`/`Set`/`Blob`); use `List` or `List` fields for Flow collection I/O +- Always include `isSuccess`, `errorMessage`, and `errorType` (`e.getTypeName()`) in Response +- Return errors in Response (recommended); throwing an exception triggers the Flow Fault path — reserve for unrecoverable failures only + +### REST Resource (`@RestResource`) +- Template: `assets/rest-resource.cls` +- `global with sharing`; both class and methods must be `global` +- Versioned URL: `@RestResource(urlMapping='/{resource}/v1/*')` +- Use proper HTTP status codes per branch (`200`/`201`/`400`/`404`/`422`/`500`); never default all errors to `500` +- Validate inputs (Id format: `Pattern.matches('[a-zA-Z0-9]{15,18}', value)`); bind all user input in SOQL +- Include `LIMIT`/`ORDER BY` in queries; implement pagination (`pageSize`/`offset`) +- Standardized `ApiResponse` wrapper (`success`, `message`, `data`/`records`); inner request/response DTOs +- Thin controller: delegate business logic to Service classes + +### `@AuraEnabled` Controller +- `with sharing`; use `WITH USER_MODE` in all SOQL +- Use `@AuraEnabled(cacheable=true)` only for read-only queries; leave `cacheable` unset for DML operations +- Catch exceptions and rethrow as `AuraHandledException` with user-friendly messages + +--- + +## Output Expectations + +Deliverables per class: +- `{ClassName}.cls` +- `{ClassName}.cls-meta.xml` (default API version `66.0` or higher unless specified) +- `{ClassName}Test.cls` (generated via `generating-apex-test` skill) +- `{ClassName}Test.cls-meta.xml` (generated via `generating-apex-test` skill) + +Meta XML template: ```xml @@ -146,108 +367,33 @@ For every class, produce TWO files: ``` -Default `apiVersion` is `62.0` unless the user specifies otherwise. +Report in this order: + +```text +Apex work: +Files: +Design: +Workflow: all steps completed (1-8); any N/A justified +Risks: +Analyzer: "> +Testing: "> +Deploy: +``` --- -## Class Type–Specific Instructions +## Cross-Skill Integration -### 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` +| Need | Delegate to | +|---|---| +| Apex tests / fix failures | `generating-apex-test` skill | +| Describe objects/fields | metadata skill (if available) | +| Deploy to org | deploy skill (if available) | +| Flow calling Apex | Flow skill (if available) | +| LWC calling Apex | LWC skill (if available) | --- -## Generation Workflow +## Troubleshooting Boundary -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 +This skill handles production `.cls`/`.trigger` issues only: compile/parse failures, deployment dependency errors, runtime governor-limit failures. For test execution, assertions, coverage, or `sf apex run test` failures, delegate to `generating-apex-test`. diff --git a/skills/generating-apex/assets/abstract.cls b/skills/generating-apex/assets/abstract.cls index cf2716a..37c9a2f 100644 --- a/skills/generating-apex/assets/abstract.cls +++ b/skills/generating-apex/assets/abstract.cls @@ -1,5 +1,9 @@ /** +<<<<<<< Updated upstream * @description Abstract base class for {describe the family of classes this serves}. +======= + * Abstract base class for {describe the family of classes this serves}. +>>>>>>> Stashed changes * 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 @@ -29,7 +33,7 @@ public abstract with sharing class {ClassName} { // ─── Constructor ───────────────────────────────────────────────────── /** - * @description Initializes the base class with default configuration + * Initializes the base class with default configuration */ protected {ClassName}() { this.timeoutMs = DEFAULT_TIMEOUT_MS; @@ -38,14 +42,14 @@ public abstract with sharing class {ClassName} { // ─── Abstract Methods (must be implemented by subclasses) ──────────── /** - * @description Returns the endpoint URL for this integration. + * 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. + * Returns the HTTP headers for this integration. * Subclasses define their own required headers. * @return Map of header name to header value */ @@ -54,7 +58,7 @@ public abstract with sharing class {ClassName} { // ─── Virtual Methods (can be overridden by subclasses) ─────────────── /** - * @description Hook called before the main operation executes. + * Hook called before the main operation executes. * Override to add pre-processing logic. * Default implementation does nothing. * @param context Map of contextual data @@ -64,7 +68,7 @@ public abstract with sharing class {ClassName} { } /** - * @description Hook called after the main operation completes. + * Hook called after the main operation completes. * Override to add post-processing logic. * Default implementation does nothing. * @param context Map of contextual data @@ -77,7 +81,7 @@ public abstract with sharing class {ClassName} { // ─── Template Method (common workflow) ─────────────────────────────── /** - * @description Executes the operation using the template method pattern. + * 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 @@ -100,7 +104,7 @@ public abstract with sharing class {ClassName} { // ─── Protected Helpers ─────────────────────────────────────────────── /** - * @description Core execution logic — override this for the main operation. + * 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 @@ -112,7 +116,7 @@ public abstract with sharing class {ClassName} { } /** - * @description Error handler called when doExecute throws. + * Error handler called when doExecute throws. * Override to customize error handling (e.g., logging, retry). * @param e The exception that was thrown */ diff --git a/skills/generating-apex/assets/batch.cls b/skills/generating-apex/assets/batch.cls index 07807d5..3633ee0 100644 --- a/skills/generating-apex/assets/batch.cls +++ b/skills/generating-apex/assets/batch.cls @@ -1,5 +1,5 @@ /** - * @description Batch Apex class for {describe the batch operation}. + * 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 @@ -24,7 +24,7 @@ public with sharing class {ClassName} implements Database.Batchable, Da // ─── Constructor ───────────────────────────────────────────────────── /** - * @description Default constructor + * Default constructor */ public {ClassName}() { // Default configuration @@ -33,7 +33,7 @@ public with sharing class {ClassName} implements Database.Batchable, Da // ─── Batchable Interface ───────────────────────────────────────────── /** - * @description Defines the scope of records to process. + * 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 @@ -48,7 +48,7 @@ public with sharing class {ClassName} implements Database.Batchable, Da } /** - * @description Processes each batch of records. + * 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 @@ -68,7 +68,7 @@ public with sharing class {ClassName} implements Database.Batchable, Da } /** - * @description Performs post-processing after all batches complete. + * Performs post-processing after all batches complete. * Logs a summary of the batch execution. * @param bc The batch context */ @@ -94,7 +94,7 @@ public with sharing class {ClassName} implements Database.Batchable, Da // ─── Private Helpers ───────────────────────────────────────────────── /** - * @description Processes Database.SaveResult list, tracking successes and failures + * Processes Database.SaveResult list, tracking successes and failures * @param results List of SaveResult from a DML operation */ private void processResults(List results) { @@ -116,7 +116,7 @@ public with sharing class {ClassName} implements Database.Batchable, Da // ─── Static Helpers ────────────────────────────────────────────────── /** - * @description Convenience method to execute with default batch size + * Convenience method to execute with default batch size * @return The batch job Id */ public static Id run() { diff --git a/skills/generating-apex/assets/domain.cls b/skills/generating-apex/assets/domain.cls index e32615a..6c2dcbd 100644 --- a/skills/generating-apex/assets/domain.cls +++ b/skills/generating-apex/assets/domain.cls @@ -1,5 +1,5 @@ /** - * @description Domain class for {SObject}. + * 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 @@ -12,7 +12,7 @@ public with sharing class {SObject}Domain { // ─── Field Defaults ────────────────────────────────────────────────── /** - * @description Applies default field values to new {SObject} records. + * 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 */ @@ -33,7 +33,7 @@ public with sharing class {SObject}Domain { // ─── Derivations ──────────────────────────────────────────────────── /** - * @description Derives calculated field values based on other fields. + * 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 */ @@ -52,7 +52,7 @@ public with sharing class {SObject}Domain { // ─── Validations ──────────────────────────────────────────────────── /** - * @description Validates {SObject} records and adds errors for any violations. + * Validates {SObject} records and adds errors for any violations. * Call this before insert and before update. * @param records List of {SObject} records to validate */ @@ -73,7 +73,7 @@ public with sharing class {SObject}Domain { // ─── Comparisons ──────────────────────────────────────────────────── /** - * @description Determines which fields have changed between old and new record versions. + * 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 diff --git a/skills/generating-apex/assets/dto.cls b/skills/generating-apex/assets/dto.cls index 7477663..ff1609a 100644 --- a/skills/generating-apex/assets/dto.cls +++ b/skills/generating-apex/assets/dto.cls @@ -1,5 +1,5 @@ /** - * @description Data Transfer Object for {describe the data this DTO represents}. + * 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 @@ -15,16 +15,16 @@ public with sharing class {ClassName} { // ─── Properties ────────────────────────────────────────────────────── - /** @description {Describe this property} */ + /** {Describe this property} */ public String name { get; set; } - /** @description {Describe this property} */ + /** {Describe this property} */ public Id recordId { get; set; } - /** @description {Describe this property} */ + /** {Describe this property} */ public Boolean isActive { get; set; } - /** @description {Describe this property} */ + /** {Describe this property} */ public List tags { get; set; } // TODO: Add additional properties as needed @@ -32,7 +32,7 @@ public with sharing class {ClassName} { // ─── Constructors ──────────────────────────────────────────────────── /** - * @description No-arg constructor for deserialization compatibility + * No-arg constructor for deserialization compatibility */ public {ClassName}() { this.tags = new List(); @@ -40,7 +40,7 @@ public with sharing class {ClassName} { } /** - * @description Parameterized constructor for convenience + * Parameterized constructor for convenience * @param name The name value * @param recordId The associated record Id */ @@ -53,7 +53,7 @@ public with sharing class {ClassName} { // ─── Factory Methods ───────────────────────────────────────────────── /** - * @description Creates a DTO instance from an SObject record + * Creates a DTO instance from an SObject record * @param record The source {SObject} record * @return A populated {ClassName} instance */ @@ -71,7 +71,7 @@ public with sharing class {ClassName} { } /** - * @description Creates a list of DTOs from a list of SObject records + * Creates a list of DTOs from a list of SObject records * @param records The source records * @return List of populated {ClassName} instances */ @@ -90,7 +90,7 @@ public with sharing class {ClassName} { // ─── Utility Methods ───────────────────────────────────────────────── /** - * @description Serializes this DTO to a JSON string + * Serializes this DTO to a JSON string * @return JSON representation of this DTO */ public String toJson() { @@ -98,7 +98,7 @@ public with sharing class {ClassName} { } /** - * @description Deserializes a JSON string into a {ClassName} instance + * Deserializes a JSON string into a {ClassName} instance * @param jsonString The JSON string to deserialize * @return A {ClassName} instance */ diff --git a/skills/generating-apex/assets/exception.cls b/skills/generating-apex/assets/exception.cls index 1d14d94..a18978a 100644 --- a/skills/generating-apex/assets/exception.cls +++ b/skills/generating-apex/assets/exception.cls @@ -1,5 +1,5 @@ /** - * @description Custom exception for {describe when this exception is thrown}. + * 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 diff --git a/skills/generating-apex/assets/interface.cls b/skills/generating-apex/assets/interface.cls index fc1da63..15796cd 100644 --- a/skills/generating-apex/assets/interface.cls +++ b/skills/generating-apex/assets/interface.cls @@ -1,5 +1,5 @@ /** - * @description Interface for {describe the capability or contract this interface defines}. + * 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 * @@ -13,7 +13,7 @@ public interface {InterfaceName} { /** - * @description {Describe what this method should do} + * {Describe what this method should do} * @param params {Describe the parameter} * @return {Describe the return value} */ diff --git a/skills/generating-apex/assets/invocable.cls b/skills/generating-apex/assets/invocable.cls new file mode 100644 index 0000000..18193d1 --- /dev/null +++ b/skills/generating-apex/assets/invocable.cls @@ -0,0 +1,115 @@ +/** + * Invocable Apex action for {describe the action}. + * Callable from Flows, Process Builder, and Agentforce. + * Accepts bulkified List, returns List. + * @author Generated by Apex Class Writer Skill + */ +public with sharing class {ClassName} { + + // ─── Invocable Method ──────────────────────────────────────────────── + + /** + * {Describe what this action does} + * @param requests List of Request inputs from the calling Flow + * @return List of Response outputs returned to the calling Flow + */ + @InvocableMethod( + label='{Display Name}' + description='{Describe the action for Flow Builder}' + category='{Category}' + ) + public static List execute(List requests) { + List responses = new List(); + + // Collect all Ids upfront for bulkified query + Set allRecordIds = new Set(); + for (Request req : requests) { + if (req.recordId != null) { + allRecordIds.add(req.recordId); + } + } + + // Single bulkified query outside the loop + Map recordMap = allRecordIds.isEmpty() + ? new Map() + : new Map([ + SELECT Id, Name + // TODO: Add required fields + FROM {SObject} + WHERE Id IN :allRecordIds + WITH USER_MODE + ]); + + // Process each request + for (Request req : requests) { + responses.add(processRequest(req, recordMap)); + } + + return responses; + } + + // ─── Private Helpers ───────────────────────────────────────────────── + + /** + * Processes a single request and returns a response. + * Errors are captured in the response, not thrown. + * @param req The input request + * @param recordMap Pre-queried records keyed by Id + * @return A Response with success/error information + */ + private static Response processRequest(Request req, Map recordMap) { + Response res = new Response(); + + try { + {SObject} record = recordMap.get(req.recordId); + if (record == null) { + res.isSuccess = false; + res.errorMessage = 'Record not found: ' + req.recordId; + res.errorType = 'NOT_FOUND'; + return res; + } + + // TODO: Implement business logic + + res.isSuccess = true; + } catch (Exception e) { + res.isSuccess = false; + res.errorMessage = e.getMessage(); + res.errorType = e.getTypeName(); + } + + return res; + } + + // ─── Request / Response DTOs ───────────────────────────────────────── + + /** + * Input parameters from the calling Flow + */ + public class Request { + @InvocableVariable(label='Record Id' description='The Id of the record to process' required=true) + public Id recordId; + + // TODO: Add additional input variables + // @InvocableVariable(label='Field Value' description='Value to set' required=false) + // public String fieldValue; + } + + /** + * Output results returned to the calling Flow + */ + public class Response { + @InvocableVariable(label='Success' description='Whether the action succeeded') + public Boolean isSuccess; + + @InvocableVariable(label='Error Message' description='Error details if the action failed') + public String errorMessage; + + @InvocableVariable(label='Error Type' description='Exception type name if the action failed') + public String errorType; + + // TODO: Add additional output variables + // @InvocableVariable(label='Result Id' description='Id of the created/updated record') + // public Id resultId; + } +} diff --git a/skills/generating-apex/assets/queueable.cls b/skills/generating-apex/assets/queueable.cls index eaa224a..8629b6c 100644 --- a/skills/generating-apex/assets/queueable.cls +++ b/skills/generating-apex/assets/queueable.cls @@ -1,5 +1,5 @@ /** - * @description Queueable Apex class for {describe the async operation}. + * 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 @@ -20,7 +20,7 @@ public with sharing class {ClassName} implements Queueable /*, Database.AllowsCa // ─── Constructors ──────────────────────────────────────────────────── /** - * @description Creates a new queueable job to process the specified records + * Creates a new queueable job to process the specified records * @param recordIds Set of record Ids to process */ public {ClassName}(Set recordIds) { @@ -28,7 +28,7 @@ public with sharing class {ClassName} implements Queueable /*, Database.AllowsCa } /** - * @description Creates a new queueable job with chain depth tracking + * 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 */ @@ -40,7 +40,7 @@ public with sharing class {ClassName} implements Queueable /*, Database.AllowsCa // ─── Queueable Interface ───────────────────────────────────────────── /** - * @description Executes the asynchronous work + * Executes the asynchronous work * @param context The queueable context */ public void execute(QueueableContext context) { @@ -64,7 +64,7 @@ public with sharing class {ClassName} implements Queueable /*, Database.AllowsCa // ─── Private Helpers ───────────────────────────────────────────────── /** - * @description Chains to the next queueable job if needed, with depth guard + * 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) @@ -78,7 +78,7 @@ public with sharing class {ClassName} implements Queueable /*, Database.AllowsCa } /** - * @description Handles errors during execution + * Handles errors during execution * @param jobId The async job Id * @param e The exception that occurred */ diff --git a/skills/generating-apex/assets/rest-resource.cls b/skills/generating-apex/assets/rest-resource.cls new file mode 100644 index 0000000..12d7a16 --- /dev/null +++ b/skills/generating-apex/assets/rest-resource.cls @@ -0,0 +1,300 @@ +/** + * REST API endpoint for {SObject} operations. + * Exposes CRUD operations via @RestResource at /services/apexrest/{urlPath}/v1/*. + * Uses versioned URL mapping for future-proof API evolution. + * All queries use WITH USER_MODE for CRUD/FLS enforcement. + * + * Authentication: OAuth 2.0 via Connected App + * Base URL: /services/apexrest/{urlPath}/v1/ + */ +@RestResource(urlMapping='/{urlPath}/v1/*') +global with sharing class {ClassName} { + + // ─── Constants ──────────────────────────────────────────────────────── + private static final Integer DEFAULT_PAGE_SIZE = 20; + private static final Integer MAX_PAGE_SIZE = 200; + private static final String ERROR_MISSING_ID = 'Record Id is required in the URL path.'; + private static final String ERROR_INVALID_ID = 'Invalid Salesforce Id format.'; + private static final String ERROR_MISSING_BODY = 'Request body is required.'; + private static final String ERROR_NOT_FOUND = '{SObject} record not found.'; + private static final String ERROR_INSUFFICIENT_ACCESS = 'Insufficient access to perform this operation.'; + + // ─── GET — Retrieve ─────────────────────────────────────────────────── + + /** + * Retrieves a single {SObject} by Id (URL path) or a paginated list (query params). + * Single: GET /services/apexrest/{urlPath}/v1/{recordId} + * List: GET /services/apexrest/{urlPath}/v1?pageSize=20&offset=0 + * @return ApiResponse containing the requested data + */ + @HttpGet + global static ApiResponse doGet() { + RestRequest req = RestContext.request; + RestResponse res = RestContext.response; + + try { + String recordId = extractIdFromUri(req.requestURI); + + if (String.isNotBlank(recordId)) { + return getSingleRecord(recordId, res); + } + return getRecordList(req, res); + + } catch (Exception e) { + return handleError(res, 500, e.getMessage()); + } + } + + // ─── POST — Create ─────────────────────────────────────────────────── + + /** + * Creates a new {SObject} record from the JSON request body. + * POST /services/apexrest/{urlPath}/v1/ + * @return ApiResponse with the created record Id + */ + @HttpPost + global static ApiResponse doPost() { + RestRequest req = RestContext.request; + RestResponse res = RestContext.response; + + try { + if (req.requestBody == null || String.isBlank(req.requestBody.toString())) { + return handleError(res, 400, ERROR_MISSING_BODY); + } + + {ClassName}Request payload = ({ClassName}Request) JSON.deserialize( + req.requestBody.toString(), + {ClassName}Request.class + ); + + // TODO: Map request payload to SObject fields + {SObject} record = new {SObject}( + Name = payload.name + ); + + Database.SaveResult saveResult = Database.insert(record, true); + if (saveResult.isSuccess()) { + res.statusCode = 201; + return new ApiResponse(true, 'Record created successfully.', record.Id); + } + + return handleError(res, 422, saveResult.getErrors()[0].getMessage()); + + } catch (JSONException e) { + return handleError(res, 400, 'Malformed JSON: ' + e.getMessage()); + } catch (DmlException e) { + return handleError(res, 422, 'Validation failed: ' + e.getDmlMessage(0)); + } catch (Exception e) { + return handleError(res, 500, e.getMessage()); + } + } + + // ─── PATCH — Partial Update ─────────────────────────────────────────── + + /** + * Partially updates an existing {SObject} record. + * PATCH /services/apexrest/{urlPath}/v1/{recordId} + * @return ApiResponse confirming the update + */ + @HttpPatch + global static ApiResponse doPatch() { + RestRequest req = RestContext.request; + RestResponse res = RestContext.response; + + try { + String recordId = extractIdFromUri(req.requestURI); + if (String.isBlank(recordId)) { + return handleError(res, 400, ERROR_MISSING_ID); + } + if (!isValidSalesforceId(recordId)) { + return handleError(res, 400, ERROR_INVALID_ID); + } + if (req.requestBody == null || String.isBlank(req.requestBody.toString())) { + return handleError(res, 400, ERROR_MISSING_BODY); + } + + List<{SObject}> existing = [ + SELECT Id FROM {SObject} WHERE Id = :recordId WITH USER_MODE LIMIT 1 + ]; + if (existing.isEmpty()) { + return handleError(res, 404, ERROR_NOT_FOUND); + } + + Map fieldUpdates = (Map) JSON.deserializeUntyped( + req.requestBody.toString() + ); + + {SObject} record = existing[0]; + // TODO: Apply allowed field updates from the payload to the record + // for (String fieldName : fieldUpdates.keySet()) { + // record.put(fieldName, fieldUpdates.get(fieldName)); + // } + + Database.SaveResult saveResult = Database.update(record, true); + if (saveResult.isSuccess()) { + res.statusCode = 200; + return new ApiResponse(true, 'Record updated successfully.', record.Id); + } + + return handleError(res, 422, saveResult.getErrors()[0].getMessage()); + + } catch (JSONException e) { + return handleError(res, 400, 'Malformed JSON: ' + e.getMessage()); + } catch (DmlException e) { + return handleError(res, 422, 'Validation failed: ' + e.getDmlMessage(0)); + } catch (Exception e) { + return handleError(res, 500, e.getMessage()); + } + } + + // ─── DELETE — Remove ────────────────────────────────────────────────── + + /** + * Deletes a {SObject} record by Id. + * DELETE /services/apexrest/{urlPath}/v1/{recordId} + * @return ApiResponse confirming the deletion + */ + @HttpDelete + global static ApiResponse doDelete() { + RestRequest req = RestContext.request; + RestResponse res = RestContext.response; + + try { + String recordId = extractIdFromUri(req.requestURI); + if (String.isBlank(recordId)) { + return handleError(res, 400, ERROR_MISSING_ID); + } + if (!isValidSalesforceId(recordId)) { + return handleError(res, 400, ERROR_INVALID_ID); + } + + List<{SObject}> existing = [ + SELECT Id FROM {SObject} WHERE Id = :recordId WITH USER_MODE LIMIT 1 + ]; + if (existing.isEmpty()) { + return handleError(res, 404, ERROR_NOT_FOUND); + } + + Database.DeleteResult deleteResult = Database.delete(existing[0], true); + if (deleteResult.isSuccess()) { + res.statusCode = 200; + return new ApiResponse(true, 'Record deleted successfully.', recordId); + } + + return handleError(res, 422, deleteResult.getErrors()[0].getMessage()); + + } catch (DmlException e) { + return handleError(res, 422, e.getDmlMessage(0)); + } catch (Exception e) { + return handleError(res, 500, e.getMessage()); + } + } + + // ─── Private Helpers ────────────────────────────────────────────────── + + private static ApiResponse getSingleRecord(String recordId, RestResponse res) { + if (!isValidSalesforceId(recordId)) { + return handleError(res, 400, ERROR_INVALID_ID); + } + + List<{SObject}> records = [ + SELECT Id, Name, CreatedDate, LastModifiedDate + FROM {SObject} + WHERE Id = :recordId + WITH USER_MODE + LIMIT 1 + ]; + + if (records.isEmpty()) { + return handleError(res, 404, ERROR_NOT_FOUND); + } + + res.statusCode = 200; + ApiResponse response = new ApiResponse(true, 'Record retrieved successfully.', recordId); + response.data = records[0]; + return response; + } + + private static ApiResponse getRecordList(RestRequest req, RestResponse res) { + Integer pageSize = getIntParam(req, 'pageSize', DEFAULT_PAGE_SIZE); + Integer offset = getIntParam(req, 'offset', 0); + + pageSize = Math.min(pageSize, MAX_PAGE_SIZE); + + List<{SObject}> records = [ + SELECT Id, Name, CreatedDate, LastModifiedDate + FROM {SObject} + WITH USER_MODE + ORDER BY Name ASC + LIMIT :pageSize + OFFSET :offset + ]; + + res.statusCode = 200; + ApiResponse response = new ApiResponse(true, 'Records retrieved successfully.', null); + response.records = records; + response.pageSize = pageSize; + response.offset = offset; + return response; + } + + private static String extractIdFromUri(String uri) { + String lastSegment = uri.substringAfterLast('/'); + if (String.isBlank(lastSegment) || lastSegment == 'v1') { + return null; + } + return lastSegment; + } + + private static Boolean isValidSalesforceId(String idValue) { + return Pattern.matches('[a-zA-Z0-9]{15,18}', idValue); + } + + private static Integer getIntParam(RestRequest req, String paramName, Integer defaultValue) { + String paramValue = req.params.get(paramName); + if (String.isBlank(paramValue)) { + return defaultValue; + } + try { + return Integer.valueOf(paramValue); + } catch (TypeException e) { + return defaultValue; + } + } + + private static ApiResponse handleError(RestResponse res, Integer statusCode, String message) { + res.statusCode = statusCode; + return new ApiResponse(false, message, null); + } + + // ─── Request / Response DTOs ────────────────────────────────────────── + + /** + * Inbound request payload for POST operations. + * Extend with additional fields as needed. + */ + global class {ClassName}Request { + global String name; + // TODO: Add fields matching the expected JSON request body + } + + /** + * Standardized API response envelope. + * All endpoints return this structure for consistent client parsing. + */ + global class ApiResponse { + global Boolean success; + global String message; + global String recordId; + global SObject data; + global List records; + global Integer pageSize; + global Integer offset; + + global ApiResponse(Boolean success, String message, String recordId) { + this.success = success; + this.message = message; + this.recordId = recordId; + } + } +} \ No newline at end of file diff --git a/skills/generating-apex/assets/schedulable.cls b/skills/generating-apex/assets/schedulable.cls index 1e9f932..fe402db 100644 --- a/skills/generating-apex/assets/schedulable.cls +++ b/skills/generating-apex/assets/schedulable.cls @@ -1,5 +1,5 @@ /** - * @description Schedulable Apex class for {describe the scheduled operation}. + * 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 @@ -20,19 +20,19 @@ 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 */ + /** Runs daily at 2:00 AM */ public static final String CRON_DAILY_2AM = '0 0 2 * * ?'; - /** @description Runs every weekday at 6:00 AM */ + /** 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 */ + /** 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. + * Entry point for the scheduled execution. * Delegates to a Batch or Queueable for the actual work. * @param sc The schedulable context */ @@ -49,7 +49,7 @@ public with sharing class {ClassName} implements Schedulable { // ─── Convenience Scheduling Methods ────────────────────────────────── /** - * @description Schedules this job to run daily at 2 AM + * Schedules this job to run daily at 2 AM * @return The scheduled job Id */ public static String scheduleDaily() { @@ -61,7 +61,7 @@ public with sharing class {ClassName} implements Schedulable { } /** - * @description Aborts this scheduled job by name + * Aborts this scheduled job by name * @param jobName The name used when scheduling */ public static void abort(String jobName) { diff --git a/skills/generating-apex/assets/selector.cls b/skills/generating-apex/assets/selector.cls index 3512465..be24059 100644 --- a/skills/generating-apex/assets/selector.cls +++ b/skills/generating-apex/assets/selector.cls @@ -1,15 +1,15 @@ /** - * @description Selector class for {SObject} queries. + * 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 { +public inherited sharing class {SObject}Selector { // ─── Field Lists ───────────────────────────────────────────────────── /** - * @description Returns the default set of fields to query for {SObject}. + * 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 */ @@ -29,7 +29,7 @@ public with sharing class {SObject}Selector { // ─── Query Methods ─────────────────────────────────────────────────── /** - * @description Selects {SObject} records by their Ids + * Selects {SObject} records by their Ids * @param recordIds Set of {SObject} Ids to query * @return List of {SObject} records matching the provided Ids * @example @@ -49,7 +49,7 @@ public with sharing class {SObject}Selector { } /** - * @description Selects {SObject} records as a Map keyed by Id + * Selects {SObject} records as a Map keyed by Id * @param recordIds Set of {SObject} Ids to query * @return Map of Id to {SObject} */ @@ -58,7 +58,7 @@ public with sharing class {SObject}Selector { } /** - * @description Selects {SObject} records by a specific field value + * 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 @@ -86,7 +86,7 @@ public with sharing class {SObject}Selector { // ─── Exception ─────────────────────────────────────────────────────── /** - * @description Custom exception for query errors + * 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 index 5f3a6c3..6f802e6 100644 --- a/skills/generating-apex/assets/service.cls +++ b/skills/generating-apex/assets/service.cls @@ -1,5 +1,5 @@ /** - * @description Service class for {SObject} business logic. + * 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 @@ -12,7 +12,7 @@ public with sharing class {SObject}Service { // ─── Public API ────────────────────────────────────────────────────── /** - * @description {Describe the primary operation} + * {Describe the primary operation} * @param recordIds Set of {SObject} Ids to process * @return List of processed {SObject} records * @throws {SObject}ServiceException if processing fails @@ -47,7 +47,7 @@ public with sharing class {SObject}Service { // ─── Convenience Overloads ─────────────────────────────────────────── /** - * @description Single-record convenience overload + * Single-record convenience overload * @param recordId The {SObject} Id to process * @return The processed {SObject} record */ @@ -63,7 +63,7 @@ public with sharing class {SObject}Service { // ─── Exception ─────────────────────────────────────────────────────── /** - * @description Custom exception for {SObject}Service errors + * Custom exception for {SObject}Service errors */ public class {SObject}ServiceException extends Exception {} } diff --git a/skills/generating-apex/assets/trigger.cls b/skills/generating-apex/assets/trigger.cls new file mode 100644 index 0000000..7fc2d75 --- /dev/null +++ b/skills/generating-apex/assets/trigger.cls @@ -0,0 +1,45 @@ +/** + * @author Generated by Apex Class Writer Skill + */ +trigger {SObject}Trigger on {SObject} ( + before insert, + before update, + before delete, + after insert, + after update, + after delete, + after undelete +) { + // ─── Option A: Trigger Actions Framework (TAF) ─────────────────────── + // Delegates to Trigger_Action__mdt-registered action classes. + // Each action class handles one concern in one context. + // + // new MetadataTriggerHandler().run(); + + // ─── Option B: Custom Handler Pattern ──────────────────────────────── + // Delegates to a single handler class that routes by context. + // + // {SObject}TriggerHandler handler = new {SObject}TriggerHandler(); + // + // if (Trigger.isBefore) { + // if (Trigger.isInsert) { + // handler.beforeInsert(Trigger.new); + // } else if (Trigger.isUpdate) { + // handler.beforeUpdate(Trigger.new, Trigger.oldMap); + // } else if (Trigger.isDelete) { + // handler.beforeDelete(Trigger.old, Trigger.oldMap); + // } + // } else if (Trigger.isAfter) { + // if (Trigger.isInsert) { + // handler.afterInsert(Trigger.new, Trigger.newMap); + // } else if (Trigger.isUpdate) { + // handler.afterUpdate(Trigger.new, Trigger.oldMap); + // } else if (Trigger.isDelete) { + // handler.afterDelete(Trigger.old, Trigger.oldMap); + // } else if (Trigger.isUndelete) { + // handler.afterUndelete(Trigger.new, Trigger.newMap); + // } + // } + + // TODO: Uncomment one option above and remove the other +} diff --git a/skills/generating-apex/assets/utility.cls b/skills/generating-apex/assets/utility.cls index 9f3f9fc..718df49 100644 --- a/skills/generating-apex/assets/utility.cls +++ b/skills/generating-apex/assets/utility.cls @@ -1,5 +1,5 @@ /** - * @description Utility class for {describe the category of utilities: String, Date, Collection, etc.}. + * 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 @@ -9,7 +9,7 @@ public with sharing class {ClassName} { // ─── Private Constructor ───────────────────────────────────────────── /** - * @description Prevents instantiation — use static methods only + * Prevents instantiation — use static methods only */ @TestVisible private {ClassName}() { @@ -21,7 +21,7 @@ public with sharing class {ClassName} { // TODO: Add utility methods below. Examples: /** - * @description Safely converts a String to an Integer, returning a default if parsing fails + * 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 @@ -41,7 +41,7 @@ public with sharing class {ClassName} { } /** - * @description Chunks a list into smaller sublists of the specified size. + * 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 @@ -72,7 +72,7 @@ public with sharing class {ClassName} { } /** - * @description Extracts a Set of non-null field values from a list of SObjects + * 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 diff --git a/skills/generating-apex/references/AccountDeduplicationBatch.cls b/skills/generating-apex/references/AccountDeduplicationBatch.cls index 30ba97d..2d6f005 100644 --- a/skills/generating-apex/references/AccountDeduplicationBatch.cls +++ b/skills/generating-apex/references/AccountDeduplicationBatch.cls @@ -1,5 +1,5 @@ /** - * @description Batch Apex class for identifying and flagging duplicate Account records. + * 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. @@ -26,7 +26,7 @@ public with sharing class AccountDeduplicationBatch implements Database.Batchabl // ─── Batchable Interface ───────────────────────────────────────────── /** - * @description Queries all active Accounts that haven't already been flagged + * Queries all active Accounts that haven't already been flagged * @param bc The batch context * @return QueryLocator scoped to unflagged active Accounts */ @@ -41,7 +41,7 @@ public with sharing class AccountDeduplicationBatch implements Database.Batchabl } /** - * @description Processes each batch by building a duplicate key and checking for matches. + * 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 @@ -82,7 +82,7 @@ public with sharing class AccountDeduplicationBatch implements Database.Batchabl } /** - * @description Logs a summary of the deduplication batch run + * Logs a summary of the deduplication batch run * @param bc The batch context */ public void finish(Database.BatchableContext bc) { @@ -101,7 +101,7 @@ public with sharing class AccountDeduplicationBatch implements Database.Batchabl // ─── Private Helpers ───────────────────────────────────────────────── /** - * @description Builds a normalized composite key for duplicate detection + * Builds a normalized composite key for duplicate detection * @param acct The Account record * @return Normalized key string, or null if insufficient data */ @@ -117,7 +117,7 @@ public with sharing class AccountDeduplicationBatch implements Database.Batchabl } /** - * @description Processes DML results, tracking successes and failures + * Processes DML results, tracking successes and failures * @param results List of Database.SaveResult from update operation */ private void processResults(List results) { @@ -139,7 +139,7 @@ public with sharing class AccountDeduplicationBatch implements Database.Batchabl // ─── Static Helpers ────────────────────────────────────────────────── /** - * @description Convenience method to execute with default batch size + * Convenience method to execute with default batch size * @return The batch job Id */ public static Id run() { diff --git a/skills/generating-apex/references/AccountSelector.cls b/skills/generating-apex/references/AccountSelector.cls index 990f043..1d826a1 100644 --- a/skills/generating-apex/references/AccountSelector.cls +++ b/skills/generating-apex/references/AccountSelector.cls @@ -1,5 +1,5 @@ /** - * @description Selector class for Account queries. + * 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 @@ -9,7 +9,7 @@ public with sharing class AccountSelector { // ─── Field Lists ───────────────────────────────────────────────────── /** - * @description Returns the default set of fields to query for Account. + * 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 */ @@ -34,7 +34,7 @@ public with sharing class AccountSelector { } /** - * @description Returns fields needed for billing/territory operations + * Returns fields needed for billing/territory operations * @return Comma-separated field list as a String */ private static String getBillingFields() { @@ -56,7 +56,7 @@ public with sharing class AccountSelector { // ─── Query Methods ─────────────────────────────────────────────────── /** - * @description Selects Account records by their Ids + * Selects Account records by their Ids * @param recordIds Set of Account Ids to query * @return List of Account records matching the provided Ids * @example @@ -76,7 +76,7 @@ public with sharing class AccountSelector { } /** - * @description Selects Account records as a Map keyed by Id + * Selects Account records as a Map keyed by Id * @param recordIds Set of Account Ids to query * @return Map of Id to Account */ @@ -85,7 +85,7 @@ public with sharing class AccountSelector { } /** - * @description Selects Accounts with billing address fields for territory assignment + * 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 */ @@ -102,7 +102,7 @@ public with sharing class AccountSelector { } /** - * @description Selects Accounts by Account Type + * Selects Accounts by Account Type * @param accountTypes Set of Account Type values to filter by * @return List of matching Account records * @example @@ -124,7 +124,7 @@ public with sharing class AccountSelector { } /** - * @description Selects Accounts by Industry with a minimum annual revenue + * 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 @@ -151,7 +151,7 @@ public with sharing class AccountSelector { } /** - * @description Selects Accounts with their related Contacts (subquery) + * Selects Accounts with their related Contacts (subquery) * @param recordIds Set of Account Ids to query * @return List of Account records with nested Contacts */ @@ -173,7 +173,7 @@ public with sharing class AccountSelector { // ─── Aggregate Queries ─────────────────────────────────────────────── /** - * @description Returns a count of Accounts grouped by Industry + * Returns a count of Accounts grouped by Industry * @return List of AggregateResult with Industry and record count * @example * List results = AccountSelector.countByIndustry(); diff --git a/skills/generating-apex/references/AccountService.cls b/skills/generating-apex/references/AccountService.cls index eafee0a..0ec3cb8 100644 --- a/skills/generating-apex/references/AccountService.cls +++ b/skills/generating-apex/references/AccountService.cls @@ -1,5 +1,5 @@ /** - * @description Service class for Account business logic. + * 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 @@ -14,7 +14,7 @@ public with sharing class AccountService { // ─── Public API ────────────────────────────────────────────────────── /** - * @description Merges duplicate Account records into a master record. + * 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 @@ -77,7 +77,7 @@ public with sharing class AccountService { } /** - * @description Assigns accounts to territories based on Billing State/Country. + * 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 @@ -115,7 +115,7 @@ public with sharing class AccountService { // ─── Convenience Overloads ─────────────────────────────────────────── /** - * @description Single-account territory assignment convenience method + * 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 */ @@ -126,7 +126,7 @@ public with sharing class AccountService { // ─── Private Helpers ───────────────────────────────────────────────── /** - * @description Loads territory mappings from Custom Metadata + * Loads territory mappings from Custom Metadata * @return Map of territory key (State:Country) to territory name */ private static Map loadTerritoryMappings() { @@ -139,7 +139,7 @@ public with sharing class AccountService { } /** - * @description Builds a consistent territory lookup key + * Builds a consistent territory lookup key * @param state The billing state * @param country The billing country * @return A normalized key string @@ -149,7 +149,7 @@ public with sharing class AccountService { } /** - * @description Chunks a list of Accounts into sublists of the given size + * 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 @@ -172,7 +172,7 @@ public with sharing class AccountService { } /** - * @description Counts successful results from a DML operation + * Counts successful results from a DML operation * @param results List of Database.SaveResult * @return Count of successful operations */ @@ -195,7 +195,7 @@ public with sharing class AccountService { // ─── Exception ─────────────────────────────────────────────────────── /** - * @description Custom exception for AccountService errors + * Custom exception for AccountService errors */ public class AccountServiceException extends Exception {} }