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