2acbaa5b77
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).
152 lines
5.9 KiB
C#
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>());
|
|
}
|
|
}
|