using Elsa.Common; using Elsa.Mediator.Contracts; using Elsa.Scheduling.ScheduledTasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; namespace Elsa.Scheduling.UnitTests.ScheduledTasks; /// /// Tests for ScheduledCronTask to ensure cron triggers continue to fire even in edge cases. /// public class ScheduledCronTaskTests : IDisposable { private const string DefaultCronExpression = "0 */5 * * * *"; private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero); private readonly ServiceProvider _serviceProvider; private readonly ISystemClock _systemClock; private readonly ICronParser _cronParser; private readonly ILogger _logger; private readonly List _tasksToDispose = new(); public ScheduledCronTaskTests() { var services = new ServiceCollection(); _systemClock = Substitute.For(); _cronParser = Substitute.For(); _logger = Substitute.For>(); services.AddSingleton(Substitute.For()); _serviceProvider = services.BuildServiceProvider(); } private ScheduledCronTask CreateScheduledTask( string? cronExpression = null, ICronParser? cronParser = null, ISystemClock? systemClock = null) { var task = Substitute.For(); var scheduledTask = new ScheduledCronTask( task, cronExpression ?? DefaultCronExpression, cronParser ?? _cronParser, _serviceProvider.CreateScope().ServiceProvider.GetRequiredService(), systemClock ?? _systemClock, _logger ); _tasksToDispose.Add(scheduledTask); return scheduledTask; } private void SetupCronParser(params DateTimeOffset[] occurrences) { _cronParser.GetNextOccurrence(Arg.Any()).Returns(occurrences[0], occurrences.Skip(1).ToArray()); } private void SetupSystemClock(params DateTimeOffset[] times) { _systemClock.UtcNow.Returns(times[0], times.Skip(1).ToArray()); } private void AssertNoErrorLogged() { _logger.DidNotReceive().Log( LogLevel.Error, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } private void AssertWarningLogged(int expectedCount = 1) { _logger.Received(expectedCount).Log( LogLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } [Fact] public void Schedule_WithVerySmallDelay_ShouldStillSetupTimer() { // Arrange - simulate a case where the delay is very small (1 tick = 100ns) SetupSystemClock(DefaultNow); SetupCronParser(DefaultNow.AddTicks(1)); // Only 1 tick in the future (100 nanoseconds) // Act CreateScheduledTask(); // Assert - Verify that no error was logged (timer should be set up successfully) AssertNoErrorLogged(); } [Fact] public void Schedule_WithZeroDelay_ShouldRetryAndSetupTimer() { // Arrange - simulate a case where the first call returns exactly now // but the second call returns a proper future time SetupSystemClock(DefaultNow, DefaultNow); SetupCronParser(DefaultNow, DefaultNow.AddMinutes(5)); // First: delay=0, Second: proper future time // Act var task = CreateScheduledTask(); // Dispose immediately to prevent timer from firing ((IDisposable)task).Dispose(); // Assert - Should call GetNextOccurrence at least twice (initial + retry) // May be called more if timer fires before disposal in rare race conditions _cronParser.Received().GetNextOccurrence(DefaultCronExpression); var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence)); Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}"); } [Fact] public void Schedule_WithNegativeDelay_ShouldRetryAndSetupTimer() { // Arrange - simulate a case where the first call returns a time in the past SetupSystemClock(DefaultNow, DefaultNow); SetupCronParser(DefaultNow.AddMinutes(-1), DefaultNow.AddMinutes(5)); // First: past, Second: future // Act var task = CreateScheduledTask(); // Dispose immediately to prevent timer from firing ((IDisposable)task).Dispose(); Thread.Sleep(5); // Brief wait to ensure disposal completes // Assert - Should call GetNextOccurrence at least twice _cronParser.Received().GetNextOccurrence(DefaultCronExpression); var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence)); Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}"); } [Fact] public void Schedule_WithPersistentZeroDelay_ShouldLogWarningAndUseMinimumDelay() { // Arrange - simulate the bug scenario: both attempts return zero/negative delay // This can happen if the system clock doesn't advance or if there's clock drift // After the initial attempts, return a proper future time to prevent timer from firing during test SetupSystemClock(DefaultNow); SetupCronParser(DefaultNow, DefaultNow, DefaultNow.AddMinutes(5)); // First two return now, third returns future // Act - This should not crash and should set up a timer with minimum delay var task = CreateScheduledTask(); // Dispose immediately to prevent timer from firing and recursing _tasksToDispose.Remove(task); ((IDisposable)task).Dispose(); // Assert - Should call GetNextOccurrence at least twice (initial + retry) // May be called more if timer fires before disposal and triggers Schedule() again _cronParser.Received().GetNextOccurrence(DefaultCronExpression); var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence)); Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}"); AssertWarningLogged(); } [Fact] public void Schedule_WithNegativeDelayAfterRetry_ShouldLogWarningAndUseMinimumDelay() { // Arrange - simulate a case where even after retry, delay is negative // This could happen due to system clock adjustments // After the initial attempts, return a proper future time to prevent timer from firing during test SetupSystemClock(DefaultNow, DefaultNow); SetupCronParser(DefaultNow.AddMilliseconds(-100), DefaultNow.AddMilliseconds(-50), DefaultNow.AddMinutes(5)); // Act - Should handle negative delay gracefully var task = CreateScheduledTask(); // Dispose immediately to prevent timer from firing _tasksToDispose.Remove(task); ((IDisposable)task).Dispose(); // Assert - Should log a warning and still set up timer AssertWarningLogged(); } [Fact] public void ReproduceOriginalIssue_WithRealCronParser_DemonstratesBugScenario() { // This test reproduces the exact scenario from the original issue report // Using the real CronosCronParser with specific times that trigger the bug // Arrange - Use the exact times from the issue that cause delay.Milliseconds to be 0 var now = new DateTimeOffset(2025, 11, 06, 22, 50, 00, 0, TimeZoneInfo.Utc.GetUtcOffset(DateTimeOffset.UtcNow)); var systemClock = Substitute.For(); systemClock.UtcNow.Returns(now); var realCronParser = new Elsa.Scheduling.Services.CronosCronParser(systemClock); // Act - Create scheduled task with real cron parser // Before the fix: This would silently fail to schedule if delay <= 0 // After the fix: This should log warning and use minimum delay CreateScheduledTask(cronParser: realCronParser, systemClock: systemClock); // Assert - The key is that the task was created successfully without throwing or silently failing // This test passes with the fix, demonstrating the issue is resolved } public void Dispose() { // Dispose tasks first to stop timers before disposing ServiceProvider foreach (var task in _tasksToDispose) { ((IDisposable)task).Dispose(); } _serviceProvider.Dispose(); } }