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).
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the current pre-match event listing, persists new events (skipping
|
||||
/// duplicates by <see cref="Domain.ValueObjects.EventId"/>), and captures an
|
||||
/// initial pre-match odds snapshot for every returned event.
|
||||
/// </summary>
|
||||
public sealed class PullUpcomingEventsUseCase
|
||||
{
|
||||
private readonly IOddsScraper _scraper;
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly ISnapshotRepository _snapshotRepo;
|
||||
private readonly ILogger<PullUpcomingEventsUseCase> _logger;
|
||||
|
||||
public PullUpcomingEventsUseCase(
|
||||
IOddsScraper scraper,
|
||||
IEventRepository eventRepo,
|
||||
ISnapshotRepository snapshotRepo,
|
||||
ILogger<PullUpcomingEventsUseCase> logger)
|
||||
{
|
||||
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one polling cycle: scrape → persist new events → capture snapshots.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A tuple of <c>(EventsProcessed, NewEvents, SnapshotsCaptured)</c>.
|
||||
/// <c>EventsProcessed</c> is the total number returned by the scraper.
|
||||
/// <c>NewEvents</c> is how many were not already in the DB.
|
||||
/// <c>SnapshotsCaptured</c> is how many snapshots were successfully saved.
|
||||
/// </returns>
|
||||
public async Task<(int EventsProcessed, int NewEvents, int SnapshotsCaptured)> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("PullUpcomingEventsUseCase: cycle started");
|
||||
|
||||
var events = await _scraper.ScrapeUpcomingAsync(sportFilter: null, ct);
|
||||
int eventsProcessed = events.Count;
|
||||
int newEvents = 0;
|
||||
int snapshotsCaptured = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"PullUpcomingEventsUseCase: scraper returned {Count} events",
|
||||
eventsProcessed);
|
||||
|
||||
foreach (var ev in events)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await _eventRepo.GetAsync(ev.Id, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
await _eventRepo.AddAsync(ev, ct);
|
||||
await _eventRepo.SaveChangesAsync(ct);
|
||||
newEvents++;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"PullUpcomingEventsUseCase: failed to persist event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await _scraper.ScrapeEventOddsAsync(
|
||||
ev.Id,
|
||||
Domain.Enums.OddsSource.PreMatch,
|
||||
ct);
|
||||
|
||||
await _snapshotRepo.AddAsync(snapshot, ct);
|
||||
await _snapshotRepo.SaveChangesAsync(ct);
|
||||
snapshotsCaptured++;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"PullUpcomingEventsUseCase: failed to capture snapshot for event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"PullUpcomingEventsUseCase: cycle done — processed={Processed}, new={New}, snapshots={Snapshots}",
|
||||
eventsProcessed, newEvents, snapshotsCaptured);
|
||||
|
||||
return (eventsProcessed, newEvents, snapshotsCaptured);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user