using Baya.Application.Contracts.Payments; using Baya.Infrastructure.Persistence.Services.Scheduling; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; namespace Baya.Test.Foundation.Scheduling; /// /// Orchestration tests for RecurringJobSchedulerHostedService (refinement-phase-7): it runs each job at /// startup under the distributed lock, keeps sibling loops alive when one job throws, and stays dormant under the /// Testing environment so integration tests are deterministic. /// public sealed class RecurringJobSchedulerTests { private sealed class FakeEnvironment(string environmentName) : IHostEnvironment { public string EnvironmentName { get; set; } = environmentName; public string ApplicationName { get; set; } = "Baya.Test"; public string ContentRootPath { get; set; } = "."; public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; } private sealed class CountingJob(string name, bool throws = false) : IRecurringJob { private int _runCount; public int RunCount => Volatile.Read(ref _runCount); public string Name => name; // Long interval: run once at startup, then idle for the whole test window. public ValueTask GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken) => ValueTask.FromResult(TimeSpan.FromHours(1)); public ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken) { Interlocked.Increment(ref _runCount); if (throws) throw new InvalidOperationException("boom"); return ValueTask.CompletedTask; } } private static (IServiceScopeFactory scopeFactory, IDistributedLock @lock) Fakes(out IDistributedLock recordingLock) { var scopeFactory = new ServiceCollection().BuildServiceProvider().GetRequiredService(); recordingLock = Substitute.For(); recordingLock.AcquireAsync(Arg.Any(), Arg.Any()) .Returns(new ValueTask(Substitute.For())); return (scopeFactory, recordingLock); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(20); } } [Fact] public async Task RunsJobAtStartup_UnderPerJobLock() { var (scopeFactory, @lock) = Fakes(out var recordingLock); var job = new CountingJob("fake_job"); var scheduler = new RecurringJobSchedulerHostedService( scopeFactory, [job], @lock, new FakeEnvironment("Development"), NullLogger.Instance); await scheduler.StartAsync(default); await WaitUntilAsync(() => job.RunCount >= 1, TimeSpan.FromSeconds(3)); await scheduler.StopAsync(default); Assert.True(job.RunCount >= 1); await recordingLock.Received().AcquireAsync("scheduler:fake_job", Arg.Any()); } [Fact] public async Task OneJobThrowing_DoesNotStopSiblingJobs() { var (scopeFactory, @lock) = Fakes(out _); var throwing = new CountingJob("throwing", throws: true); var healthy = new CountingJob("healthy"); var scheduler = new RecurringJobSchedulerHostedService( scopeFactory, [throwing, healthy], @lock, new FakeEnvironment("Development"), NullLogger.Instance); await scheduler.StartAsync(default); await WaitUntilAsync(() => healthy.RunCount >= 1, TimeSpan.FromSeconds(3)); await scheduler.StopAsync(default); Assert.True(throwing.RunCount >= 1); Assert.True(healthy.RunCount >= 1); } [Fact] public async Task TestingEnvironment_KeepsSchedulerDormant() { var (scopeFactory, @lock) = Fakes(out _); var job = new CountingJob("fake_job"); var scheduler = new RecurringJobSchedulerHostedService( scopeFactory, [job], @lock, new FakeEnvironment("Testing"), NullLogger.Instance); await scheduler.StartAsync(default); await Task.Delay(200); await scheduler.StopAsync(default); Assert.Equal(0, job.RunCount); } }