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);