diff --git a/src/RunWay.Tests/Unit/FluentJobBuilderTests.cs b/src/RunWay.Tests/Unit/FluentJobBuilderTests.cs
new file mode 100644
index 0000000..d3ddb6c
--- /dev/null
+++ b/src/RunWay.Tests/Unit/FluentJobBuilderTests.cs
@@ -0,0 +1,109 @@
+using Kododo.RunWay.Core.Jobs;
+using Kododo.RunWay.Core.Recurrences;
+using Kododo.RunWay.Core.Store;
+using Kododo.RunWay.Schedule;
+using NSubstitute;
+using Xunit;
+
+namespace Kododo.RunWay.Tests.Unit;
+
+///
+/// The chains from the README, written the same way, through the public :
+/// if a setting stops returning the builder that has ScheduleAsync / SetRecurrenceAsync, this file no longer compiles.
+///
+public class FluentJobBuilderTests
+{
+ private sealed class SendEmailJob
+ {
+ public required string To { get; set; }
+ public required string Subject { get; set; }
+ }
+
+ private readonly IStore _store = Substitute.For();
+ private readonly IRecurrenceCalculator _calculator = Substitute.For();
+ private readonly IScheduler _scheduler;
+
+ public FluentJobBuilderTests()
+ {
+ _store.CreateAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!);
+ _store.CreateWithoutTransactionAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!);
+ _store.FindRecurrenceAsync(Arg.Any(), Arg.Any()).Returns((Recurrence?)null);
+ _store.CreateRecurrenceWithoutTransactionAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!);
+ _store.UpdateRecurrenceWithoutTransactionAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!);
+ _calculator.CalculateNextOccurrence(Arg.Any(), Arg.Any()).Returns(DateTimeOffset.UtcNow);
+ _scheduler = new Scheduler(_store, _calculator, new RunWayOptions());
+ }
+
+ [Fact]
+ public async Task Readme_SchedulingOptions_ChainEndsWithScheduleAsync()
+ {
+ // README, "Scheduling options"
+ await _scheduler
+ .Job(new SendEmailJob { To = "user@example.com", Subject = "Hello" })
+ .WithPriority(10)
+ .WithRetryDelaysInSeconds(10, 60, 300)
+ .WithTimeout(TimeSpan.FromMinutes(5))
+ .ScheduleAsync(CancellationToken.None);
+
+ await _store.Received(1).CreateAsync(
+ Arg.Is(j => j != null && j.Options.Priority == 10
+ && j.Options.RetryDelaysInSeconds.SequenceEqual(new[] { 10, 60, 300 })
+ && j.Options.Timeout == TimeSpan.FromMinutes(5)),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Readme_Outbox_AsTransactionalFalse_UsesAmbientTransaction()
+ {
+ // README, "Outbox pattern"
+ await _scheduler
+ .Job(new SendEmailJob { To = "user@example.com", Subject = "Order confirmed" })
+ .AsTransactional(false)
+ .ScheduleAsync(CancellationToken.None);
+
+ await _store.Received(1).CreateWithoutTransactionAsync(Arg.Any(), Arg.Any());
+ await _store.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task ChainWithScheduledTime_EndsWithScheduleAsync()
+ {
+ var at = new DateTimeOffset(2030, 1, 1, 8, 0, 0, TimeSpan.Zero);
+
+ await _scheduler.Job(new SendEmailJob { To = "a@b.c", Subject = "Later" })
+ .AsTransactional(false)
+ .WithPriority(3)
+ .ScheduleAsync(at, CancellationToken.None);
+
+ await _store.Received(1).CreateWithoutTransactionAsync(
+ Arg.Is(j => j != null && j.ScheduledAt == at && j.Options.Priority == 3), Arg.Any());
+ }
+
+ [Fact]
+ public async Task RecurrenceChain_EndsWithSetRecurrenceAsync()
+ {
+ await _scheduler.Recurrence(new SendEmailJob { To = "a@b.c", Subject = "Report" })
+ .WithTimeout(TimeSpan.FromMinutes(30))
+ .WithRetryDelaysInSeconds(60, 300)
+ .SetRecurrenceAsync("nightly-report", "0 2 * * *", CancellationToken.None);
+
+ await _store.Received(1).CreateRecurrenceWithoutTransactionAsync(
+ Arg.Is(r => r != null && r.Options.Timeout == TimeSpan.FromMinutes(30)
+ && r.Options.RetryDelaysInSeconds.SequenceEqual(new[] { 60, 300 })),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task SettingsThroughTheBaseInterface_StillApply()
+ {
+ // Code written against IJobBuilder (e.g. the SetRecurrenceAsync configure callback) keeps working.
+ var builder = _scheduler.Job(new SendEmailJob { To = "a@b.c", Subject = "Base" });
+ IJobBuilder configure = builder;
+ configure.WithPriority(7).WithTimeout(TimeSpan.FromSeconds(30));
+
+ await builder.ScheduleAsync(CancellationToken.None);
+
+ await _store.Received(1).CreateAsync(
+ Arg.Is(j => j != null && j.Options.Priority == 7 && j.Options.Timeout == TimeSpan.FromSeconds(30)), Arg.Any());
+ }
+}
diff --git a/src/RunWay/Schedule/RecurringJobBuilder.cs b/src/RunWay/Schedule/RecurringJobBuilder.cs
index ca2e74a..01a09d1 100644
--- a/src/RunWay/Schedule/RecurringJobBuilder.cs
+++ b/src/RunWay/Schedule/RecurringJobBuilder.cs
@@ -5,6 +5,12 @@ namespace Kododo.RunWay.Schedule;
public interface IRecurringJobBuilder : IJobBuilder
{
+ // The settings return the recurring builder, so a chain can end with SetRecurrenceAsync.
+ new IRecurringJobBuilder AsTransactional(bool transactional);
+ new IRecurringJobBuilder WithPriority(int priority);
+ new IRecurringJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds);
+ new IRecurringJobBuilder WithTimeout(TimeSpan timeout);
+
Task SetRecurrenceAsync(string key, string cronExpression, CancellationToken stoppingToken);
}
@@ -23,6 +29,18 @@ public RecurringJobBuilder(T data, IStore store, IRecurrenceCalculator recurrenc
_options = options;
}
+ public new IRecurringJobBuilder AsTransactional(bool transactional) { base.AsTransactional(transactional); return this; }
+
+ public new IRecurringJobBuilder WithPriority(int priority) { base.WithPriority(priority); return this; }
+
+ public new IRecurringJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds)
+ {
+ base.WithRetryDelaysInSeconds(retryDelaysInSeconds);
+ return this;
+ }
+
+ public new IRecurringJobBuilder WithTimeout(TimeSpan timeout) { base.WithTimeout(timeout); return this; }
+
public async Task SetRecurrenceAsync(string key, string cronExpression, CancellationToken stoppingToken)
{
var recurrenceId = new RecurrenceId(key);
diff --git a/src/RunWay/Schedule/ScheduleJobBuilder.cs b/src/RunWay/Schedule/ScheduleJobBuilder.cs
index 508857d..fce3869 100644
--- a/src/RunWay/Schedule/ScheduleJobBuilder.cs
+++ b/src/RunWay/Schedule/ScheduleJobBuilder.cs
@@ -5,6 +5,12 @@ namespace Kododo.RunWay.Schedule;
public interface IScheduleJobBuilder : IJobBuilder
{
+ // The settings return the schedule builder, so a chain can end with ScheduleAsync.
+ new IScheduleJobBuilder AsTransactional(bool transactional);
+ new IScheduleJobBuilder WithPriority(int priority);
+ new IScheduleJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds);
+ new IScheduleJobBuilder WithTimeout(TimeSpan timeout);
+
Task ScheduleAsync(DateTimeOffset scheduledAt, CancellationToken stoppingToken);
Task ScheduleAsync(CancellationToken stoppingToken);
}
@@ -22,6 +28,18 @@ public ScheduleJobBuilder(T data, IStore store, RunWayOptions options) : base(op
_options = options;
}
+ public new IScheduleJobBuilder AsTransactional(bool transactional) { base.AsTransactional(transactional); return this; }
+
+ public new IScheduleJobBuilder WithPriority(int priority) { base.WithPriority(priority); return this; }
+
+ public new IScheduleJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds)
+ {
+ base.WithRetryDelaysInSeconds(retryDelaysInSeconds);
+ return this;
+ }
+
+ public new IScheduleJobBuilder WithTimeout(TimeSpan timeout) { base.WithTimeout(timeout); return this; }
+
public async Task ScheduleAsync(DateTimeOffset scheduledAt, CancellationToken stoppingToken)
{
var jobData = _options.JobDataSerializer.Serialize(_data);