using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using w4c_workflows.Services.Triggers; using Xunit; namespace w4c_workflows.Tests; /// /// P0-2: the scheduler's try/catch must live inside the poll loop. /// If it wrapped the loop, the first throwing tick (a transient DB error) would /// end ExecuteAsync — and with BackgroundServiceExceptionBehavior.Ignore /// the host would swallow it, silently stopping every cron/interval trigger for /// the lifetime of the process. /// public class TriggerSchedulerTests { private sealed class ThrowingScopeFactory : IServiceScopeFactory { public IServiceScope CreateScope() => throw new InvalidOperationException("transient scope failure"); } private sealed class NullTriggerState : ITriggerState { public Task GetLastFireAsync(string tenantId, Guid workflowId, CancellationToken ct) => Task.FromResult(null); public Task SetLastFireAsync(string tenantId, Guid workflowId, DateTimeOffset at, CancellationToken ct) => Task.CompletedTask; } private static TriggerScheduler Scheduler(int pollSeconds, int warmupSeconds) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Workflows:SchedulerPollSeconds"] = pollSeconds.ToString(), ["Workflows:SchedulerWarmupSeconds"] = warmupSeconds.ToString(), }) .Build(); return new TriggerScheduler( new ThrowingScopeFactory(), new NullTriggerState(), TimeProvider.System, config, NullLogger.Instance); } [Fact] public async Task A_throwing_tick_does_not_end_the_scheduler_loop() { var scheduler = Scheduler(pollSeconds: 1, warmupSeconds: 0); using var cts = new CancellationTokenSource(); await scheduler.StartAsync(cts.Token); // Give the loop time to run (and survive) at least one throwing tick. await Task.Delay(TimeSpan.FromMilliseconds(1500)); var stillRunning = scheduler.ExecuteTask is { IsCompleted: false }; cts.Cancel(); await scheduler.StopAsync(CancellationToken.None); Assert.True(stillRunning, "the scheduler loop must keep running after a throwing tick"); } }