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, TestFixtures.Throttle(), NullLogger.Instance); [Fact] public async Task Should_CaptureOneSnapshotPerEvent_When_LiveListingReturnsTwoEvents() { // Arrange: 2 events from the live listing; both already known to the DB var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var live = new List { ev1, ev2 }.AsReadOnly(); _scraper.ScrapeLiveAsync(Arg.Any()).Returns(live); _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); _scraper.ScrapeEventOddsAsync( Arg.Is(e => e.Id == ev1.Id), OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev1.Id, OddsSource.Live)); _scraper.ScrapeEventOddsAsync( Arg.Is(e => e.Id == 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 _eventRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); await _snapshotRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Should_PersistNewLiveEvent_When_NotYetInDatabase() { // Arrange: live listing returns one event the DB has never seen var live = TestFixtures.MakeEvent("99999999"); _scraper.ScrapeLiveAsync(Arg.Any()) .Returns(new List { live }.AsReadOnly()); _eventRepo.GetAsync(live.Id, Arg.Any()).Returns((Event?)null); _scraper.ScrapeEventOddsAsync( Arg.Is(e => e.Id == live.Id), OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(live.Id, OddsSource.Live)); var sut = CreateSut(); // Act var snapshotsCaptured = await sut.ExecuteAsync(CancellationToken.None); // Assert snapshotsCaptured.Should().Be(1); await _eventRepo.Received(1).AddAsync(live, Arg.Any()); } [Fact] public async Task Should_ContinueAfterSnapshotFailure_And_NotPropagateException() { // Arrange: 2 live events — scraping the first throws var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); _scraper.ScrapeLiveAsync(Arg.Any()) .Returns(new List { ev1, ev2 }.AsReadOnly()); _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); _scraper.ScrapeEventOddsAsync( Arg.Is(e => e.Id == ev1.Id), OddsSource.Live, Arg.Any()) .ThrowsAsync(new HttpRequestException("timeout")); _scraper.ScrapeEventOddsAsync( Arg.Is(e => e.Id == 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); await act.Should().NotThrowAsync(); // Re-execute to assert the count (mocks are still primed) var result = await sut.ExecuteAsync(CancellationToken.None); result.Should().Be(1, "only ev2 succeeded; ev1 failed silently"); } [Fact] public async Task Should_ReturnZero_When_LiveListingIsEmpty() { _scraper.ScrapeLiveAsync(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()); } [Fact] public async Task Should_ReturnZeroAndSwallow_When_LiveListingFetchThrows() { _scraper.ScrapeLiveAsync(Arg.Any()) .ThrowsAsync(new HttpRequestException("listing unavailable")); var sut = CreateSut(); var result = await sut.ExecuteAsync(CancellationToken.None); result.Should().Be(0); await _scraper.DidNotReceive() .ScrapeEventOddsAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task Should_BackfillEventPath_When_ExistingRowMissedIt() { // Arrange: DB row pre-dates the EventPath column (EventPath = null); // live listing supplies a path. var withoutPath = TestFixtures.MakeEvent("55555555"); var withPath = withoutPath with { EventPath = "Football/Some+Path/Team+vs+Team+-+99" }; _scraper.ScrapeLiveAsync(Arg.Any()) .Returns(new List { withPath }.AsReadOnly()); _eventRepo.GetAsync(withPath.Id, Arg.Any()).Returns(withoutPath); _scraper.ScrapeEventOddsAsync( Arg.Is(e => e.EventPath == withPath.EventPath), OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(withPath.Id, OddsSource.Live)); var sut = CreateSut(); // Act var result = await sut.ExecuteAsync(CancellationToken.None); // Assert — the DB row was updated with the new path before scraping odds result.Should().Be(1); await _eventRepo.Received(1).UpdateAsync( Arg.Is(e => e.Id == withPath.Id && e.EventPath == withPath.EventPath), Arg.Any()); } }