diff --git a/force-app/main/default/classes/Async.cls b/force-app/main/default/classes/Async.cls
index 38bd8d3..4b65ddc 100644
--- a/force-app/main/default/classes/Async.cls
+++ b/force-app/main/default/classes/Async.cls
@@ -15,8 +15,8 @@ public inherited sharing class Async {
return QueueableManager.get().getQueueableJobContext();
}
- public static Id getQueueableChainBatchId() {
- return QueueableManager.get().getQueueableChainBatchId();
+ public static Id getQueueableChainSchedulableId() {
+ return QueueableManager.get().getQueueableChainSchedulableId();
}
public static QueueableChainState getCurrentQueueableChainState() {
diff --git a/force-app/main/default/classes/Async.cls-meta.xml b/force-app/main/default/classes/Async.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/Async.cls-meta.xml
+++ b/force-app/main/default/classes/Async.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls
index 952a4b3..22a1b9e 100644
--- a/force-app/main/default/classes/AsyncTest.cls
+++ b/force-app/main/default/classes/AsyncTest.cls
@@ -19,7 +19,8 @@ private class AsyncTest implements Database.Batchable {
SuccessfulQueueableTest q = new SuccessfulQueueableTest();
Assert.areEqual(0, [SELECT COUNT() FROM Account]);
- Id initialQueuableChainBatchJobId;
+ Id initialQueuableChainSchedulableId;
+ List cronTriggers;
Test.startTest();
for (Integer idx = 0; idx < 60; idx++) {
@@ -32,13 +33,18 @@ private class AsyncTest implements Database.Batchable {
);
} else {
Assert.areEqual(
- QueueableManager.EnqueueType.INITIAL_SCHEDULED_BATCH_JOB,
+ QueueableManager.EnqueueType.INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE,
ar.queueableChainState.enqueueType,
'Should be enqueued via batch.'
);
}
}
- initialQueuableChainBatchJobId = Async.getQueueableChainBatchId();
+ initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId();
+ cronTriggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
+ ];
Test.stopTest();
Assert.areEqual(
@@ -46,35 +52,46 @@ private class AsyncTest implements Database.Batchable {
[SELECT COUNT() FROM Account],
'Should have 50 job runs (50 queueable and 1 scheduled batch that was not executed yet).'
);
- List cronTriggers = [
- SELECT Id
- FROM CronTrigger
- WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%'
- ];
Assert.areEqual(
1,
cronTriggers.size(),
'Should schedule only one batchable job with the rest.'
);
Assert.areEqual(
- initialQueuableChainBatchJobId,
+ initialQueuableChainSchedulableId,
cronTriggers[0].Id,
- 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()'
+ 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()'
+ );
+ List currentCronTriggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
+ ];
+ Assert.areEqual(
+ 0,
+ currentCronTriggers.size(),
+ 'The scheduled job should be deleted once finished.'
);
}
@IsTest
- private static void shouldEnqueue60QueueablesWitDelaySuccessfully() {
+ private static void shouldEnqueue60QueueablesWithDelaySuccessfully() {
SuccessfulQueueableTest q = new SuccessfulQueueableTest();
Assert.areEqual(0, [SELECT COUNT() FROM Account]);
- Id initialQueuableChainBatchJobId;
+ Id initialQueuableChainSchedulableId;
+ List cronTriggers;
Test.startTest();
for (Integer idx = 0; idx < 60; idx++) {
Async.queueable(q).delay(1).enqueue();
}
- initialQueuableChainBatchJobId = Async.getQueueableChainBatchId();
+ initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId();
+ cronTriggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
+ ];
Test.stopTest();
Assert.areEqual(
@@ -82,20 +99,25 @@ private class AsyncTest implements Database.Batchable {
[SELECT COUNT() FROM Account],
'Should have 50 job runs (50 queueable and 1 scheduled batch that was not executed yet).'
);
- List cronTriggers = [
- SELECT Id
- FROM CronTrigger
- WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%'
- ];
Assert.areEqual(
1,
cronTriggers.size(),
'Should schedule only one batchable job with the rest.'
);
Assert.areEqual(
- initialQueuableChainBatchJobId,
+ initialQueuableChainSchedulableId,
cronTriggers[0].Id,
- 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()'
+ 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()'
+ );
+ List currentCronTriggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
+ ];
+ Assert.areEqual(
+ 0,
+ currentCronTriggers.size(),
+ 'The scheduled job should be deleted once finished.'
);
}
@@ -104,30 +126,41 @@ private class AsyncTest implements Database.Batchable {
FailureQueueableTest q = new FailureQueueableTest();
Assert.areEqual(0, [SELECT COUNT() FROM Account]);
- Id initialQueuableChainBatchJobId;
+ Id initialQueuableChainSchedulableId;
+ List cronTriggers;
Test.startTest();
for (Integer idx = 0; idx < 60; idx++) {
Async.queueable(q).continueOnJobExecuteFail().rollbackOnJobExecuteFail().enqueue();
}
- initialQueuableChainBatchJobId = Async.getQueueableChainBatchId();
- Test.stopTest();
-
- Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Should have no Accounts created.');
- List cronTriggers = [
+ initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId();
+ cronTriggers = [
SELECT Id
FROM CronTrigger
- WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%'
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
];
+ Test.stopTest();
+
+ Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Should have no Accounts created.');
Assert.areEqual(
1,
cronTriggers.size(),
'Should schedule only one batchable job with the rest.'
);
Assert.areEqual(
- initialQueuableChainBatchJobId,
+ initialQueuableChainSchedulableId,
cronTriggers[0].Id,
- 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()'
+ 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()'
+ );
+ List currentCronTriggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
+ ];
+ Assert.areEqual(
+ 0,
+ currentCronTriggers.size(),
+ 'The scheduled job should be deleted once finished.'
);
}
@@ -136,30 +169,41 @@ private class AsyncTest implements Database.Batchable {
FailureQueueableTest q = new FailureQueueableTest();
Assert.areEqual(0, [SELECT COUNT() FROM Account]);
- Id initialQueuableChainBatchJobId;
+ Id initialQueuableChainSchedulableId;
+ List cronTriggers;
Test.startTest();
for (Integer idx = 0; idx < 60; idx++) {
Async.queueable(q).continueOnJobExecuteFail().enqueue();
}
- initialQueuableChainBatchJobId = Async.getQueueableChainBatchId();
- Test.stopTest();
-
- Assert.areEqual(50, [SELECT COUNT() FROM Account], 'Inserted Accounts are not rollbacked.');
- List cronTriggers = [
+ initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId();
+ cronTriggers = [
SELECT Id
FROM CronTrigger
- WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%'
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
];
+ Test.stopTest();
+
+ Assert.areEqual(50, [SELECT COUNT() FROM Account], 'Inserted Accounts are not rollbacked.');
Assert.areEqual(
1,
cronTriggers.size(),
'Should schedule only one batchable job with the rest.'
);
Assert.areEqual(
- initialQueuableChainBatchJobId,
+ initialQueuableChainSchedulableId,
cronTriggers[0].Id,
- 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()'
+ 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()'
+ );
+ List currentCronTriggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%'
+ ];
+ Assert.areEqual(
+ 0,
+ currentCronTriggers.size(),
+ 'The scheduled job should be deleted once finished.'
);
}
@@ -901,38 +945,670 @@ private class AsyncTest implements Database.Batchable {
@IsTest
static void shouldSoftCloneTheJob() {
- String CHANGED_PRIMITIVE_VALUE = 'CHANGED_PRIMITIVE_VALUE';
+ String changedPrimitiveValue = 'changedPrimitiveValue';
QueueableJobTest1 job1 = new QueueableJobTest1();
Async.Result result1 = Async.queueable(job1).chain();
// Complex member is passed by reference, what change the first job value
- job1.complexMember.primitiveMember = CHANGED_PRIMITIVE_VALUE;
+ job1.complexMember.primitiveMember = changedPrimitiveValue;
Async.Result result2 = Async.queueable(job1).enqueue();
QueueableJobTest1 firstEnqueuedJob = (QueueableJobTest1) result1.job;
QueueableJobTest1 secondEnqueuedJob = (QueueableJobTest1) result2.job;
- Assert.areEqual(CHANGED_PRIMITIVE_VALUE, firstEnqueuedJob.complexMember.primitiveMember);
- Assert.areEqual(CHANGED_PRIMITIVE_VALUE, secondEnqueuedJob.complexMember.primitiveMember);
+ Assert.areEqual(changedPrimitiveValue, firstEnqueuedJob.complexMember.primitiveMember);
+ Assert.areEqual(changedPrimitiveValue, secondEnqueuedJob.complexMember.primitiveMember);
}
@IsTest
static void shouldDeepCloneTheJob() {
- String CHANGED_PRIMITIVE_VALUE = 'CHANGED_PRIMITIVE_VALUE';
+ String changedPrimitiveValue = 'changedPrimitiveValue';
QueueableJobTest1 job1 = new QueueableJobTest1();
Async.Result result1 = Async.queueable(job1).deepClone().chain();
// Due to above deep clone, all values above were passed by value
- job1.complexMember.primitiveMember = CHANGED_PRIMITIVE_VALUE;
+ job1.complexMember.primitiveMember = changedPrimitiveValue;
Async.Result result2 = Async.queueable(job1).enqueue();
QueueableJobTest1 firstEnqueuedJob = (QueueableJobTest1) result1.job;
QueueableJobTest1 secondEnqueuedJob = (QueueableJobTest1) result2.job;
Assert.areEqual(PRIMITIVE_VALUE_INITIAL, firstEnqueuedJob.complexMember.primitiveMember);
- Assert.areEqual(CHANGED_PRIMITIVE_VALUE, secondEnqueuedJob.complexMember.primitiveMember);
+ Assert.areEqual(changedPrimitiveValue, secondEnqueuedJob.complexMember.primitiveMember);
+ }
+
+ @IsTest
+ static void shouldExecuteChainedQueueableJob20Times() {
+ Integer noOfChainedJobs = 20;
+ Assert.areEqual(0, [SELECT COUNT() FROM Account]);
+
+ Test.startTest();
+ Async.queueable(new ChainedQueueableJob(noOfChainedJobs)).enqueue();
+ Test.stopTest();
+
+ Assert.areEqual(noOfChainedJobs, [SELECT COUNT() FROM Account]);
+ Assert.areEqual(
+ noOfChainedJobs,
+ [
+ SELECT COUNT()
+ FROM AsyncApexJob
+ WHERE
+ Status = 'Completed'
+ AND JobType = 'Queueable'
+ AND ApexClass.Name = 'AsyncTest'
+ ]
+ );
+ }
+
+ @IsTest
+ static void shouldFailToSetAsyncOptionsAfterDelay() {
+ SuccessfulQueueableTest q = new SuccessfulQueueableTest();
+
+ try {
+ Async.queueable(q).delay(1).asyncOptions(new AsyncOptions());
+ Assert.fail('Should throw exception when setting asyncOptions after delay.');
+ } catch (Exception ex) {
+ Assert.areEqual(
+ QueueableManager.ERROR_MESSAGE_ASYNC_OPTIONS_AFTER_DELAY,
+ ex.getMessage()
+ );
+ }
+ }
+
+ @IsTest
+ static void shouldFailToSetDelayAfterAsyncOptions() {
+ SuccessfulQueueableTest q = new SuccessfulQueueableTest();
+
+ try {
+ Async.queueable(q).asyncOptions(new AsyncOptions()).delay(1);
+ Assert.fail('Should throw exception when setting delay after asyncOptions.');
+ } catch (Exception ex) {
+ Assert.areEqual(
+ QueueableManager.ERROR_MESSAGE_DELAY_AFTER_ASYNC_OPTIONS,
+ ex.getMessage()
+ );
+ }
+ }
+
+ @IsTest
+ static void shouldSetPriorityOnJob() {
+ QueueableJobTest1 job = new QueueableJobTest1();
+
+ Async.Result result = Async.queueable(job).priority(10).chain();
+
+ Assert.areEqual(10, result.job.priority);
+ }
+
+ @IsTest
+ static void shouldFailWhenBatchScopeSizeIsZeroOrLess() {
+ try {
+ Async.batchable(new AsyncTest()).scopeSize(0).execute();
+ Assert.fail('Should throw exception when scope size is zero.');
+ } catch (Exception ex) {
+ Assert.areEqual('Scope size must be greater than zero.', ex.getMessage());
+ }
+
+ try {
+ Async.batchable(new AsyncTest()).scopeSize(-1).execute();
+ Assert.fail('Should throw exception when scope size is negative.');
+ } catch (Exception ex) {
+ Assert.areEqual('Scope size must be greater than zero.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenBatchMinutesFromNowIsZeroOrLess() {
+ try {
+ Async.batchable(new AsyncTest())
+ .minutesFromNow(0)
+ .asSchedulable()
+ .name(TEST_SCHEDULABLE_JOB_NAME)
+ .schedule();
+ Assert.fail('Should throw exception when minutes from now is zero.');
+ } catch (Exception ex) {
+ Assert.areEqual('Minutes from now must be greater than zero.', ex.getMessage());
+ }
+
+ try {
+ Async.batchable(new AsyncTest())
+ .minutesFromNow(-5)
+ .asSchedulable()
+ .name(TEST_SCHEDULABLE_JOB_NAME)
+ .schedule();
+ Assert.fail('Should throw exception when minutes from now is negative.');
+ } catch (Exception ex) {
+ Assert.areEqual('Minutes from now must be greater than zero.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenBatchJobIsNull() {
+ try {
+ BatchableManager.get().execute(null);
+ Assert.fail('Should throw exception when builder is null.');
+ } catch (Exception ex) {
+ Assert.areEqual('Batch job must be set.', ex.getMessage());
+ }
+
+ try {
+ BatchableBuilder builder = new BatchableBuilder(new AsyncTest());
+ builder.job = null;
+ BatchableManager.get().execute(builder);
+ Assert.fail('Should throw exception when job is null.');
+ } catch (Exception ex) {
+ Assert.areEqual('Batch job must be set.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldExecuteBatchWithDefaultScopeSize() {
+ Test.startTest();
+ Async.Result result = Async.batchable(new AsyncTest()).execute();
+ Test.stopTest();
+
+ Assert.isNotNull(result.salesforceJobId);
+ Assert.areEqual(Async.AsyncType.BATCHABLE, result.asyncType);
+ }
+
+ @IsTest
+ static void shouldFailWhenCronExpressionIsBlank() {
+ try {
+ new CronBuilder('');
+ Assert.fail('Should throw exception when cron expression is blank.');
+ } catch (Exception ex) {
+ Assert.areEqual('Cron expression cannot be blank.', ex.getMessage());
+ }
+
+ try {
+ new CronBuilder(' ');
+ Assert.fail('Should throw exception when cron expression is whitespace.');
+ } catch (Exception ex) {
+ Assert.areEqual('Cron expression cannot be blank.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenCronExpressionHasInvalidParts() {
+ try {
+ new CronBuilder('0 0 * *');
+ Assert.fail('Should throw exception when cron has less than 6 parts.');
+ } catch (Exception ex) {
+ Assert.isTrue(ex.getMessage().contains('must have 6 or 7 parts'));
+ }
+
+ try {
+ new CronBuilder('0 0 * * * ? * 2024 extra');
+ Assert.fail('Should throw exception when cron has more than 7 parts.');
+ } catch (Exception ex) {
+ Assert.isTrue(ex.getMessage().contains('must have 6 or 7 parts'));
+ }
+ }
+
+ @IsTest
+ static void shouldParseCronExpressionWith7Parts() {
+ CronBuilder cron = new CronBuilder('0 30 10 * * ? 2025');
+
+ Assert.areEqual('0', cron.second);
+ Assert.areEqual('30', cron.minute);
+ Assert.areEqual('10', cron.hour);
+ Assert.areEqual('*', cron.dayOfMonth);
+ Assert.areEqual('*', cron.month);
+ Assert.areEqual('?', cron.dayOfWeek);
+ Assert.areEqual('2025', cron.optionalYear);
+ }
+
+ @IsTest
+ static void shouldBuildCronExpressionWithSetters() {
+ CronBuilder cron = new CronBuilder()
+ .second('30')
+ .minute('15')
+ .hour('8')
+ .dayOfMonth('1')
+ .month('6')
+ .dayOfWeek('MON')
+ .optionalYear('2025');
+
+ String expression = cron.getCronExpression();
+ Assert.areEqual('30 15 8 1 6 MON 2025', expression);
+ }
+
+ @IsTest
+ static void shouldBuildCronExpressionWithDefaults() {
+ CronBuilder cron = new CronBuilder();
+ String expression = cron.getCronExpression();
+
+ Assert.areEqual('0 0 * * * ? *', expression);
+ }
+
+ @IsTest
+ static void shouldFailWhenEveryXMinutesIsZeroOrNegative() {
+ try {
+ new CronBuilder().buildForEveryXMinutes(0);
+ Assert.fail('Should throw exception when everyXMinutes is zero.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X minutes must be greater than zero.', ex.getMessage());
+ }
+
+ try {
+ new CronBuilder().buildForEveryXMinutes(-1);
+ Assert.fail('Should throw exception when everyXMinutes is negative.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X minutes must be greater than zero.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenEveryXHoursIsInvalid() {
+ try {
+ new CronBuilder().everyXHours(0, 30);
+ Assert.fail('Should throw exception when everyXHours is zero.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X hours must be greater than zero.', ex.getMessage());
+ }
+
+ try {
+ new CronBuilder().everyXHours(13, 30);
+ Assert.fail('Should throw exception when everyXHours is greater than 12.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X hours must be 12 or less.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenEveryXDaysIsInvalid() {
+ try {
+ new CronBuilder().everyXDays(0, 10, 30);
+ Assert.fail('Should throw exception when everyXDays is zero.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X days must be greater than zero.', ex.getMessage());
+ }
+
+ try {
+ new CronBuilder().everyXDays(16, 10, 30);
+ Assert.fail('Should throw exception when everyXDays is greater than 15.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X days must be 15 or less.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenEveryXMonthsIsInvalid() {
+ try {
+ new CronBuilder().everyXMonths(0, 1, 10, 30);
+ Assert.fail('Should throw exception when everyXMonths is zero.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X months must be greater than zero.', ex.getMessage());
+ }
+
+ try {
+ new CronBuilder().everyXMonths(7, 1, 10, 30);
+ Assert.fail('Should throw exception when everyXMonths is greater than 6.');
+ } catch (Exception ex) {
+ Assert.areEqual('Every X months must be 6 or less.', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldBuildEveryHourCron() {
+ CronBuilder cron = new CronBuilder().everyHour(45);
+
+ Assert.areEqual('45', cron.minute);
+ String expression = cron.getCronExpression();
+ Assert.isTrue(expression.contains('45'));
+ }
+
+ @IsTest
+ static void shouldBuildEveryDayCron() {
+ CronBuilder cron = new CronBuilder().everyDay(14, 30);
+
+ Assert.areEqual('14', cron.hour);
+ Assert.areEqual('30', cron.minute);
+ }
+
+ @IsTest
+ static void shouldBuildEveryMonthCron() {
+ CronBuilder cron = new CronBuilder().everyMonth(15, 9, 0);
+
+ Assert.areEqual('15', cron.dayOfMonth);
+ Assert.areEqual('9', cron.hour);
+ Assert.areEqual('0', cron.minute);
+ }
+
+ @IsTest
+ static void shouldFailWhenQueueableChainFinalizerChainIsNull() {
+ try {
+ new QueueableChainFinalizer(null);
+ Assert.fail('Should throw exception when chain is null.');
+ } catch (Exception ex) {
+ Assert.areEqual('QueueableChain cannot be null', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldFailWhenQueueableChainSchedulableChainIsNull() {
+ try {
+ new QueueableChainSchedulable(null);
+ Assert.fail('Should throw exception when chain is null.');
+ } catch (Exception ex) {
+ Assert.areEqual('QueueableChain cannot be null', ex.getMessage());
+ }
+ }
+
+ @IsTest
+ static void shouldGetCurrentQueueableChainState() {
+ SuccessfulQueueableTest q = new SuccessfulQueueableTest();
+
+ Async.queueable(q).chain();
+ Async.QueueableChainState state = Async.getCurrentQueueableChainState();
+
+ Assert.isNotNull(state);
+ Assert.isNotNull(state.jobs);
+ Assert.areEqual(1, state.jobs.size());
+ }
+
+ @IsTest
+ static void shouldGetQueueableJobContext() {
+ Async.QueueableJobContext ctx = Async.getQueueableJobContext();
+
+ Assert.isNotNull(ctx);
+ Assert.isNull(ctx.currentJob);
+ }
+
+ @IsTest
+ @SuppressWarnings('PMD.ApexUnitTestClassShouldHaveAsserts')
+ static void shouldRemoveSchedulableJobWhenIdIsNull() {
+ QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists(null);
+ }
+
+ @IsTest
+ @SuppressWarnings('PMD.ApexUnitTestClassShouldHaveAsserts,PMD.AvoidHardcodingId')
+ static void shouldHandleRemoveSchedulableJobWithInvalidId() {
+ QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists(
+ '001000000000000AAA'
+ );
+ }
+
+ @IsTest
+ static void shouldChainMultipleJobsWithBuilder() {
+ QueueableJobTest1 job1 = new QueueableJobTest1();
+ QueueableJobTest2 job2 = new QueueableJobTest2();
+
+ Async.queueable(job1).chain(job2).chain();
+
+ Async.QueueableChainState state = Async.getCurrentQueueableChainState();
+ Assert.areEqual(2, state.jobs.size());
+ }
+
+ @IsTest
+ static void shouldConvertQueueableToSchedulable() {
+ SuccessfulQueueableTest q = new SuccessfulQueueableTest();
+
+ Test.startTest();
+ Async.queueable(q)
+ .asSchedulable()
+ .name(TEST_SCHEDULABLE_JOB_NAME)
+ .cronExpression(new CronBuilder().everyDay(10, 30))
+ .schedule();
+ Test.stopTest();
+
+ List triggers = [
+ SELECT Id
+ FROM CronTrigger
+ WHERE CronJobDetail.Name = :TEST_SCHEDULABLE_JOB_NAME
+ ];
+ Assert.areEqual(1, triggers.size());
+ }
+
+ @IsTest
+ static void shouldHandleQueueableChainStateWithNullJobs() {
+ Async.QueueableChainState state = new Async.QueueableChainState();
+
+ state.setNextSalesforceJobIdFromFirstJob();
+ Assert.isNull(state.nextSalesforceJobId);
+
+ state.setNextCustomJobIdFromFirstJob();
+ Assert.isNull(state.nextCustomJobId);
+ }
+
+ @IsTest
+ static void shouldHandleQueueableChainStateWithEmptyJobs() {
+ Async.QueueableChainState state = new Async.QueueableChainState();
+ state.setJobs(new List());
+
+ state.setNextSalesforceJobIdFromFirstJob();
+ Assert.isNull(state.nextSalesforceJobId);
+
+ state.setNextCustomJobIdFromFirstJob();
+ Assert.isNull(state.nextCustomJobId);
+ }
+
+ @IsTest
+ static void shouldSetEnqueueTypeOnChainState() {
+ Async.QueueableChainState state = new Async.QueueableChainState();
+
+ state.setEnqueueType(QueueableManager.EnqueueType.NEW_CHAIN);
+ Assert.areEqual(QueueableManager.EnqueueType.NEW_CHAIN, state.enqueueType);
+
+ state.setEnqueueType(QueueableManager.EnqueueType.EXISTING_CHAIN);
+ Assert.areEqual(QueueableManager.EnqueueType.EXISTING_CHAIN, state.enqueueType);
+ }
+
+ @IsTest
+ static void shouldCompareJobsWithBothFinalizersAndSamePriority() {
+ QueueableJobTest1 job1 = new QueueableJobTest1();
+ job1.parentCustomJobId = 'parent1';
+ job1.priority = 10;
+
+ QueueableJobTest2 job2 = new QueueableJobTest2();
+ job2.parentCustomJobId = 'parent2';
+ job2.priority = 10;
+
+ Integer result = job1.compareTo(job2);
+ Assert.areEqual(0, result);
+ }
+
+ @IsTest
+ static void shouldCompareJobsWithBothNullPriorities() {
+ QueueableJobTest1 job1 = new QueueableJobTest1();
+ job1.priority = null;
+
+ QueueableJobTest2 job2 = new QueueableJobTest2();
+ job2.priority = null;
+
+ Integer result = job1.compareTo(job2);
+ Assert.areEqual(0, result);
+ }
+
+ @IsTest
+ static void shouldCompareJobsWithOnePriorityNull() {
+ QueueableJobTest1 job1 = new QueueableJobTest1();
+ job1.priority = 10;
+
+ QueueableJobTest2 job2 = new QueueableJobTest2();
+ job2.priority = null;
+
+ Integer result = job1.compareTo(job2);
+ Assert.areEqual(-1, result);
+
+ result = job2.compareTo(job1);
+ Assert.areEqual(1, result);
+ }
+
+ @IsTest
+ static void shouldUseSchedulableBuilderWithStringCronExpression() {
+ Test.startTest();
+ Async.schedulable(new SchedulableTest())
+ .name(TEST_SCHEDULABLE_JOB_NAME)
+ .cronExpression('0 0 12 * * ?')
+ .schedule();
+ Test.stopTest();
+
+ List triggers = [
+ SELECT CronExpression
+ FROM CronTrigger
+ WHERE CronJobDetail.Name = :TEST_SCHEDULABLE_JOB_NAME
+ ];
+ Assert.areEqual(1, triggers.size());
+ Assert.areEqual('0 0 12 * * ? *', triggers[0].CronExpression);
+ }
+
+ @IsTest
+ static void shouldBuildEveryXMinutesCrons() {
+ List crons = new CronBuilder().buildForEveryXMinutes(15);
+
+ Assert.areEqual(4, crons.size());
+ Assert.areEqual('0', crons[0].minute);
+ Assert.areEqual('15', crons[1].minute);
+ Assert.areEqual('30', crons[2].minute);
+ Assert.areEqual('45', crons[3].minute);
+ }
+
+ @IsTest
+ static void shouldScheduleWithBoundaryEveryXMinutes() {
+ List crons30 = new CronBuilder().buildForEveryXMinutes(30);
+ Assert.areEqual(2, crons30.size());
+
+ List crons1 = new CronBuilder().buildForEveryXMinutes(1);
+ Assert.areEqual(60, crons1.size());
+ }
+
+ @IsTest
+ static void shouldTestFinalizerWithSuccessViaEnqueue() {
+ AsyncMock.whenFinalizer('error-handler').thenReturn(ParentJobResult.SUCCESS);
+
+ Test.startTest();
+ Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue();
+ Test.stopTest();
+
+ Assert.areEqual(0, [SELECT COUNT() FROM Account]);
+ }
+
+ @IsTest
+ static void shouldTestFinalizerWithExceptionViaEnqueue() {
+ AsyncMock.whenFinalizer('error-handler').thenThrow(new DmlException('Parent job failed'));
+
+ Test.startTest();
+ Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue();
+ Test.stopTest();
+
+ Account errorLog = [SELECT Name, Description FROM Account LIMIT 1];
+ Assert.areEqual('Error Log', errorLog.Name);
+ Assert.areEqual('Parent job failed', errorLog.Description);
+ }
+
+ @IsTest
+ static void shouldTestFinalizerDirectlyWithMockContext() {
+ ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer();
+ finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext()
+ .setResult(ParentJobResult.UNHANDLED_EXCEPTION)
+ .setException(new DmlException('Direct test error'));
+
+ finalizer.work();
+
+ Account errorLog = [SELECT Name, Description FROM Account LIMIT 1];
+ Assert.areEqual('Error Log', errorLog.Name);
+ Assert.areEqual('Direct test error', errorLog.Description);
+ }
+
+ @IsTest
+ static void shouldTestQueueableWithMockContext() {
+ AsyncMock.whenQueueable('account-creator').thenReturn(new AsyncMock.MockQueueableContext());
+
+ Test.startTest();
+ Async.queueable(new AccountCreatorJob('Test Account')).mockId('account-creator').enqueue();
+ Test.stopTest();
+
+ Account acc = [SELECT Name, Description FROM Account LIMIT 1];
+ Assert.areEqual('Test Account', acc.Name);
+ Assert.isNotNull(acc.Description);
+ }
+
+ @IsTest
+ static void shouldTestQueueableDirectlyWithMockContext() {
+ AccountCreatorJob job = new AccountCreatorJob('Direct Test');
+ job.queueableCtx = new AsyncMock.MockQueueableContext();
+
+ job.work();
+
+ Account acc = [SELECT Name, Description FROM Account LIMIT 1];
+ Assert.areEqual('Direct Test', acc.Name);
+ Assert.isNotNull(acc.Description);
+ }
+
+ @IsTest
+ static void shouldTestMultipleFinalizerInvocations() {
+ AsyncMock.whenFinalizer('multi-test')
+ .thenReturn(ParentJobResult.SUCCESS)
+ .thenThrow(new DmlException('Second call failed'))
+ .thenReturn(ParentJobResult.SUCCESS);
+
+ Test.startTest();
+ Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue();
+ Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue();
+ Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue();
+ Test.stopTest();
+
+ Assert.areEqual(1, [SELECT COUNT() FROM Account]);
+ Assert.areEqual(
+ 'Second call failed',
+ [SELECT Description FROM Account LIMIT 1].Description
+ );
+ }
+
+ @IsTest
+ static void shouldTestDefaultFinalizerMock() {
+ AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS);
+
+ Test.startTest();
+ Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue();
+ Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue();
+ Test.stopTest();
+
+ Assert.areEqual(0, [SELECT COUNT() FROM Account]);
+ }
+
+ @IsTest
+ static void shouldResetMocks() {
+ AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS);
+ AsyncMock.whenQueueable('test').thenReturn(new AsyncMock.MockQueueableContext());
+ AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS);
+ AsyncMock.whenQueueableDefault().thenReturn(new AsyncMock.MockQueueableContext());
+
+ AsyncMock.reset();
+
+ Assert.isNull(AsyncMock.getFinalizerContext('test'));
+ Assert.isNull(AsyncMock.getQueueableContext('test'));
+ Assert.isNull(AsyncMock.getFinalizerContext('unknown'));
+ Assert.isNull(AsyncMock.getQueueableContext('unknown'));
+ }
+
+ @IsTest
+ static void shouldReturnNullWhenAllMocksConsumed() {
+ AsyncMock.whenFinalizer('test')
+ .thenReturn(ParentJobResult.SUCCESS)
+ .thenThrow(new DmlException('Error'));
+
+ FinalizerContext ctx1 = AsyncMock.getFinalizerContext('test');
+ FinalizerContext ctx2 = AsyncMock.getFinalizerContext('test');
+ FinalizerContext ctx3 = AsyncMock.getFinalizerContext('test');
+
+ Assert.areEqual(ParentJobResult.SUCCESS, ctx1.getResult());
+ Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx2.getResult());
+ Assert.isNull(ctx3);
+ }
+
+ @IsTest
+ static void shouldUseDefaultMockWhenSpecificMocksConsumed() {
+ AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS);
+ AsyncMock.whenFinalizer('test').thenThrow(new DmlException('Error'));
+
+ FinalizerContext ctx1 = AsyncMock.getFinalizerContext('test');
+ FinalizerContext ctx2 = AsyncMock.getFinalizerContext('test');
+
+ Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult());
+ Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult());
}
private static String getClassNameWithNamespaceDotPrefix(String className) {
@@ -950,9 +1626,16 @@ private class AsyncTest implements Database.Batchable {
}
public void execute(Database.BatchableContext ctx, List scope) {
+ for (Account acc : scope) {
+ acc.Description = 'Processed by: ' + ctx.getJobId();
+ }
+ if (!scope.isEmpty() && scope[0].Id != null) {
+ update scope;
+ }
}
public void finish(Database.BatchableContext bc) {
+ insert new Account(Name = 'Batch Complete', Description = 'Job: ' + bc.getJobId());
}
private class SuccessfulQueueableTest extends QueueableJob {
@@ -986,6 +1669,23 @@ private class AsyncTest implements Database.Batchable {
}
}
+ private class ChainedQueueableJob extends QueueableJob {
+ private Integer chainDepthLimit = 1;
+ private Integer currentChainDepth = 0;
+
+ public ChainedQueueableJob(Integer chainDepthLimit) {
+ this.chainDepthLimit = chainDepthLimit;
+ }
+
+ public override void work() {
+ currentChainDepth++;
+ insert new Account(Name = Async.getQueueableJobContext()?.currentJob?.uniqueName);
+ if (currentChainDepth < chainDepthLimit) {
+ Async.queueable(this).enqueue();
+ }
+ }
+ }
+
public class QueueableJobTest1 extends QueueableJob {
public QueueableJobTest2 complexMember = new QueueableJobTest2();
public override void work() {
@@ -1035,4 +1735,42 @@ private class AsyncTest implements Database.Batchable {
private class CustomException extends Exception {
}
+
+ public class ParentJobWithFinalizer extends QueueableJob {
+ private String mockId;
+
+ public ParentJobWithFinalizer(String mockId) {
+ this.mockId = mockId;
+ }
+
+ public override void work() {
+ Async.queueable(new ErrorHandlerFinalizer()).mockId(mockId).attachFinalizer();
+ }
+ }
+
+ public class ErrorHandlerFinalizer extends QueueableJob.Finalizer {
+ public override void work() {
+ FinalizerContext ctx = this.finalizerCtx;
+ if (ctx?.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
+ insert new Account(
+ Name = 'Error Log',
+ Description = ctx.getException()?.getMessage()
+ );
+ }
+ }
+ }
+
+ public class AccountCreatorJob extends QueueableJob {
+ private String accountName;
+
+ public AccountCreatorJob(String accountName) {
+ this.accountName = accountName;
+ }
+
+ public override void work() {
+ Id jobId = this.queueableCtx?.getJobId();
+ insert new Account(Name = accountName, Description = 'Job: ' + jobId);
+ }
+ }
+
}
diff --git a/force-app/main/default/classes/AsyncTest.cls-meta.xml b/force-app/main/default/classes/AsyncTest.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/AsyncTest.cls-meta.xml
+++ b/force-app/main/default/classes/AsyncTest.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml b/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml
+++ b/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml b/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml
+++ b/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/mocks/AsyncMock.cls b/force-app/main/default/classes/mocks/AsyncMock.cls
new file mode 100644
index 0000000..03a7af8
--- /dev/null
+++ b/force-app/main/default/classes/mocks/AsyncMock.cls
@@ -0,0 +1,157 @@
+@IsTest
+public class AsyncMock {
+ private static Map finalizerSetups = new Map();
+ private static Map queueableSetups = new Map();
+ private static FinalizerMockSetup defaultFinalizerSetup;
+ private static QueueableMockSetup defaultQueueableSetup;
+
+ public static FinalizerMockSetup whenFinalizer(String mockId) {
+ FinalizerMockSetup setup = new FinalizerMockSetup();
+ finalizerSetups.put(mockId, setup);
+ return setup;
+ }
+
+ public static FinalizerMockSetup whenFinalizerDefault() {
+ defaultFinalizerSetup = new FinalizerMockSetup();
+ return defaultFinalizerSetup;
+ }
+
+ public static QueueableMockSetup whenQueueable(String mockId) {
+ QueueableMockSetup setup = new QueueableMockSetup();
+ queueableSetups.put(mockId, setup);
+ return setup;
+ }
+
+ public static QueueableMockSetup whenQueueableDefault() {
+ defaultQueueableSetup = new QueueableMockSetup();
+ return defaultQueueableSetup;
+ }
+
+ public static FinalizerContext getFinalizerContext(String mockId) {
+ FinalizerMockSetup setup = finalizerSetups.get(mockId);
+ FinalizerContext ctx = setup?.getNext();
+ if (ctx == null && defaultFinalizerSetup != null) {
+ ctx = defaultFinalizerSetup.getNext();
+ }
+ return ctx;
+ }
+
+ public static QueueableContext getQueueableContext(String mockId) {
+ QueueableMockSetup setup = queueableSetups.get(mockId);
+ QueueableContext ctx = setup?.getNext();
+ if (ctx == null && defaultQueueableSetup != null) {
+ ctx = defaultQueueableSetup.getNext();
+ }
+ return ctx;
+ }
+
+ public static Boolean hasQueueableMock(String mockId) {
+ return queueableSetups.containsKey(mockId) || defaultQueueableSetup != null;
+ }
+
+ public static Boolean hasFinalizerMock(String mockId) {
+ return finalizerSetups.containsKey(mockId) || defaultFinalizerSetup != null;
+ }
+
+ public static void reset() {
+ finalizerSetups.clear();
+ queueableSetups.clear();
+ defaultFinalizerSetup = null;
+ defaultQueueableSetup = null;
+ }
+
+ public class FinalizerMockSetup {
+ private List contexts = new List();
+
+ public FinalizerMockSetup thenReturn(FinalizerContext ctx) {
+ contexts.add(ctx);
+ return this;
+ }
+
+ public FinalizerMockSetup thenReturn(ParentJobResult result) {
+ return thenReturn(new MockFinalizerContext().setResult(result));
+ }
+
+ public FinalizerMockSetup thenThrow(Exception ex) {
+ return thenReturn(new MockFinalizerContext().setException(ex));
+ }
+
+ public FinalizerContext getNext() {
+ if (contexts.isEmpty()) {
+ return null;
+ }
+ return contexts.remove(0);
+ }
+ }
+
+ public class QueueableMockSetup {
+ private List contexts = new List();
+
+ public QueueableMockSetup thenReturn(QueueableContext ctx) {
+ contexts.add(ctx);
+ return this;
+ }
+
+ public QueueableMockSetup thenReturn(Id jobId) {
+ return thenReturn(new MockQueueableContext().setJobId(jobId));
+ }
+
+ public QueueableContext getNext() {
+ if (contexts.isEmpty()) {
+ return null;
+ }
+ return contexts.remove(0);
+ }
+ }
+
+ public class MockFinalizerContext implements System.FinalizerContext {
+ private ParentJobResult result = ParentJobResult.SUCCESS;
+ private Exception ex;
+ private Id jobId;
+
+ public MockFinalizerContext setResult(ParentJobResult result) {
+ this.result = result;
+ return this;
+ }
+
+ public MockFinalizerContext setException(Exception ex) {
+ this.ex = ex;
+ this.result = ParentJobResult.UNHANDLED_EXCEPTION;
+ return this;
+ }
+
+ public MockFinalizerContext setJobId(Id jobId) {
+ this.jobId = jobId;
+ return this;
+ }
+
+ public ParentJobResult getResult() {
+ return result;
+ }
+
+ public Exception getException() {
+ return ex;
+ }
+
+ public Id getAsyncApexJobId() {
+ return jobId;
+ }
+
+ public String getRequestId() {
+ return 'mock-request-id';
+ }
+ }
+
+ public class MockQueueableContext implements System.QueueableContext {
+ private Id jobId;
+
+ public MockQueueableContext setJobId(Id jobId) {
+ this.jobId = jobId;
+ return this;
+ }
+
+ public Id getJobId() {
+ return jobId;
+ }
+ }
+}
diff --git a/force-app/main/default/classes/queue/QueueableChainBatch.cls-meta.xml b/force-app/main/default/classes/mocks/AsyncMock.cls-meta.xml
similarity index 80%
rename from force-app/main/default/classes/queue/QueueableChainBatch.cls-meta.xml
rename to force-app/main/default/classes/mocks/AsyncMock.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableChainBatch.cls-meta.xml
+++ b/force-app/main/default/classes/mocks/AsyncMock.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls b/force-app/main/default/classes/queue/QueueableBuilder.cls
index dba4d20..4766b93 100644
--- a/force-app/main/default/classes/queue/QueueableBuilder.cls
+++ b/force-app/main/default/classes/queue/QueueableBuilder.cls
@@ -52,6 +52,11 @@ public inherited sharing class QueueableBuilder {
return this;
}
+ public QueueableBuilder mockId(String mockId) {
+ job.mockId = mockId;
+ return this;
+ }
+
public QueueableBuilder chain(QueueableJob job) {
chain();
this.job = job;
diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml b/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml
+++ b/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableChain.cls b/force-app/main/default/classes/queue/QueueableChain.cls
index 8525313..9c8abb5 100644
--- a/force-app/main/default/classes/queue/QueueableChain.cls
+++ b/force-app/main/default/classes/queue/QueueableChain.cls
@@ -7,7 +7,7 @@ public inherited sharing class QueueableChain {
@TestVisible
private List jobs = new List();
@TestVisible
- private Id initialQueuableChainBatchJobId;
+ private Id initialQueuableChainSchedulableId;
private Boolean isChainedContext = false;
private Integer chainCounter = 0;
private QueueableJob currentJob;
@@ -46,6 +46,9 @@ public inherited sharing class QueueableChain {
currentJob.queueableCtx = ctx;
currentJob.salesforceJobId = ctx.getJobId();
currentJob.setChainCounterToName(++chainCounter);
+
+ injectQueueableMockIfNeeded(currentJob);
+
Savepoint sp = currentJob.rollbackOnJobExecuteFail ? Database.setSavepoint() : null;
try {
// Debug statements to determine the Job Id and Name
@@ -115,8 +118,8 @@ public inherited sharing class QueueableChain {
return jobs;
}
- public void executeOrReplaceInitialQueueableChainBatchJob() {
- QueueableChainBatch.removeInitialQueuableChainBatchJobIfExists(initialQueuableChainBatchJobId);
+ public void executeOrReplaceInitialQueueableChainSchedulableJob() {
+ QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists(initialQueuableChainSchedulableId);
Datetime nextRunTime = Datetime.now().addMinutes(1);
String hour = String.valueOf(nextRunTime.hour());
@@ -124,7 +127,7 @@ public inherited sharing class QueueableChain {
String ss = String.valueOf(nextRunTime.second());
String nextFireTime = ss + ' ' + min + ' ' + hour + ' * * ?';
- initialQueuableChainBatchJobId = System.schedule('QueueableChainBatch/' + String.valueOf(Datetime.now()) + '/' + UUID.randomUUID().toString(), nextFireTime, new QueueableChainBatch(this));
+ initialQueuableChainSchedulableId = System.schedule('QueueableChainSchedulable/' + String.valueOf(Datetime.now()) + '/' + UUID.randomUUID().toString(), nextFireTime, new QueueableChainSchedulable(this));
}
public QueueableJob getCurrentJob() {
@@ -135,8 +138,8 @@ public inherited sharing class QueueableChain {
return isChainedContext;
}
- public Id getQueueableChainBatchId() {
- return initialQueuableChainBatchJobId;
+ public Id getQueueableChainSchedulableId() {
+ return initialQueuableChainSchedulableId;
}
@TestVisible
@@ -207,8 +210,22 @@ public inherited sharing class QueueableChain {
private void setFinalizerContextToAllFinalizersForPreviousJob(QueueableJob previousJob, FinalizerContext ctx) {
for (QueueableJob job : jobs) {
if (!job.isProcessed && job.parentCustomJobId == previousJob.customJobId && job.finalizerCtx == null) {
- job.finalizerCtx = ctx;
+ injectFinalizerMockOrDefault(job, ctx);
}
}
}
+
+ private void injectQueueableMockIfNeeded(QueueableJob job) {
+ if (Test.isRunningTest() && AsyncMock.hasQueueableMock(job.mockId)) {
+ job.queueableCtx = AsyncMock.getQueueableContext(job.mockId);
+ }
+ }
+
+ private void injectFinalizerMockOrDefault(QueueableJob job, FinalizerContext defaultCtx) {
+ if (Test.isRunningTest() && AsyncMock.hasFinalizerMock(job.mockId)) {
+ job.finalizerCtx = AsyncMock.getFinalizerContext(job.mockId);
+ return;
+ }
+ job.finalizerCtx = defaultCtx;
+ }
}
diff --git a/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml
+++ b/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableChainBatch.cls b/force-app/main/default/classes/queue/QueueableChainBatch.cls
deleted file mode 100644
index f991355..0000000
--- a/force-app/main/default/classes/queue/QueueableChainBatch.cls
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * PMD False Positives:
- * - EmptyCatchBlock: Catch is just for additional safety when creating initial job, and later removing it
- **/
-@SuppressWarnings('PMD.EmptyCatchBlock')
-public inherited sharing class QueueableChainBatch implements Schedulable, Database.Batchable {
- private QueueableChain chain;
- private String cronTriggerId;
-
- public QueueableChainBatch(QueueableChain chain) {
- if (chain == null) {
- throw new IllegalArgumentException('QueueableChain cannot be null');
- }
- this.chain = chain;
- }
-
- public void execute(SchedulableContext ctx) {
- this.cronTriggerId = ctx.getTriggerId();
- Database.executeBatch(this, 1);
- }
-
- public void execute(Database.BatchableContext bc, List scope) {
- chain.enqueueNextJobIfAny();
- }
-
- public static void removeInitialQueuableChainBatchJobIfExists(Id jobId) {
- if (jobId == null) {
- return;
- }
-
- try {
- System.abortJob(jobId);
- } catch (Exception e) {
- // No action if not exists
- }
- }
-
- public Iterable start(Database.BatchableContext bc) {
- // This is just a placeholder to start the batch.
- return new List{ new Account() };
- }
-
- public void finish(Database.BatchableContext bc) {
- System.abortJob(this.cronTriggerId);
- }
-}
diff --git a/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml
+++ b/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableChainSchedulable.cls b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls
new file mode 100644
index 0000000..f8fed3e
--- /dev/null
+++ b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls
@@ -0,0 +1,32 @@
+/**
+ * PMD False Positives:
+ * - EmptyCatchBlock: Catch is just for additional safety when creating initial job, and later removing it
+ **/
+@SuppressWarnings('PMD.EmptyCatchBlock')
+public inherited sharing class QueueableChainSchedulable implements Schedulable {
+ private QueueableChain chain;
+
+ public QueueableChainSchedulable(QueueableChain chain) {
+ if (chain == null) {
+ throw new IllegalArgumentException('QueueableChain cannot be null');
+ }
+ this.chain = chain;
+ }
+
+ public void execute(SchedulableContext ctx) {
+ chain.enqueueNextJobIfAny();
+ System.abortJob(ctx.getTriggerId());
+ }
+
+ public static void removeInitialQueuableChainSchedulableIfExists(Id jobId) {
+ if (jobId == null) {
+ return;
+ }
+
+ try {
+ System.abortJob(jobId);
+ } catch (Exception e) {
+ // No action if not exists
+ }
+ }
+}
diff --git a/force-app/main/default/classes/queue/QueueableChainSchedulable.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls-meta.xml
new file mode 100644
index 0000000..82775b9
--- /dev/null
+++ b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls-meta.xml
@@ -0,0 +1,5 @@
+
+
+ 65.0
+ Active
+
diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls
index 5ff56c2..8c7a8e4 100644
--- a/force-app/main/default/classes/queue/QueueableJob.cls
+++ b/force-app/main/default/classes/queue/QueueableJob.cls
@@ -1,3 +1,4 @@
+@SuppressWarnings('PMD.ExcessivePublicCount')
public abstract class QueueableJob implements Queueable, Comparable {
public Id salesforceJobId;
public String customJobId;
@@ -20,6 +21,7 @@ public abstract class QueueableJob implements Queueable, Comparable {
public String parentCustomJobId;
public FinalizerContext finalizerCtx;
+ public String mockId;
public Boolean isFinalizer {
get {
return String.isNotBlank(parentCustomJobId);
diff --git a/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml b/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml
+++ b/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableManager.cls b/force-app/main/default/classes/queue/QueueableManager.cls
index 1978208..c5f69bf 100644
--- a/force-app/main/default/classes/queue/QueueableManager.cls
+++ b/force-app/main/default/classes/queue/QueueableManager.cls
@@ -34,8 +34,8 @@ public inherited sharing class QueueableManager {
return ctx;
}
- public Id getQueueableChainBatchId() {
- return chain.getQueueableChainBatchId();
+ public Id getQueueableChainSchedulableId() {
+ return chain.getQueueableChainSchedulableId();
}
public Async.Result attachFinalizer(QueueableJob job) {
@@ -79,11 +79,11 @@ public inherited sharing class QueueableManager {
.setJobs(chain.getJobs())
.setNextCustomJobIdFromFirstJob();
- if (shouldExecuteOrReplaceInitialQueueableChainBatch()) {
- chain.executeOrReplaceInitialQueueableChainBatchJob();
+ if (shouldExecuteOrReplaceInitialQueueableChainSchedulable()) {
+ chain.executeOrReplaceInitialQueueableChainSchedulableJob();
queueableChainState
- .setNextSalesforceJobId(chain.getQueueableChainBatchId())
- .setEnqueueType(EnqueueType.INITIAL_SCHEDULED_BATCH_JOB);
+ .setNextSalesforceJobId(chain.getQueueableChainSchedulableId())
+ .setEnqueueType(EnqueueType.INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE);
} else if (!chain.isChainedContext()) {
chain.enqueueNextJobIfAny();
queueableChainState
@@ -97,7 +97,7 @@ public inherited sharing class QueueableManager {
return new Async.Result(job).setQueueableChainState(queueableChainState);
}
- private Boolean shouldExecuteOrReplaceInitialQueueableChainBatch() {
+ private Boolean shouldExecuteOrReplaceInitialQueueableChainSchedulable() {
return !chain.isChainedContext() &&
(Limits.getQueueableJobs() >= Limits.getLimitQueueableJobs() || System.isQueueable());
}
@@ -110,7 +110,7 @@ public inherited sharing class QueueableManager {
}
public enum EnqueueType {
- INITIAL_SCHEDULED_BATCH_JOB,
+ INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE,
NEW_CHAIN,
EXISTING_CHAIN
}
diff --git a/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml b/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml
+++ b/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml b/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml
+++ b/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml b/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml
+++ b/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml b/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml
index 1e7de94..82775b9 100644
--- a/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml
+++ b/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml
@@ -1,5 +1,5 @@
- 64.0
+ 65.0
Active
diff --git a/sfdx-project.json b/sfdx-project.json
index 9ee47f8..8e14a8d 100644
--- a/sfdx-project.json
+++ b/sfdx-project.json
@@ -1,12 +1,12 @@
{
- "packageDirectories": [
- {
- "path": "force-app",
- "default": true
- }
- ],
- "name": "BeyondTheCloud",
- "namespace": "",
- "sfdcLoginUrl": "https://login.salesforce.com",
- "sourceApiVersion": "64.0"
+ "packageDirectories": [
+ {
+ "path": "force-app",
+ "default": true
+ }
+ ],
+ "name": "BeyondTheCloud",
+ "namespace": "",
+ "sfdcLoginUrl": "https://login.salesforce.com",
+ "sourceApiVersion": "65.0"
}
diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts
index 5288d4a..c2077da 100644
--- a/website/.vitepress/config.mts
+++ b/website/.vitepress/config.mts
@@ -8,7 +8,10 @@ export default defineConfig({
['link', { rel: 'icon', href: '/favicon.ico' }],
[
'script',
- { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-53N22KN47H' }
+ {
+ async: '',
+ src: 'https://www.googletagmanager.com/gtag/js?id=G-53N22KN47H'
+ }
],
[
'script',
@@ -45,7 +48,8 @@ export default defineConfig({
items: [
{ text: 'Queueable', link: '/api/queueable' },
{ text: 'Batchable', link: '/api/batchable' },
- { text: 'Schedulable', link: '/api/schedulable' }
+ { text: 'Schedulable', link: '/api/schedulable' },
+ { text: 'AsyncMock', link: '/api/async-mock' }
]
},
{
@@ -53,10 +57,11 @@ export default defineConfig({
collapsed: false,
items: [
{
- text: 'Initial Scheduled Job',
+ text: 'Initial Queueable Chain Schedulable',
link: '/explanations/initial-scheduled-queuable-batch-job'
},
- { text: 'Job Cloning', link: '/explanations/job-cloning' }
+ { text: 'Job Cloning', link: '/explanations/job-cloning' },
+ { text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' }
]
}
],
diff --git a/website/api/async-mock.md b/website/api/async-mock.md
new file mode 100644
index 0000000..14a5a3d
--- /dev/null
+++ b/website/api/async-mock.md
@@ -0,0 +1,434 @@
+# AsyncMock API
+
+Apex class `AsyncMock.cls`.
+
+**Common Queueable mocking example:**
+
+```apex
+@IsTest
+static void shouldMockQueueableContext() {
+ AsyncMock.whenQueueable('account-creator')
+ .thenReturn(new AsyncMock.MockQueueableContext());
+
+ Test.startTest();
+ Async.queueable(new AccountCreatorJob())
+ .mockId('account-creator')
+ .enqueue();
+ Test.stopTest();
+}
+```
+
+**Common Finalizer mocking example:**
+
+```apex
+@IsTest
+static void shouldMockFinalizerContext() {
+ AsyncMock.whenFinalizer('error-handler')
+ .thenThrow(new DmlException('Parent job failed'));
+
+ Test.startTest();
+ Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue();
+ Test.stopTest();
+}
+```
+
+::: tip
+For finalizer mocking, the `mockId` must be set on the finalizer itself (via `attachFinalizer()` inside `work()`), not on the parent job.
+:::
+
+For testing patterns and best practices, see [Testing Async Jobs](/explanations/testing-async-jobs).
+
+## Methods
+
+The following are methods for using AsyncMock in tests:
+
+[**INIT - Finalizer**](#init---finalizer)
+
+- [`whenFinalizer(String mockId)`](#whenfinalizer)
+- [`whenFinalizerDefault()`](#whenfinalizerdefault)
+
+[**INIT - Queueable**](#init---queueable)
+
+- [`whenQueueable(String mockId)`](#whenqueueable)
+- [`whenQueueableDefault()`](#whenqueueabledefault)
+
+[**Build - FinalizerMockSetup**](#build---finalizermocksetup)
+
+- [`thenReturn(FinalizerContext ctx)`](#thenreturn-finalizercontext)
+- [`thenReturn(ParentJobResult result)`](#thenreturn-parentjobresult)
+- [`thenThrow(Exception ex)`](#thenthrow)
+
+[**Build - QueueableMockSetup**](#build---queueablemocksetup)
+
+- [`thenReturn(QueueableContext ctx)`](#thenreturn-queueablecontext)
+- [`thenReturn(Id jobId)`](#thenreturn-id)
+
+[**Utility**](#utility)
+
+- [`reset()`](#reset)
+- [`hasFinalizerMock(String mockId)`](#hasfinalizermock)
+- [`hasQueueableMock(String mockId)`](#hasqueueablemock)
+- [`getFinalizerContext(String mockId)`](#getfinalizercontext)
+- [`getQueueableContext(String mockId)`](#getqueueablecontext)
+
+[**Mock Context Classes**](#mock-context-classes)
+
+- [`MockFinalizerContext`](#mockfinalizercontext)
+- [`MockQueueableContext`](#mockqueueablecontext)
+
+### INIT - Finalizer
+
+#### whenFinalizer
+
+Sets up a mock for a specific finalizer identified by mockId.
+
+**Signature**
+
+```apex
+static FinalizerMockSetup whenFinalizer(String mockId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizer('error-handler')
+ .thenReturn(ParentJobResult.SUCCESS);
+```
+
+#### whenFinalizerDefault
+
+Sets up a default mock that applies when no specific mockId matches or when a specific mock is exhausted.
+
+**Signature**
+
+```apex
+static FinalizerMockSetup whenFinalizerDefault();
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizerDefault()
+ .thenReturn(ParentJobResult.SUCCESS);
+
+Test.startTest();
+Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue();
+Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue();
+Test.stopTest();
+```
+
+### INIT - Queueable
+
+#### whenQueueable
+
+Sets up a mock for a specific queueable job identified by mockId.
+
+**Signature**
+
+```apex
+static QueueableMockSetup whenQueueable(String mockId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenQueueable('account-creator')
+ .thenReturn(new AsyncMock.MockQueueableContext());
+```
+
+#### whenQueueableDefault
+
+Sets up a default mock that applies when no specific mockId matches or when a specific mock is exhausted.
+
+**Signature**
+
+```apex
+static QueueableMockSetup whenQueueableDefault();
+```
+
+**Example**
+
+```apex
+AsyncMock.whenQueueableDefault()
+ .thenReturn(new AsyncMock.MockQueueableContext());
+```
+
+### Build - FinalizerMockSetup
+
+#### thenReturn (FinalizerContext)
+
+Adds a `FinalizerContext` to the mock queue. Each call to `getFinalizerContext` consumes one context from the queue (FIFO).
+
+**Signature**
+
+```apex
+FinalizerMockSetup thenReturn(FinalizerContext ctx);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizer('multi-test')
+ .thenReturn(new AsyncMock.MockFinalizerContext()
+ .setResult(ParentJobResult.SUCCESS))
+ .thenReturn(new AsyncMock.MockFinalizerContext()
+ .setResult(ParentJobResult.UNHANDLED_EXCEPTION));
+```
+
+#### thenReturn (ParentJobResult)
+
+Convenience method that creates a `MockFinalizerContext` with the specified result.
+
+**Signature**
+
+```apex
+FinalizerMockSetup thenReturn(ParentJobResult result);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizer('my-job')
+ .thenReturn(ParentJobResult.SUCCESS)
+ .thenReturn(ParentJobResult.UNHANDLED_EXCEPTION)
+ .thenReturn(ParentJobResult.SUCCESS);
+```
+
+#### thenThrow
+
+Creates a `MockFinalizerContext` with `UNHANDLED_EXCEPTION` result and the specified exception.
+
+**Signature**
+
+```apex
+FinalizerMockSetup thenThrow(Exception ex);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizer('error-handler')
+ .thenThrow(new DmlException('Parent job failed'));
+
+Test.startTest();
+Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue();
+Test.stopTest();
+
+Account errorLog = [SELECT Name, Description FROM Account LIMIT 1];
+Assert.areEqual('Parent job failed', errorLog.Description);
+```
+
+### Build - QueueableMockSetup
+
+#### thenReturn (QueueableContext)
+
+Adds a `QueueableContext` to the mock queue. Each call to `getQueueableContext` consumes one context from the queue (FIFO).
+
+**Signature**
+
+```apex
+QueueableMockSetup thenReturn(QueueableContext ctx);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenQueueable('my-job')
+ .thenReturn(new AsyncMock.MockQueueableContext().setJobId('707xx0000000001'));
+```
+
+#### thenReturn (Id)
+
+Convenience method that creates a `MockQueueableContext` with the specified job ID.
+
+**Signature**
+
+```apex
+QueueableMockSetup thenReturn(Id jobId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenQueueable('my-job')
+ .thenReturn('707xx0000000001AAA');
+```
+
+### Utility
+
+#### reset
+
+Clears all mock setups (both specific and default mocks).
+
+**Signature**
+
+```apex
+static void reset();
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS);
+AsyncMock.whenQueueable('test').thenReturn(new AsyncMock.MockQueueableContext());
+
+AsyncMock.reset();
+
+Assert.isNull(AsyncMock.getFinalizerContext('test'));
+Assert.isNull(AsyncMock.getQueueableContext('test'));
+```
+
+#### hasFinalizerMock
+
+Checks if a finalizer mock exists for the given mockId or if a default mock is configured.
+
+**Signature**
+
+```apex
+static Boolean hasFinalizerMock(String mockId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizer('my-job').thenReturn(ParentJobResult.SUCCESS);
+
+Assert.isTrue(AsyncMock.hasFinalizerMock('my-job'));
+Assert.isFalse(AsyncMock.hasFinalizerMock('other-job'));
+```
+
+#### hasQueueableMock
+
+Checks if a queueable mock exists for the given mockId or if a default mock is configured.
+
+**Signature**
+
+```apex
+static Boolean hasQueueableMock(String mockId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenQueueable('my-job').thenReturn(new AsyncMock.MockQueueableContext());
+
+Assert.isTrue(AsyncMock.hasQueueableMock('my-job'));
+Assert.isFalse(AsyncMock.hasQueueableMock('other-job'));
+```
+
+#### getFinalizerContext
+
+Retrieves and removes the next `FinalizerContext` from the mock queue. Falls back to default mock if specific mock is exhausted.
+
+**Signature**
+
+```apex
+static FinalizerContext getFinalizerContext(String mockId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS);
+AsyncMock.whenFinalizer('special').thenThrow(new DmlException('Error'));
+
+FinalizerContext ctx1 = AsyncMock.getFinalizerContext('special');
+FinalizerContext ctx2 = AsyncMock.getFinalizerContext('special');
+
+Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult());
+Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); // Falls back to default
+```
+
+#### getQueueableContext
+
+Retrieves and removes the next `QueueableContext` from the mock queue. Falls back to default mock if specific mock is exhausted.
+
+**Signature**
+
+```apex
+static QueueableContext getQueueableContext(String mockId);
+```
+
+**Example**
+
+```apex
+AsyncMock.whenQueueableDefault().thenReturn(new AsyncMock.MockQueueableContext());
+AsyncMock.whenQueueable('special').thenReturn(new AsyncMock.MockQueueableContext());
+
+QueueableContext ctx1 = AsyncMock.getQueueableContext('special');
+QueueableContext ctx2 = AsyncMock.getQueueableContext('special');
+
+Assert.isNotNull(ctx1);
+Assert.isNotNull(ctx2); // Falls back to default
+```
+
+### Mock Context Classes
+
+#### MockFinalizerContext
+
+Implements `System.FinalizerContext` for test scenarios.
+
+**Signature**
+
+```apex
+public class MockFinalizerContext implements System.FinalizerContext
+```
+
+**Build Methods**
+
+| Method | Description |
+|--------|-------------|
+| `setResult(ParentJobResult result)` | Sets the parent job result |
+| `setException(Exception ex)` | Sets exception and auto-sets result to `UNHANDLED_EXCEPTION` |
+| `setJobId(Id jobId)` | Sets the async apex job ID |
+
+**Interface Methods**
+
+| Method | Description |
+|--------|-------------|
+| `getResult()` | Returns the configured `ParentJobResult` |
+| `getException()` | Returns the configured exception |
+| `getAsyncApexJobId()` | Returns the configured job ID |
+| `getRequestId()` | Returns `'mock-request-id'` |
+
+**Example**
+
+```apex
+ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer();
+finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext()
+ .setResult(ParentJobResult.UNHANDLED_EXCEPTION)
+ .setException(new DmlException('Direct test error'));
+
+finalizer.work();
+```
+
+#### MockQueueableContext
+
+Implements `System.QueueableContext` for test scenarios.
+
+**Signature**
+
+```apex
+public class MockQueueableContext implements System.QueueableContext
+```
+
+**Build Methods**
+
+| Method | Description |
+|--------|-------------|
+| `setJobId(Id jobId)` | Sets the job ID |
+
+**Interface Methods**
+
+| Method | Description |
+|--------|-------------|
+| `getJobId()` | Returns the configured job ID |
+
+**Example**
+
+```apex
+AccountCreatorJob job = new AccountCreatorJob('Direct Test');
+job.queueableCtx = new AsyncMock.MockQueueableContext();
+
+job.work();
+```
diff --git a/website/api/batchable.md b/website/api/batchable.md
index ab50887..9e0051e 100644
--- a/website/api/batchable.md
+++ b/website/api/batchable.md
@@ -2,7 +2,7 @@
Apex classes `BatchableBuilder.cls` and `BatchableManager.cls`.
-Common Batchable example:
+**Common Batchable example:**
```apex
Database.Batchable