Files
maraphon-app/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs
T
alexei.dolgolyov 2acbaa5b77 feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
2026-05-05 12:28:15 +03:00

152 lines
5.9 KiB
C#

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<IOddsScraper>();
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
private static readonly DateRange AnyRange = new(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow);
private PullResultsUseCase CreateSut() =>
new(_scraper, _eventRepo, _resultRepo,
NullLogger<PullResultsUseCase>.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<CancellationToken>()).Returns(ev1);
_eventRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>()).Returns(ev2);
_eventRepo.GetAsync(ev3.Id, Arg.Any<CancellationToken>()).Returns(ev3);
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
// Scraper returns no results (Phase 3 no-op)
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<EventResult>());
var selection = new List<EventId> { 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<CancellationToken>());
}
[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<Event> { ev1, ev2, ev3 }.AsReadOnly();
_eventRepo.ListByDateRangeAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(allEvents);
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<EventResult>());
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<CancellationToken>());
}
[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<Event> { ev1, ev2 }.AsReadOnly();
_eventRepo.ListByDateRangeAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(allEvents);
_resultRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>())
.Returns(TestFixtures.MakeResult(ev1.Id)); // ev1 already has result
_resultRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<EventResult>());
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<EventResult>(), Arg.Any<CancellationToken>());
}
[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<Event> { ev1 }.AsReadOnly();
_eventRepo.ListByDateRangeAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(allEvents);
_resultRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(new List<EventResult> { 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<CancellationToken>());
}
}