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 PullUpcomingEventsUseCaseTests { private readonly IOddsScraper _scraper = Substitute.For(); private readonly IEventRepository _eventRepo = Substitute.For(); private readonly ISnapshotRepository _snapshotRepo = Substitute.For(); private PullUpcomingEventsUseCase CreateSut() => new(_scraper, _eventRepo, _snapshotRepo, NullLogger.Instance); [Fact] public async Task Should_PersistNewEventsAndCaptureSnapshots_When_ScraperReturnsEvents() { // Arrange: scraper returns 2 events, neither exists in DB var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var events = new List { ev1, ev2 }.AsReadOnly(); _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); _eventRepo.GetAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg())); var sut = CreateSut(); // Act var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); // Assert processed.Should().Be(2); newEvents.Should().Be(2); snapshots.Should().Be(2); await _eventRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); await _snapshotRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Should_SkipExistingEvents_When_EventAlreadyInDatabase() { // Arrange: 3 events from scraper — 1 already in DB, 2 new var ev1 = TestFixtures.MakeEvent("11111111"); // already in DB var ev2 = TestFixtures.MakeEvent("22222222"); // new var ev3 = TestFixtures.MakeEvent("33333333"); // new var events = new List { ev1, ev2, ev3 }.AsReadOnly(); _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); // ev1 exists, ev2/ev3 do not _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns((Event?)null); _eventRepo.GetAsync(ev3.Id, Arg.Any()).Returns((Event?)null); _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg())); var sut = CreateSut(); // Act var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); // Assert processed.Should().Be(3); newEvents.Should().Be(2, "ev1 was already in the database"); snapshots.Should().Be(3, "snapshots are captured for all events regardless of duplicate status"); await _eventRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); await _eventRepo.DidNotReceive().AddAsync(ev1, Arg.Any()); } [Fact] public async Task Should_ContinueProcessing_When_SnapshotCaptureFailsForOneEvent() { // Arrange: 2 events — snapshot for first throws, second succeeds var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var events = new List { ev1, ev2 }.AsReadOnly(); _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); _eventRepo.GetAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.PreMatch, Arg.Any()) .ThrowsAsync(new HttpRequestException("site down")); _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.PreMatch, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev2.Id)); var sut = CreateSut(); // Act — should not throw var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); // Assert processed.Should().Be(2); newEvents.Should().Be(2); snapshots.Should().Be(1, "only ev2 snapshot succeeded"); } [Fact] public async Task Should_ReturnZeros_When_ScraperReturnsNoEvents() { _scraper.ScrapeUpcomingAsync(null, Arg.Any()) .Returns(Array.Empty()); var sut = CreateSut(); var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); processed.Should().Be(0); newEvents.Should().Be(0); snapshots.Should().Be(0); } }