using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.Storage; 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 PullResultsUseCaseTests { private readonly IOddsScraper _scraper = Substitute.For(); private readonly IEventRepository _eventRepo = Substitute.For(); private readonly IResultRepository _resultRepo = Substitute.For(); public PullResultsUseCaseTests() { // Selection-mode candidate resolution now batches via GetManyAsync; route // it through the per-id GetAsync stubs each test configures. TestFixtures.BridgeGetMany(_eventRepo); } private static readonly DateRange AnyRange = new( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow); private PullResultsUseCase CreateSut() => new(_scraper, _eventRepo, _resultRepo, NullLogger.Instance); // ── Selection mode ────────────────────────────────────────────────────── [Fact] public async Task Should_InspectOnlySelectedEvents_When_SelectionIsProvided() { // Arrange: 3 events in the DB; only 2 in the selection var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var ev3 = TestFixtures.MakeEvent("33333333"); _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.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); var selection = new List { ev1.Id, ev2.Id }; var sut = CreateSut(); // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection, CancellationToken.None); // Assert: ev3 never resolved; only ev1+ev2 inspected inspected.Should().Be(2); loaded.Should().Be(0); skipped.Should().Be(0); await _eventRepo.DidNotReceive().GetAsync(ev3.Id, Arg.Any()); await _eventRepo.DidNotReceive().ListByDateRangeAsync(Arg.Any(), Arg.Any()); } // ── Bulk mode ─────────────────────────────────────────────────────────── [Fact] public async Task Should_InspectAllEventsInRange_When_SelectionIsNull() { 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.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); var sut = CreateSut(); var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection: null, CancellationToken.None); inspected.Should().Be(3); loaded.Should().Be(0, "scraper says none of them are complete yet"); skipped.Should().Be(0); await _eventRepo.Received(1).ListByDateRangeAsync(AnyRange, Arg.Any()); } [Fact] public async Task Should_InspectAllEventsInRange_When_SelectionIsEmpty() { var ev1 = TestFixtures.MakeEvent("11111111"); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(new List { ev1 }.AsReadOnly()); _resultRepo.GetAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); var sut = CreateSut(); var (inspected, _, _) = await sut.ExecuteAsync( AnyRange, selection: Array.Empty(), CancellationToken.None); inspected.Should().Be(1); await _eventRepo.Received(1).ListByDateRangeAsync(AnyRange, Arg.Any()); } // ── Idempotency ───────────────────────────────────────────────────────── [Fact] public async Task Should_SkipEventsWithExistingResult_And_BeIdempotent() { var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(new List { ev1, ev2 }.AsReadOnly()); _resultRepo.GetAsync(ev1.Id, Arg.Any()) .Returns(TestFixtures.MakeResult(ev1.Id)); _resultRepo.GetAsync(ev2.Id, Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); var sut = CreateSut(); var (_, _, skipped1) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); var (_, _, skipped2) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); skipped1.Should().Be(1, "ev1 already has a result"); skipped2.Should().Be(1, "idempotent: ev1 still skipped on second run"); await _resultRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); await _scraper.DidNotReceive() .ScrapeEventResultAsync( Arg.Is(e => e.Id == ev1.Id), Arg.Any()); } // ── Successful loads ──────────────────────────────────────────────────── [Fact] public async Task Should_PersistResults_When_ScraperReturnsCompletedMatch() { var ev1 = TestFixtures.MakeEvent("11111111"); var result1 = TestFixtures.MakeResult(ev1.Id); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(new List { ev1 }.AsReadOnly()); _resultRepo.GetAsync(ev1.Id, Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeEventResultAsync( Arg.Is(e => e.Id == ev1.Id), Arg.Any()) .Returns(result1); var sut = CreateSut(); var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); inspected.Should().Be(1); loaded.Should().Be(1); skipped.Should().Be(0); await _resultRepo.Received(1).AddAsync(result1, Arg.Any()); await _resultRepo.Received(1).SaveChangesAsync(Arg.Any()); } // ── Progress reporting ────────────────────────────────────────────────── [Fact] public async Task Should_ReportProgress_OncePerCandidate_With_CorrectOutcome() { var ev1 = TestFixtures.MakeEvent("11111111"); // already has result → AlreadyLoaded var ev2 = TestFixtures.MakeEvent("22222222"); // scrape returns null → NotYetComplete var ev3 = TestFixtures.MakeEvent("33333333"); // scrape returns res3 → Loaded var result3 = TestFixtures.MakeResult(ev3.Id); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(new List { ev1, ev2, ev3 }.AsReadOnly()); _resultRepo.GetAsync(ev1.Id, Arg.Any()) .Returns(TestFixtures.MakeResult(ev1.Id)); _resultRepo.GetAsync(ev2.Id, Arg.Any()) .Returns((EventResult?)null); _resultRepo.GetAsync(ev3.Id, Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeEventResultAsync( Arg.Is(e => e.Id == ev2.Id), Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeEventResultAsync( Arg.Is(e => e.Id == ev3.Id), Arg.Any()) .Returns(result3); var ticks = new List(); var progress = new Progress(ticks.Add); var sut = CreateSut(); await sut.ExecuteAsync(AnyRange, null, progress, CancellationToken.None); // Progress callback runs on the synchronization context — pump it await Task.Delay(50); ticks.Should().HaveCount(3); ticks.Select(t => t.Total).Should().AllBeEquivalentTo(3); ticks.Select(t => t.Processed).Should().Equal(1, 2, 3); ticks.Select(t => t.Outcome).Should().Equal( ResultLoadOutcome.AlreadyLoaded, ResultLoadOutcome.NotYetComplete, ResultLoadOutcome.Loaded); ticks[2].Result.Should().Be(result3); } // ── Failure isolation ─────────────────────────────────────────────────── [Fact] public async Task Should_ContinueAfterScrapeFailure_AndReportFailedOutcome() { var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var result2 = TestFixtures.MakeResult(ev2.Id); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(new List { ev1, ev2 }.AsReadOnly()); _resultRepo.GetAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); _scraper.ScrapeEventResultAsync( Arg.Is(e => e.Id == ev1.Id), Arg.Any()) .ThrowsAsync(new HttpRequestException("network down")); _scraper.ScrapeEventResultAsync( Arg.Is(e => e.Id == ev2.Id), Arg.Any()) .Returns(result2); var ticks = new List(); var progress = new Progress(ticks.Add); var sut = CreateSut(); var (inspected, loaded, _) = await sut.ExecuteAsync(AnyRange, null, progress, CancellationToken.None); await Task.Delay(50); inspected.Should().Be(2); loaded.Should().Be(1, "ev1 failed, ev2 loaded"); ticks.Should().HaveCount(2); ticks[0].Outcome.Should().Be(ResultLoadOutcome.Failed); ticks[1].Outcome.Should().Be(ResultLoadOutcome.Loaded); } }