using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.UseCases; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; namespace Marathon.Application.Tests.UseCases; public sealed class PullLiveOddsUseCaseTests { private readonly IOddsScraper _scraper = Substitute.For(); private readonly IEventRepository _eventRepo = Substitute.For(); private readonly ISnapshotRepository _snapshotRepo = Substitute.For(); private PullLiveOddsUseCase CreateSut() => new(_scraper, _eventRepo, _snapshotRepo, NullLogger.Instance); [Fact] public async Task Should_CaptureOneSnapshotPerEvent_When_TwoLiveEventsExistInDatabase() { // Arrange: 2 events in the database var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var storedEvents = new List { ev1, ev2 }.AsReadOnly(); _eventRepo.ListAsync(Arg.Any()).Returns(storedEvents); // ScrapeUpcomingAsync is also called (by implementation) — return empty to keep test focused _scraper.ScrapeUpcomingAsync(null, Arg.Any()) .Returns(Array.Empty()); _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev1.Id, OddsSource.Live)); _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev2.Id, OddsSource.Live)); var sut = CreateSut(); // Act var snapshotsCaptured = await sut.ExecuteAsync(CancellationToken.None); // Assert snapshotsCaptured.Should().Be(2); await _scraper.Received(1).ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()); await _scraper.Received(1).ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()); await _snapshotRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Should_ContinueAfterSnapshotFailure_And_NotPropagateException() { // Arrange: 2 events — scraping the first throws var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var storedEvents = new List { ev1, ev2 }.AsReadOnly(); _eventRepo.ListAsync(Arg.Any()).Returns(storedEvents); _scraper.ScrapeUpcomingAsync(null, Arg.Any()) .Returns(Array.Empty()); _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()) .ThrowsAsync(new HttpRequestException("timeout")); _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev2.Id, OddsSource.Live)); var sut = CreateSut(); // Act — must not throw var act = async () => await sut.ExecuteAsync(CancellationToken.None); // Assert await act.Should().NotThrowAsync(); var result = await sut.ExecuteAsync(CancellationToken.None); result.Should().Be(1, "only ev2 succeeded; ev1 failed silently"); } [Fact] public async Task Should_ReturnZero_When_NoEventsInDatabase() { _eventRepo.ListAsync(Arg.Any()) .Returns(Array.Empty()); _scraper.ScrapeUpcomingAsync(null, Arg.Any()) .Returns(Array.Empty()); var sut = CreateSut(); var result = await sut.ExecuteAsync(CancellationToken.None); result.Should().Be(0); await _scraper.DidNotReceive() .ScrapeEventOddsAsync(Arg.Any(), Arg.Any(), Arg.Any()); } }