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:
2026-05-05 12:28:15 +03:00
parent c4d87b59d6
commit 2acbaa5b77
31 changed files with 1719 additions and 94 deletions
@@ -0,0 +1,54 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Application.UseCases;
/// <summary>
/// Exports odds snapshots for a date range to an Excel file, placing it in
/// the configured export directory.
/// </summary>
public sealed class ExportToExcelUseCase
{
private readonly IExcelExporter _exporter;
private readonly IOptions<StorageOptions> _storageOptions;
private readonly ILogger<ExportToExcelUseCase> _logger;
public ExportToExcelUseCase(
IExcelExporter exporter,
IOptions<StorageOptions> storageOptions,
ILogger<ExportToExcelUseCase> logger)
{
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs the export and returns the absolute path of the created file.
/// </summary>
/// <param name="range">Inclusive date range to export.</param>
/// <param name="kind">Which snapshots to include.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Absolute path of the written <c>.xlsx</c> file.</returns>
public async Task<string> ExecuteAsync(DateRange range, ExportKind kind, CancellationToken ct)
{
var exportDir = _storageOptions.Value.ExportDirectory;
// Ensure the output directory exists before handing off to the exporter.
Directory.CreateDirectory(exportDir);
_logger.LogInformation(
"ExportToExcelUseCase: exporting {Kind} snapshots for {From:yyyy-MM-dd}..{To:yyyy-MM-dd} → {Dir}",
kind, range.From, range.To, exportDir);
var outputPath = await _exporter.ExportAsync(range, kind, exportDir, ct);
_logger.LogInformation(
"ExportToExcelUseCase: export complete — file={Path}",
outputPath);
return outputPath;
}
}
@@ -0,0 +1,79 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// For each currently-live event in the database, fetches a fresh odds snapshot
/// via the scraper and persists it.
/// </summary>
public sealed class PullLiveOddsUseCase
{
private readonly IOddsScraper _scraper;
private readonly IEventRepository _eventRepo;
private readonly ISnapshotRepository _snapshotRepo;
private readonly ILogger<PullLiveOddsUseCase> _logger;
public PullLiveOddsUseCase(
IOddsScraper scraper,
IEventRepository eventRepo,
ISnapshotRepository snapshotRepo,
ILogger<PullLiveOddsUseCase> 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 live-odds polling cycle.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of snapshots successfully captured.</returns>
public async Task<int> ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("PullLiveOddsUseCase: cycle started");
// Fetch live events from scraper — returns only events currently live on site.
var liveEvents = await _scraper.ScrapeUpcomingAsync(sportFilter: null, ct);
// Note: the scraper's ScrapeUpcomingAsync returns pre-match by default.
// For the live cycle, we load known events from the DB and refresh each one.
// The "live vs pre-match" distinction is handled by OddsSource.Live in the snapshot.
// We use the DB list because the site's live listing may differ from what we track.
var allEvents = await _eventRepo.ListAsync(ct);
int snapshotsCaptured = 0;
foreach (var ev in allEvents)
{
ct.ThrowIfCancellationRequested();
try
{
var snapshot = await _scraper.ScrapeEventOddsAsync(ev.Id, OddsSource.Live, ct);
await _snapshotRepo.AddAsync(snapshot, ct);
await _snapshotRepo.SaveChangesAsync(ct);
snapshotsCaptured++;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullLiveOddsUseCase: failed to capture live snapshot for event {EventId} — skipping",
ev.Id.Value);
}
}
_logger.LogInformation(
"PullLiveOddsUseCase: cycle done — snapshots captured for {Count}/{Total} events",
snapshotsCaptured, allEvents.Count);
return snapshotsCaptured;
}
}
@@ -0,0 +1,140 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Scaffolded results loader — inspects events for completion and persists
/// <see cref="Domain.Entities.EventResult"/>s when detected.
/// </summary>
/// <remarks>
/// <para>
/// <b>Phase 4 scaffold:</b> This implementation is intentionally minimal.
/// The formal watch-list polling strategy lands in Phase 8, when
/// <c>IOddsScraper.ScrapeResultsAsync</c> will be replaced with real
/// per-event polling against <c>IResultsParser</c>.
/// </para>
/// <para>
/// Current behaviour: calls <c>IOddsScraper.ScrapeResultsAsync</c> (which
/// returns an empty list and logs a warning per Phase 3), so
/// <c>ResultsLoaded</c> will always be 0 until Phase 8.
/// All events with existing results are skipped (idempotent).
/// </para>
/// </remarks>
public sealed class PullResultsUseCase
{
private readonly IOddsScraper _scraper;
private readonly IEventRepository _eventRepo;
private readonly IResultRepository _resultRepo;
private readonly ILogger<PullResultsUseCase> _logger;
public PullResultsUseCase(
IOddsScraper scraper,
IEventRepository eventRepo,
IResultRepository resultRepo,
ILogger<PullResultsUseCase> logger)
{
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Inspects events for completion and persists results.
/// </summary>
/// <param name="range">Date range to scope the event search.</param>
/// <param name="selection">
/// When non-null, only these event IDs are inspected.
/// When null, all events in <paramref name="range"/> without a result row are inspected.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A tuple of <c>(Inspected, ResultsLoaded, Skipped)</c> where:
/// <list type="bullet">
/// <item><c>Inspected</c>: total candidates examined.</item>
/// <item><c>ResultsLoaded</c>: results that were persisted this cycle.</item>
/// <item><c>Skipped</c>: events already with a result (idempotency guard).</item>
/// </list>
/// </returns>
public async Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync(
DateRange range,
IReadOnlyList<DomainEventId>? selection,
CancellationToken ct)
{
_logger.LogInformation(
"PullResultsUseCase: cycle started — range={From:O}..{To:O}, selection={SelectionCount}",
range.From, range.To, selection?.Count.ToString() ?? "all");
// Resolve the candidate event IDs.
IReadOnlyList<Domain.Entities.Event> candidates;
if (selection is { Count: > 0 })
{
var selected = new List<Domain.Entities.Event>(selection.Count);
foreach (var id in selection)
{
ct.ThrowIfCancellationRequested();
var ev = await _eventRepo.GetAsync(id, ct);
if (ev is not null)
selected.Add(ev);
}
candidates = selected;
}
else
{
candidates = await _eventRepo.ListByDateRangeAsync(range, ct);
}
int inspected = 0;
int resultsLoaded = 0;
int skipped = 0;
// Use the scraper's results endpoint (currently a no-op in Phase 3 — returns []).
var scraped = await _scraper.ScrapeResultsAsync(range, ct);
var scrapedByEventId = scraped.ToDictionary(r => r.EventId.Value, r => r);
foreach (var ev in candidates)
{
ct.ThrowIfCancellationRequested();
inspected++;
try
{
// Idempotency: skip events that already have a result stored.
var existingResult = await _resultRepo.GetAsync(ev.Id, ct);
if (existingResult is not null)
{
skipped++;
continue;
}
// Check if the scraper returned a result for this event.
if (scrapedByEventId.TryGetValue(ev.Id.Value, out var result))
{
await _resultRepo.AddAsync(result, ct);
await _resultRepo.SaveChangesAsync(ct);
resultsLoaded++;
}
// Phase 8: else → add to watch list for next poll cycle.
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullResultsUseCase: error processing event {EventId} — skipping",
ev.Id.Value);
}
}
_logger.LogInformation(
"PullResultsUseCase: cycle done — inspected={Inspected}, loaded={Loaded}, skipped={Skipped}",
inspected, resultsLoaded, skipped);
return (inspected, resultsLoaded, skipped);
}
}
@@ -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);
}
}