using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.Storage; using Marathon.Application.UseCases; using Marathon.Domain.Entities; using Marathon.Domain.ValueObjects; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; namespace Marathon.Application.Tests.UseCases; public sealed class PullResultsUseCaseTests { private readonly IOddsScraper _scraper = Substitute.For(); private readonly IEventRepository _eventRepo = Substitute.For(); private readonly IResultRepository _resultRepo = Substitute.For(); private static readonly DateRange AnyRange = new( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow); private PullResultsUseCase CreateSut() => new(_scraper, _eventRepo, _resultRepo, NullLogger.Instance); [Fact] public async Task Should_InspectOnlySelectedEvents_When_SelectionIsProvided() { // Arrange: 3 events in DB; only 2 are in the selection var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var ev3 = TestFixtures.MakeEvent("33333333"); // not selected _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); _eventRepo.GetAsync(ev3.Id, Arg.Any()).Returns(ev3); _resultRepo.GetAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); // Scraper returns no results (Phase 3 no-op) _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) .Returns(Array.Empty()); var selection = new List { ev1.Id, ev2.Id }; var sut = CreateSut(); // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection, CancellationToken.None); // Assert: only ev1 and ev2 inspected; ev3 not fetched via GetAsync lookup for range inspected.Should().Be(2); loaded.Should().Be(0, "scraper returns no results in Phase 3"); skipped.Should().Be(0); // ev3 was never resolved await _eventRepo.DidNotReceive().GetAsync(ev3.Id, Arg.Any()); } [Fact] public async Task Should_InspectAllEventsInRange_When_SelectionIsNull() { // Arrange: 3 events returned by date-range query var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var ev3 = TestFixtures.MakeEvent("33333333"); var allEvents = new List { ev1, ev2, ev3 }.AsReadOnly(); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(allEvents); _resultRepo.GetAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) .Returns(Array.Empty()); var sut = CreateSut(); // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection: null, CancellationToken.None); // Assert inspected.Should().Be(3); loaded.Should().Be(0); skipped.Should().Be(0); await _eventRepo.Received(1).ListByDateRangeAsync(AnyRange, Arg.Any()); } [Fact] public async Task Should_SkipEventsWithExistingResult_And_BeIdempotent() { // Arrange: 2 events — ev1 already has a result stored var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var allEvents = new List { ev1, ev2 }.AsReadOnly(); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(allEvents); _resultRepo.GetAsync(ev1.Id, Arg.Any()) .Returns(TestFixtures.MakeResult(ev1.Id)); // ev1 already has result _resultRepo.GetAsync(ev2.Id, Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) .Returns(Array.Empty()); var sut = CreateSut(); // Act — run twice to verify idempotency var (_, _, skipped1) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); var (_, _, skipped2) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); // Assert skipped1.Should().Be(1, "ev1 already has a result"); skipped2.Should().Be(1, "idempotent: ev1 still skipped on second run"); // No new results persisted await _resultRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Should_PersistResults_When_ScraperReturnsMatchingResults() { // Arrange: 1 event; scraper returns a result for it var ev1 = TestFixtures.MakeEvent("11111111"); var result1 = TestFixtures.MakeResult(ev1.Id); var allEvents = new List { ev1 }.AsReadOnly(); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(allEvents); _resultRepo.GetAsync(ev1.Id, Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) .Returns(new List { result1 }.AsReadOnly()); var sut = CreateSut(); // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); // Assert inspected.Should().Be(1); loaded.Should().Be(1); skipped.Should().Be(0); await _resultRepo.Received(1).AddAsync(result1, Arg.Any()); } }