using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.Configuration; using Marathon.Application.UseCases; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Marathon.Infrastructure.Configuration; using Marathon.Infrastructure.Workers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; namespace Marathon.Infrastructure.Tests.Workers; /// /// Tests for . /// Uses real backed by NSubstitute interfaces, /// so the poller's DI scope resolution is exercised end-to-end. /// public sealed class LiveOddsPollerTests { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static OddsSnapshot MakeSnapshot(EventId eventId) => new(eventId, DateTimeOffset.UtcNow, OddsSource.Live, new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.80m)), }); /// /// Builds a DI ServiceProvider wiring a real /// against the provided interface substitutes. /// private static IServiceProvider BuildServiceProvider( IOddsScraper scraper, IEventRepository eventRepo, ISnapshotRepository snapshotRepo) { var services = new ServiceCollection(); services.AddScoped(_ => scraper); services.AddScoped(_ => eventRepo); services.AddScoped(_ => snapshotRepo); services.AddScoped(sp => new PullLiveOddsUseCase( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), StaticThrottle(), NullLogger.Instance)); return services.BuildServiceProvider(); } private static IOptionsMonitor StaticThrottle() { var monitor = Substitute.For>(); monitor.CurrentValue.Returns(new ScrapingThrottle { MaxConcurrentRequests = 1 }); return monitor; } private static IOptionsMonitor BuildOptions( bool enabled = true, int intervalSeconds = 0) { var opts = new WorkerOptions { LivePollerEnabled = enabled, LivePollIntervalSeconds = intervalSeconds, }; var monitor = Substitute.For>(); monitor.CurrentValue.Returns(opts); return monitor; } [Fact] public async Task Should_InvokeUseCase_When_PollerIsEnabled() { // Arrange — scraper returns 1 event; snapshot succeeds var eventId = new EventId("10000001"); var ev = new Event(eventId, new SportCode(6), "BY", "league-1", "Group", new DateTimeOffset(2026, 5, 10, 18, 0, 0, MoscowOffset), "A", "B"); var scraper = Substitute.For(); var eventRepo = Substitute.For(); var snapshotRepo = Substitute.For(); // Use case discovers live events via ScrapeLiveAsync (NOT ListAsync) scraper.ScrapeLiveAsync(Arg.Any()) .Returns(new List { ev }.AsReadOnly()); eventRepo.GetAsync(eventId, Arg.Any()) .Returns(ev); scraper.ScrapeEventOddsAsync( Arg.Is(e => e.Id == eventId), OddsSource.Live, Arg.Any()) .Returns(MakeSnapshot(eventId)); var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); var opts = BuildOptions(enabled: true, intervalSeconds: 0); var poller = new LiveOddsPoller(sp, opts, NullLogger.Instance); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); // Act await poller.StartAsync(cts.Token); await Task.Delay(300); // allow at least one cycle await poller.StopAsync(CancellationToken.None); // Assert — snapshot was added at least once await snapshotRepo.Received().AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Should_NotInvokeUseCase_When_PollerIsDisabled() { // Arrange var scraper = Substitute.For(); var eventRepo = Substitute.For(); var snapshotRepo = Substitute.For(); var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); var opts = BuildOptions(enabled: false, intervalSeconds: 0); var poller = new LiveOddsPoller(sp, opts, NullLogger.Instance); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); // Act await poller.StartAsync(cts.Token); await Task.Delay(300); await poller.StopAsync(CancellationToken.None); // Assert — no snapshot attempts while disabled await snapshotRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); await scraper.DidNotReceive().ScrapeLiveAsync(Arg.Any()); } [Fact] public async Task Should_ContinueRunning_When_UseCaseThrowsException() { // Arrange — ListAsync always throws; poller must survive var scraper = Substitute.For(); var eventRepo = Substitute.For(); var snapshotRepo = Substitute.For(); // Use case calls ScrapeLiveAsync first; if it throws, the cycle returns 0 // without hitting the repo. To exercise the "poller survives failures" // path, make ScrapeLiveAsync itself throw. scraper.ScrapeLiveAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException("listing unavailable")); var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); var opts = BuildOptions(enabled: true, intervalSeconds: 0); var poller = new LiveOddsPoller(sp, opts, NullLogger.Instance); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); // Act — start, let failures occur, stop cleanly await poller.StartAsync(cts.Token); await Task.Delay(400); var stopAct = async () => await poller.StopAsync(CancellationToken.None); // Assert — StopAsync must not propagate the exception await stopAct.Should().NotThrowAsync(); // Scraper was hit at least once (poller cycled at least one iteration). // The use case swallows ScrapeLiveAsync exceptions and returns 0, so the // poller's catch block is not triggered — but it still cycles. await scraper.Received().ScrapeLiveAsync(Arg.Any()); } }