diff --git a/CLAUDE.md b/CLAUDE.md index 725e37f..b346d7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,28 @@ Marathon__to_.xlsx - **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`). +## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings + +- **Two `WorkerOptions` classes coexist** with the same JSON shape but different namespaces: + `Marathon.Infrastructure.Configuration.WorkerOptions` (immutable `init`, used by workers) + and `Marathon.UI.Services.WorkerOptions` (mutable `set`, used by Settings page). + Both bind to `"Workers"` in `appsettings.json`. Keep them in sync when adding new keys. +- **`Microsoft.Extensions.Logging.EventId` conflicts with `Marathon.Domain.ValueObjects.EventId`** + in any project that adds `Microsoft.Extensions.Logging.Abstractions`. Fix with a global alias + in `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;` + and local file aliases where both are used together. +- **NSubstitute cannot proxy `sealed` classes.** Use cases are `sealed record` or `sealed class`. + Worker tests must build a real use-case instance backed by substituted interfaces rather than + substituting the use case directly. +- **`BackgroundService` workers are singletons; use cases are scoped.** Always resolve scoped + use cases via `IServiceProvider.CreateAsyncScope()` inside the worker loop — never inject them + directly into the constructor. +- **Cronos 6-field cron format.** Pass `CronFormat.IncludeSeconds` to `CronExpression.Parse` + when the expression has a seconds field (e.g., `"0 0 */6 * * *"`). Default Cronos parse + expects 5-field (no seconds). +- **`ApplicationModule.AddMarathonApplication` takes no `IConfiguration`** — the Application + layer has no config bindings of its own. Infrastructure and UI bind their own options sections. + ## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings (Permanent learnings about marathonbet.by data shape, anti-bot, page structure. diff --git a/plans/initial-implementation/CONTEXT.md b/plans/initial-implementation/CONTEXT.md index 33c5e9c..c55adb6 100644 --- a/plans/initial-implementation/CONTEXT.md +++ b/plans/initial-implementation/CONTEXT.md @@ -83,7 +83,7 @@ with scraping research, no implementation. | Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 9 projects (5 src + 4 test). 96 domain tests passed. Key decisions: BetScope sealed hierarchy, ScheduledAt=UTC+3 (Moscow), OddsValue rejects zero. Deviations: slnx auto-created alongside sln, WPF App.xaml.cs needs FQ Application type. | | Phase 2 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 3 + 5 | — | | Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — | -| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | — | +| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 4 use cases, 3 BackgroundService pollers, InfrastructureModule, ApplicationModule, reflection wiring removed. 202/202 tests green (+17 new). | | Phase 5 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | ✅ With 2 + 3 | Uses frontend-design skill | | Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | Uses frontend-design skill | | Phase 7 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus | diff --git a/plans/initial-implementation/PLAN.md b/plans/initial-implementation/PLAN.md index b01217d..432a74c 100644 --- a/plans/initial-implementation/PLAN.md +++ b/plans/initial-implementation/PLAN.md @@ -66,7 +66,7 @@ parameter configurable. | Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea | | Phase 2: Storage | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) | | Phase 3: Scraping | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) | -| Phase 4: Application + Workers | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | +| Phase 4: Application + Workers | backend | ✅ Done | ⬜ | ✅ Build OK + 202/202 tests | ⬜ | | Phase 5: Host + Theme + i18n | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 11/11 UI tests | ✅ batch (e4d8476…686550d…+) | | Phase 6: Event browsing UI | frontend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | | Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | diff --git a/plans/initial-implementation/phase-4-application-and-workers.md b/plans/initial-implementation/phase-4-application-and-workers.md index a6a2981..e94b667 100644 --- a/plans/initial-implementation/phase-4-application-and-workers.md +++ b/plans/initial-implementation/phase-4-application-and-workers.md @@ -1,6 +1,6 @@ # Phase 4: Application Layer + Background Workers -**Status:** ⬜ Not Started +**Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend **Depends on:** Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping) @@ -12,7 +12,7 @@ and background services that execute pollers on configurable intervals. ## Tasks -- [ ] Implement use cases in `Marathon.Application/UseCases/`: +- [x] Implement use cases in `Marathon.Application/UseCases/`: - `PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)` - `ExecuteAsync(CancellationToken)` → fetch upcoming events, persist new ones, capture initial pre-match snapshots for each @@ -22,39 +22,38 @@ and background services that execute pollers on configurable intervals. - `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)` - `ExecuteAsync(DateRange range, IReadOnlyList? selection, CancellationToken)` → fetch results for completed events (all or selected) - - `ExportToExcelUseCase(IExcelExporter, IEventRepository)` + - `ExportToExcelUseCase(IExcelExporter, IOptions, ILogger)` - `ExecuteAsync(DateRange, ExportKind, CancellationToken)` -- [ ] Implement background services in `Marathon.Infrastructure/Workers/`: +- [x] Implement background services in `Marathon.Infrastructure/Workers/`: - `UpcomingEventsPoller : BackgroundService` — runs `PullUpcomingEventsUseCase` on - a configurable cron-like schedule (default: every 6 hours) + a configurable cron-like schedule (default: every 6 hours, Cronos 6-field) - `LiveOddsPoller : BackgroundService` — runs `PullLiveOddsUseCase` every - `Scraping:PollingIntervalSeconds` seconds - - Both honor `CancellationToken`, log via `ILogger`, and skip cycles gracefully - on errors (don't crash the host) -- [ ] Add `WorkerOptions` POCO bound to `Workers:*` config: - ```csharp - public sealed class WorkerOptions { - public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *"; // every 6h - public bool LivePollerEnabled { get; init; } = true; - public bool UpcomingPollerEnabled { get; init; } = true; - } - ``` - Use `Cronos` package or simple TimeSpan for upcoming schedule. -- [ ] Add DI extension `AddMarathonApplication(IServiceCollection, IConfiguration)` - in `Marathon.Application/DependencyInjection.cs`: - - Registers all use cases -- [ ] Update `Marathon.Infrastructure/DependencyInjection.cs` to also register - `BackgroundService`s under `services.AddHostedService()`. -- [ ] Tests in `Marathon.Application.Tests`: + `WorkerOptions.LivePollIntervalSeconds` seconds (default 30 s) + - `ResultsWatchListPoller : BackgroundService` — scaffold disabled by default + (`WorkerOptions.ResultsPollerEnabled = false`); formal impl in Phase 8 + - All honor `CancellationToken`, log via `ILogger`, skip cycles gracefully on errors +- [x] Add `WorkerOptions` POCO bound to `Workers:*` config + (in `Marathon.Infrastructure.Configuration`; UI mirror in `Marathon.UI.Services`): + `UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`, + `LivePollIntervalSeconds`, `ResultsPollerEnabled`, `ResultsPollIntervalSeconds` +- [x] Add `ApplicationModule.AddMarathonApplication(IServiceCollection)` in + `Marathon.Application/ApplicationModule.cs` — no `IConfiguration` needed +- [x] Add `InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration)` + in `Marathon.Infrastructure/InfrastructureModule.cs` — composes Persistence + Scraping + Workers +- [x] Replace reflection wiring in `App.xaml.cs` with direct `AddMarathonApplication()` + + `AddMarathonInfrastructure(config)` calls; removed `TryAddApplicationAndInfrastructure` + and `TryInvokeExtension` helpers +- [x] Bind `Sports:Basketball:QuarterMode` from config in `ScrapingModule` (Phase 3 TODO resolved) +- [x] Add new `Workers` keys to `appsettings.json` + `SharedResource.*.resx` + `Settings.razor` +- [x] Tests in `Marathon.Application.Tests/UseCases/`: - Mock `IOddsScraper` + repos with NSubstitute - - Test: `PullUpcomingEventsUseCase` persists new events, skips duplicates - - Test: `PullLiveOddsUseCase` writes a snapshot per live event - - Test: `PullResultsUseCase` respects `selection` filter (when null, fetches all) - - Test: `ExportToExcelUseCase` invokes `IExcelExporter.ExportAsync` with correct - date range -- [ ] Tests in `Marathon.Infrastructure.Tests/Workers/`: - - Test: `LiveOddsPoller` invokes use case at configured interval (use FakeTimeProvider) - - Test: poller continues after a use-case exception (logs, doesn't propagate) + - `PullUpcomingEventsUseCaseTests`: persists new events, skips duplicates, tolerates snapshot failures + - `PullLiveOddsUseCaseTests`: one snapshot per live event, survives per-event errors + - `PullResultsUseCaseTests`: selection filter, null=all-in-range, idempotency, persists scraped results + - `ExportToExcelUseCaseTests`: delegates to exporter with correct args, propagates exporter exceptions +- [x] Tests in `Marathon.Infrastructure.Tests/Workers/`: + - `LiveOddsPollerTests`: happy-path invokes use case; disabled flag skips use case; + exception-swallowing (continues running after use-case error) ## Files to Modify/Create @@ -83,12 +82,101 @@ and background services that execute pollers on configurable intervals. ## Review Checklist -- [ ] Use cases have no Infrastructure dependencies -- [ ] Both pollers configurable (interval, enable/disable) -- [ ] Cancellation propagated correctly -- [ ] Errors logged, not propagated out of `ExecuteAsync` +- [x] Use cases have no Infrastructure dependencies +- [x] All three pollers configurable (interval, enable/disable) +- [x] Cancellation propagated correctly (OperationCanceledException re-thrown, breaks loop) +- [x] Errors logged, not propagated out of `ExecuteAsync` ## Handoff to Next Phase - +### For Phase 6 (Event Browsing UI) + +#### Use case names, namespaces, and DI lifetimes + +All use cases are in `Marathon.Application.UseCases`, registered `Scoped`: + +| Class | `ExecuteAsync` signature | Return type | +|---|---|---| +| `PullUpcomingEventsUseCase` | `(CancellationToken)` | `(int EventsProcessed, int NewEvents, int SnapshotsCaptured)` | +| `PullLiveOddsUseCase` | `(CancellationToken)` | `int` (snapshots captured) | +| `PullResultsUseCase` | `(DateRange, IReadOnlyList?, CancellationToken)` | `(int Inspected, int ResultsLoaded, int Skipped)` | +| `ExportToExcelUseCase` | `(DateRange, ExportKind, CancellationToken)` | `string` (absolute output path) | + +`DomainEventId` alias: `using DomainEventId = Marathon.Domain.ValueObjects.EventId;` +(needed to disambiguate from `Microsoft.Extensions.Logging.EventId`). + +#### How to inject and call from a Blazor component + +```csharp +@inject PullUpcomingEventsUseCase Puller +@inject ExportToExcelUseCase Exporter + +// In an event handler: +var result = await Puller.ExecuteAsync(CancellationToken.None); +// result.EventsProcessed, result.NewEvents, result.SnapshotsCaptured + +var path = await Exporter.ExecuteAsync(range, ExportKind.Combined, CancellationToken.None); +``` + +**Important caveat:** Use cases are `Scoped`. In Blazor Server/Hybrid each circuit has +its own scope, so injecting directly is safe. Do NOT call long-running use cases +synchronously on the UI thread — use `Task.Run` or await with a progress indicator. +Ad-hoc "Export now" or "Refresh now" buttons are fine to call directly from a component +event handler since those are already async. + +#### BackgroundService names + +| Class | Config key | Default | Notes | +|---|---|---|---| +| `UpcomingEventsPoller` | `Workers:UpcomingPollerEnabled` | `true` | Cron driven (`Workers:UpcomingScheduleCron`, default every 6 h) | +| `LiveOddsPoller` | `Workers:LivePollerEnabled` | `true` | Fixed interval (`Workers:LivePollIntervalSeconds`, default 30 s) | +| `ResultsWatchListPoller` | `Workers:ResultsPollerEnabled` | **`false`** | Disabled until Phase 8 | + +All three are registered via `AddMarathonInfrastructure`. They start automatically +with the `IHost`. No manual wiring needed. + +#### WorkerOptions POCO locations + +Two separate `WorkerOptions` classes exist (same JSON shape, different namespaces): + +- `Marathon.Infrastructure.Configuration.WorkerOptions` — used by workers (immutable `init` setters) +- `Marathon.UI.Services.WorkerOptions` — used by the Settings page (mutable `set` setters) + +Both bind to `"Workers"` in `appsettings.json`. Phase 6 can read live values via +`IOptionsMonitor` (already registered by `AddMarathonUi`). + +#### ApplicationModule entry point + +```csharp +services.AddMarathonApplication(); // no IConfiguration needed +services.AddMarathonInfrastructure(config); // wires Persistence + Scraping + Workers +``` + +These are already called in `App.xaml.cs`. Phase 6 needs no changes to DI setup. + +#### New config keys added in Phase 4 + +```json +"Workers": { + "UpcomingScheduleCron": "0 0 */6 * * *", + "LivePollerEnabled": true, + "UpcomingPollerEnabled": true, + "LivePollIntervalSeconds": 30, + "ResultsPollIntervalSeconds": 300, + "ResultsPollerEnabled": false +}, +"Sports": { + "Basketball": { "QuarterMode": false } +} +``` + +#### Phase 3 TODO resolved + +`ScrapingModule` now binds `Sports:Basketball:QuarterMode` from config and passes +it to the `PeriodScopeMapper` constructor. The TODO comment is removed. + +#### Tests added + +- `Marathon.Application.Tests`: 14 new tests (1 placeholder → 15 total) covering all 4 use cases. +- `Marathon.Infrastructure.Tests`: 3 new worker tests (77 → 80 total). +- Total suite: 185 → **202 passing**. diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs new file mode 100644 index 0000000..029c422 --- /dev/null +++ b/src/Marathon.Application/ApplicationModule.cs @@ -0,0 +1,35 @@ +using Marathon.Application.UseCases; +using Microsoft.Extensions.DependencyInjection; + +namespace Marathon.Application; + +/// +/// DI registration helpers for the Marathon.Application layer. +/// Call from the composition root (host or +/// InfrastructureModule). +/// +public static class ApplicationModule +{ + /// + /// Registers all Application-layer use cases with Scoped lifetime. + /// Use cases are scoped so that each background-service cycle or UI request + /// gets a fresh unit-of-work from its own DI scope. + /// + /// + /// No is + /// required here — the Application layer has no direct configuration bindings. + /// Infrastructure and UI layers bind their own options against the shared + /// JSON sections. + /// + public static IServiceCollection AddMarathonApplication(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Marathon.Application/GlobalUsings.cs b/src/Marathon.Application/GlobalUsings.cs new file mode 100644 index 0000000..1f0a0ce --- /dev/null +++ b/src/Marathon.Application/GlobalUsings.cs @@ -0,0 +1,3 @@ +// Alias Microsoft.Extensions.Logging.EventId to avoid name conflict with +// Marathon.Domain.ValueObjects.EventId throughout the Application layer. +global using LogEventId = Microsoft.Extensions.Logging.EventId; diff --git a/src/Marathon.Application/Marathon.Application.csproj b/src/Marathon.Application/Marathon.Application.csproj index 12ee053..c763fdd 100644 --- a/src/Marathon.Application/Marathon.Application.csproj +++ b/src/Marathon.Application/Marathon.Application.csproj @@ -4,6 +4,11 @@ net8.0 + + + + + diff --git a/src/Marathon.Application/UseCases/ExportToExcelUseCase.cs b/src/Marathon.Application/UseCases/ExportToExcelUseCase.cs new file mode 100644 index 0000000..960420d --- /dev/null +++ b/src/Marathon.Application/UseCases/ExportToExcelUseCase.cs @@ -0,0 +1,54 @@ +using Marathon.Application.Abstractions; +using Marathon.Application.Storage; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Marathon.Application.UseCases; + +/// +/// Exports odds snapshots for a date range to an Excel file, placing it in +/// the configured export directory. +/// +public sealed class ExportToExcelUseCase +{ + private readonly IExcelExporter _exporter; + private readonly IOptions _storageOptions; + private readonly ILogger _logger; + + public ExportToExcelUseCase( + IExcelExporter exporter, + IOptions storageOptions, + ILogger logger) + { + _exporter = exporter ?? throw new ArgumentNullException(nameof(exporter)); + _storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Runs the export and returns the absolute path of the created file. + /// + /// Inclusive date range to export. + /// Which snapshots to include. + /// Cancellation token. + /// Absolute path of the written .xlsx file. + public async Task 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; + } +} diff --git a/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs b/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs new file mode 100644 index 0000000..6fea2cf --- /dev/null +++ b/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs @@ -0,0 +1,79 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.Enums; +using Microsoft.Extensions.Logging; + +namespace Marathon.Application.UseCases; + +/// +/// For each currently-live event in the database, fetches a fresh odds snapshot +/// via the scraper and persists it. +/// +public sealed class PullLiveOddsUseCase +{ + private readonly IOddsScraper _scraper; + private readonly IEventRepository _eventRepo; + private readonly ISnapshotRepository _snapshotRepo; + private readonly ILogger _logger; + + public PullLiveOddsUseCase( + IOddsScraper scraper, + IEventRepository eventRepo, + ISnapshotRepository snapshotRepo, + ILogger 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)); + } + + /// + /// Executes one live-odds polling cycle. + /// + /// Cancellation token. + /// Number of snapshots successfully captured. + public async Task 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; + } +} diff --git a/src/Marathon.Application/UseCases/PullResultsUseCase.cs b/src/Marathon.Application/UseCases/PullResultsUseCase.cs new file mode 100644 index 0000000..1c8a347 --- /dev/null +++ b/src/Marathon.Application/UseCases/PullResultsUseCase.cs @@ -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; + +/// +/// Scaffolded results loader — inspects events for completion and persists +/// s when detected. +/// +/// +/// +/// Phase 4 scaffold: This implementation is intentionally minimal. +/// The formal watch-list polling strategy lands in Phase 8, when +/// IOddsScraper.ScrapeResultsAsync will be replaced with real +/// per-event polling against IResultsParser. +/// +/// +/// Current behaviour: calls IOddsScraper.ScrapeResultsAsync (which +/// returns an empty list and logs a warning per Phase 3), so +/// ResultsLoaded will always be 0 until Phase 8. +/// All events with existing results are skipped (idempotent). +/// +/// +public sealed class PullResultsUseCase +{ + private readonly IOddsScraper _scraper; + private readonly IEventRepository _eventRepo; + private readonly IResultRepository _resultRepo; + private readonly ILogger _logger; + + public PullResultsUseCase( + IOddsScraper scraper, + IEventRepository eventRepo, + IResultRepository resultRepo, + ILogger 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)); + } + + /// + /// Inspects events for completion and persists results. + /// + /// Date range to scope the event search. + /// + /// When non-null, only these event IDs are inspected. + /// When null, all events in without a result row are inspected. + /// + /// Cancellation token. + /// + /// A tuple of (Inspected, ResultsLoaded, Skipped) where: + /// + /// Inspected: total candidates examined. + /// ResultsLoaded: results that were persisted this cycle. + /// Skipped: events already with a result (idempotency guard). + /// + /// + public async Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync( + DateRange range, + IReadOnlyList? 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 candidates; + if (selection is { Count: > 0 }) + { + var selected = new List(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); + } +} diff --git a/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs b/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs new file mode 100644 index 0000000..5ed8ea5 --- /dev/null +++ b/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs @@ -0,0 +1,108 @@ +using Marathon.Application.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Marathon.Application.UseCases; + +/// +/// Fetches the current pre-match event listing, persists new events (skipping +/// duplicates by ), and captures an +/// initial pre-match odds snapshot for every returned event. +/// +public sealed class PullUpcomingEventsUseCase +{ + private readonly IOddsScraper _scraper; + private readonly IEventRepository _eventRepo; + private readonly ISnapshotRepository _snapshotRepo; + private readonly ILogger _logger; + + public PullUpcomingEventsUseCase( + IOddsScraper scraper, + IEventRepository eventRepo, + ISnapshotRepository snapshotRepo, + ILogger 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)); + } + + /// + /// Executes one polling cycle: scrape → persist new events → capture snapshots. + /// + /// Cancellation token. + /// + /// A tuple of (EventsProcessed, NewEvents, SnapshotsCaptured). + /// EventsProcessed is the total number returned by the scraper. + /// NewEvents is how many were not already in the DB. + /// SnapshotsCaptured is how many snapshots were successfully saved. + /// + 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); + } +} diff --git a/src/Marathon.Hosts.WpfBlazor/App.xaml.cs b/src/Marathon.Hosts.WpfBlazor/App.xaml.cs index be2bed5..ea8e92e 100644 --- a/src/Marathon.Hosts.WpfBlazor/App.xaml.cs +++ b/src/Marathon.Hosts.WpfBlazor/App.xaml.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.IO; using System.Windows; +using Marathon.Application; +using Marathon.Infrastructure; using Marathon.UI.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -73,9 +75,9 @@ public partial class App : System.Windows.Application builder.Services.AddBlazorWebViewDeveloperTools(); #endif - // Compose Application + Infrastructure modules if they exist. Parallel - // Phase 2/3/4 work may still be merging these; we degrade gracefully. - TryAddApplicationAndInfrastructure(builder.Services, builder.Configuration); + // Application use cases + Infrastructure (persistence, scraping, workers). + builder.Services.AddMarathonApplication(); + builder.Services.AddMarathonInfrastructure(builder.Configuration); // MainWindow needs the IServiceProvider for BlazorWebView.Services binding. builder.Services.AddSingleton(); @@ -104,50 +106,6 @@ public partial class App : System.Windows.Application ? level : Serilog.Events.LogEventLevel.Information; - /// - /// Best-effort wiring of the Application + Infrastructure DI modules. - /// TODO(phase-4): the orchestrator will land a single - /// AddMarathonInfrastructure(config) entry point. Until then we use - /// reflection to call whichever extension methods exist so partial merges - /// don't break compilation of this host. - /// - private static void TryAddApplicationAndInfrastructure(IServiceCollection services, IConfiguration configuration) - { - TryInvokeExtension(services, configuration, "Marathon.Application.DependencyInjection", "AddMarathonApplication"); - TryInvokeExtension(services, configuration, "Marathon.Infrastructure.DependencyInjection", "AddMarathonInfrastructure"); - TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Persistence.PersistenceServiceCollectionExtensions", "AddMarathonPersistence"); - TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Scraping.ScrapingServiceCollectionExtensions", "AddMarathonScraping"); - } - - private static void TryInvokeExtension(IServiceCollection services, IConfiguration configuration, string typeName, string methodName) - { - try - { - // Probe across all loaded assemblies — project refs cause them to load on startup. - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - var type = asm.GetType(typeName, throwOnError: false, ignoreCase: false); - if (type is null) - { - continue; - } - - var method = type.GetMethod(methodName, new[] { typeof(IServiceCollection), typeof(IConfiguration) }); - if (method is null) - { - continue; - } - - method.Invoke(null, new object[] { services, configuration }); - return; - } - } - catch (Exception ex) - { - Log.Warning(ex, "Optional module {Type}.{Method} not wired", typeName, methodName); - } - } - protected override void OnExit(ExitEventArgs e) { try diff --git a/src/Marathon.Hosts.WpfBlazor/appsettings.json b/src/Marathon.Hosts.WpfBlazor/appsettings.json index 00432c3..af41e01 100644 --- a/src/Marathon.Hosts.WpfBlazor/appsettings.json +++ b/src/Marathon.Hosts.WpfBlazor/appsettings.json @@ -19,9 +19,17 @@ "RequestTimeoutSeconds": 30 }, "Workers": { - "UpcomingScheduleCron": "0 */5 * * * *", + "UpcomingScheduleCron": "0 0 */6 * * *", "LivePollerEnabled": true, - "UpcomingPollerEnabled": true + "UpcomingPollerEnabled": true, + "LivePollIntervalSeconds": 30, + "ResultsPollIntervalSeconds": 300, + "ResultsPollerEnabled": false + }, + "Sports": { + "Basketball": { + "QuarterMode": false + } }, "Storage": { "DatabasePath": "./data/marathon.db", diff --git a/src/Marathon.Infrastructure/Configuration/WorkerOptions.cs b/src/Marathon.Infrastructure/Configuration/WorkerOptions.cs new file mode 100644 index 0000000..c2b4eff --- /dev/null +++ b/src/Marathon.Infrastructure/Configuration/WorkerOptions.cs @@ -0,0 +1,43 @@ +namespace Marathon.Infrastructure.Configuration; + +/// +/// Strongly typed options for the background worker pollers. +/// Bound from the Workers section of appsettings.json. +/// +public sealed class WorkerOptions +{ + public const string SectionName = "Workers"; + + /// + /// Cron expression (6-field with seconds: s m h d M dow) controlling when the + /// upcoming-events poller fires. Default: every 6 hours. + /// + public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *"; + + /// Whether the live odds poller should run at startup. + public bool LivePollerEnabled { get; init; } = true; + + /// Whether the upcoming/pre-match poller should run at startup. + public bool UpcomingPollerEnabled { get; init; } = true; + + /// + /// How long the live odds poller sleeps between polling cycles, in seconds. + /// Default: 30 s (matches Scraping:PollingIntervalSeconds but is + /// independently configurable here). + /// + public int LivePollIntervalSeconds { get; init; } = 30; + + /// + /// How long the results watch-list poller sleeps between cycles, in seconds. + /// Default: 300 s (5 minutes). + /// + public int ResultsPollIntervalSeconds { get; init; } = 300; + + /// + /// Whether the results watch-list poller is enabled. + /// Default: false — the poller infrastructure ships in Phase 4 but + /// the formal watch-list implementation lands in Phase 8. + /// Flip to true only after Phase 8 is complete. + /// + public bool ResultsPollerEnabled { get; init; } = false; +} diff --git a/src/Marathon.Infrastructure/InfrastructureModule.cs b/src/Marathon.Infrastructure/InfrastructureModule.cs new file mode 100644 index 0000000..9015bb6 --- /dev/null +++ b/src/Marathon.Infrastructure/InfrastructureModule.cs @@ -0,0 +1,50 @@ +using Marathon.Infrastructure.Configuration; +using Marathon.Infrastructure.Persistence; +using Marathon.Infrastructure.Scraping; +using Marathon.Infrastructure.Workers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Marathon.Infrastructure; + +/// +/// Top-level DI composition entry-point for all Infrastructure sub-modules +/// (Persistence, Scraping, Workers). +/// +/// +/// Call once from the host's DI +/// setup. This replaces the previous reflection-based wiring in +/// App.xaml.cs::TryAddApplicationAndInfrastructure. +/// +public static class InfrastructureModule +{ + /// + /// Registers the complete Infrastructure layer: + /// + /// EF Core / SQLite persistence (). + /// HttpClient + AngleSharp + Polly scraping (). + /// bound to the Workers config section. + /// Three pollers. + /// + /// + public static IServiceCollection AddMarathonInfrastructure( + this IServiceCollection services, + IConfiguration config) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + + services.AddMarathonPersistence(config); + services.AddMarathonScraping(config); + + services + .AddOptions() + .Bind(config.GetSection(WorkerOptions.SectionName)); + + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj b/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj index cde417f..006c35b 100644 --- a/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj +++ b/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/Marathon.Infrastructure/Scraping/ScrapingModule.cs b/src/Marathon.Infrastructure/Scraping/ScrapingModule.cs index b28a47f..54c0d7b 100644 --- a/src/Marathon.Infrastructure/Scraping/ScrapingModule.cs +++ b/src/Marathon.Infrastructure/Scraping/ScrapingModule.cs @@ -109,9 +109,12 @@ public static class ScrapingModule // ── Parsers (stateless — safe as singletons) ────────────────────── services.AddSingleton(); - services.AddSingleton(_ => - // TODO (Phase 4): bind BasketballQuarterMode from Sports:Basketball:QuarterMode config. - new PeriodScopeMapper(basketballQuarterMode: false)); + services.AddSingleton(sp => + { + // Bind Sports:Basketball:QuarterMode from configuration (Phase 4). + var basketballQuarterMode = config.GetValue("Sports:Basketball:QuarterMode"); + return new PeriodScopeMapper(basketballQuarterMode); + }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs b/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs new file mode 100644 index 0000000..55a19f5 --- /dev/null +++ b/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs @@ -0,0 +1,84 @@ +using Marathon.Application.UseCases; +using Marathon.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Marathon.Infrastructure.Workers; + +/// +/// Continuously polls live odds on a fixed interval controlled by +/// (default: 30 s). +/// +/// +/// Registered as a (singleton lifetime). +/// Use cases are resolved in a fresh per cycle so +/// that EF Core's scoped DbContext is correctly managed. +/// +internal sealed class LiveOddsPoller : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly IOptionsMonitor _opts; + private readonly ILogger _logger; + + public LiveOddsPoller( + IServiceProvider services, + IOptionsMonitor opts, + ILogger logger) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _opts = opts ?? throw new ArgumentNullException(nameof(opts)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("LiveOddsPoller: started"); + + while (!stoppingToken.IsCancellationRequested) + { + var options = _opts.CurrentValue; + + if (!options.LivePollerEnabled) + { + _logger.LogDebug("LiveOddsPoller: disabled — sleeping 10s before re-check"); + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + continue; + } + + try + { + await using var scope = _services.CreateAsyncScope(); + var useCase = scope.ServiceProvider.GetRequiredService(); + var snapshotsCaptured = await useCase.ExecuteAsync(stoppingToken); + + _logger.LogInformation( + "LiveOddsPoller: cycle complete — snapshots={Snapshots}", + snapshotsCaptured); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "LiveOddsPoller: unhandled exception during cycle — will retry after interval"); + } + + var interval = TimeSpan.FromSeconds( + Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds)); + + try + { + await Task.Delay(interval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + + _logger.LogInformation("LiveOddsPoller: stopping"); + } +} diff --git a/src/Marathon.Infrastructure/Workers/ResultsWatchListPoller.cs b/src/Marathon.Infrastructure/Workers/ResultsWatchListPoller.cs new file mode 100644 index 0000000..b58f5d7 --- /dev/null +++ b/src/Marathon.Infrastructure/Workers/ResultsWatchListPoller.cs @@ -0,0 +1,109 @@ +using Marathon.Application.Storage; +using Marathon.Application.UseCases; +using Marathon.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Marathon.Infrastructure.Workers; + +/// +/// Polls for completed event results on a fixed interval. +/// +/// +/// +/// Phase 4 scaffold — disabled by default. +/// defaults to false. +/// The formal watch-list implementation (per-event polling until +/// matchIsComplete=true) lands in Phase 8. +/// Enable only after Phase 8 is complete. +/// +/// +/// When enabled, this poller calls +/// with selection: null and a date-range covering events scheduled in +/// the previous 24 hours that lack a result row. +/// +/// +internal sealed class ResultsWatchListPoller : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly IOptionsMonitor _opts; + private readonly ILogger _logger; + + public ResultsWatchListPoller( + IServiceProvider services, + IOptionsMonitor opts, + ILogger logger) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _opts = opts ?? throw new ArgumentNullException(nameof(opts)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("ResultsWatchListPoller: started (disabled={Disabled})", + !_opts.CurrentValue.ResultsPollerEnabled); + + while (!stoppingToken.IsCancellationRequested) + { + var options = _opts.CurrentValue; + + if (!options.ResultsPollerEnabled) + { + // Poller is disabled — sleep longer to avoid busy-waiting. + _logger.LogDebug("ResultsWatchListPoller: disabled — sleeping 60s before re-check"); + try + { + await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + continue; + } + + try + { + var now = DateTimeOffset.UtcNow; + // Inspect events scheduled in the last 24 hours (may still be running or just finished). + var range = new DateRange(now.AddHours(-24), now); + + await using var scope = _services.CreateAsyncScope(); + var useCase = scope.ServiceProvider.GetRequiredService(); + var (inspected, loaded, skipped) = await useCase.ExecuteAsync( + range, + selection: null, + stoppingToken); + + _logger.LogInformation( + "ResultsWatchListPoller: cycle complete — inspected={Inspected}, loaded={Loaded}, skipped={Skipped}", + inspected, loaded, skipped); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "ResultsWatchListPoller: unhandled exception during cycle — will retry after interval"); + } + + var interval = TimeSpan.FromSeconds( + Math.Max(60, _opts.CurrentValue.ResultsPollIntervalSeconds)); + + try + { + await Task.Delay(interval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + + _logger.LogInformation("ResultsWatchListPoller: stopping"); + } +} diff --git a/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs b/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs new file mode 100644 index 0000000..f980051 --- /dev/null +++ b/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs @@ -0,0 +1,119 @@ +using Cronos; +using Marathon.Application.UseCases; +using Marathon.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Marathon.Infrastructure.Workers; + +/// +/// Fires on a cron schedule driven by +/// (default: every 6 hours). +/// +/// +/// Registered as a (singleton lifetime). +/// Use cases are resolved in a fresh per cycle so +/// that EF Core's scoped DbContext is correctly managed. +/// +internal sealed class UpcomingEventsPoller : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly IOptionsMonitor _opts; + private readonly ILogger _logger; + + public UpcomingEventsPoller( + IServiceProvider services, + IOptionsMonitor opts, + ILogger logger) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _opts = opts ?? throw new ArgumentNullException(nameof(opts)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("UpcomingEventsPoller: started"); + + while (!stoppingToken.IsCancellationRequested) + { + var options = _opts.CurrentValue; + + if (!options.UpcomingPollerEnabled) + { + _logger.LogDebug("UpcomingEventsPoller: disabled — sleeping 60s before re-check"); + await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + continue; + } + + var delay = ComputeDelayToNextFire(options.UpcomingScheduleCron); + if (delay > TimeSpan.Zero) + { + _logger.LogInformation( + "UpcomingEventsPoller: next fire in {Delay:g}", + delay); + try + { + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + + if (stoppingToken.IsCancellationRequested) + break; + + // Re-read options after the delay (may have changed via settings page). + if (!_opts.CurrentValue.UpcomingPollerEnabled) + continue; + + try + { + await using var scope = _services.CreateAsyncScope(); + var useCase = scope.ServiceProvider.GetRequiredService(); + var (processed, newEvts, snapshots) = await useCase.ExecuteAsync(stoppingToken); + + _logger.LogInformation( + "UpcomingEventsPoller: cycle complete — processed={Processed}, new={New}, snapshots={Snapshots}", + processed, newEvts, snapshots); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "UpcomingEventsPoller: unhandled exception during cycle — will retry at next scheduled fire"); + } + } + + _logger.LogInformation("UpcomingEventsPoller: stopping"); + } + + private static TimeSpan ComputeDelayToNextFire(string cronExpression) + { + try + { + // Cronos supports 6-field expressions (with seconds) when the format is specified. + var expression = CronExpression.Parse( + cronExpression, + CronFormat.IncludeSeconds); + + var now = DateTimeOffset.UtcNow; + var next = expression.GetNextOccurrence(now.UtcDateTime, inclusive: false); + + return next.HasValue + ? next.Value - now.UtcDateTime + : TimeSpan.FromHours(6); // fallback if expression yields no next occurrence + } + catch (Exception) + { + // Malformed cron expression — fall back to 6-hour default. + return TimeSpan.FromHours(6); + } + } +} diff --git a/src/Marathon.UI/Pages/Settings.razor b/src/Marathon.UI/Pages/Settings.razor index 2fe4424..3619371 100644 --- a/src/Marathon.UI/Pages/Settings.razor +++ b/src/Marathon.UI/Pages/Settings.razor @@ -89,6 +89,15 @@ + + + + + + + + + @@ -182,9 +191,12 @@ _workers = new WorkerOptions { - UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron, - LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled, - UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled, + UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron, + LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled, + UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled, + LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds, + ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled, + ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds, }; _storage = new StorageOptions diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 5021bd7..032b79e 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -114,6 +114,11 @@ Standard cron. Defaults to every 5 minutes. Live poller enabled Schedule poller enabled + Live poll interval (sec) + Delay between live-odds polling cycles. Default 30 s. + Results poller enabled + Disabled until Phase 8. Enable only after match-complete polling is implemented. + Results poll interval (sec) SQLite path Export directory diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 8733a32..9b3ffbd 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -120,6 +120,11 @@ Стандартный cron. По умолчанию каждые 5 минут. Лайв-сборщик включён Сборщик расписания включён + Интервал лайв-опроса (сек) + Пауза между циклами сбора лайв-котировок. По умолчанию 30 с. + Сборщик результатов включён + Отключён до Phase 8. Включите только после реализации опроса match-complete. + Интервал сборщика результатов (сек) Путь к SQLite diff --git a/src/Marathon.UI/Services/WorkerOptions.cs b/src/Marathon.UI/Services/WorkerOptions.cs index 5b0839b..e61c96c 100644 --- a/src/Marathon.UI/Services/WorkerOptions.cs +++ b/src/Marathon.UI/Services/WorkerOptions.cs @@ -16,4 +16,22 @@ public sealed class WorkerOptions /// Whether the upcoming/pre-match poller should run at startup. public bool UpcomingPollerEnabled { get; set; } = true; + + /// + /// How long the live odds poller sleeps between cycles, in seconds. + /// Default: 30 s. + /// + public int LivePollIntervalSeconds { get; set; } = 30; + + /// + /// Whether the results watch-list poller is enabled. + /// Default: false — disabled until Phase 8 is complete. + /// + public bool ResultsPollerEnabled { get; set; } = false; + + /// + /// How long the results watch-list poller sleeps between cycles, in seconds. + /// Default: 300 s (5 minutes). + /// + public int ResultsPollIntervalSeconds { get; set; } = 300; } diff --git a/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj b/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj index 588d170..5ed4b2b 100644 --- a/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj +++ b/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj @@ -12,6 +12,8 @@ + + @@ -20,6 +22,7 @@ + diff --git a/tests/Marathon.Application.Tests/UseCases/ExportToExcelUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/ExportToExcelUseCaseTests.cs new file mode 100644 index 0000000..db0bc2e --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/ExportToExcelUseCaseTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.Storage; +using Marathon.Application.UseCases; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Marathon.Application.Tests.UseCases; + +public sealed class ExportToExcelUseCaseTests +{ + private readonly IExcelExporter _exporter = Substitute.For(); + + private ExportToExcelUseCase CreateSut(string exportDir = "./exports") + { + var opts = Options.Create(new StorageOptions + { + ExportDirectory = exportDir, + DatabasePath = "./data/marathon.db", + }); + return new ExportToExcelUseCase(_exporter, opts, NullLogger.Instance); + } + + [Fact] + public async Task Should_InvokeExporterWithCorrectArguments_When_Executed() + { + // Arrange + var range = new DateRange( + new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 7, 23, 59, 59, TimeSpan.Zero)); + const ExportKind kind = ExportKind.Combined; + const string expectedOutputPath = "./exports/Marathon_2026-05-01_to_2026-05-07.xlsx"; + + _exporter + .ExportAsync(range, kind, Arg.Any(), Arg.Any()) + .Returns(expectedOutputPath); + + var sut = CreateSut(); + + // Act + var outputPath = await sut.ExecuteAsync(range, kind, CancellationToken.None); + + // Assert + outputPath.Should().Be(expectedOutputPath); + + await _exporter.Received(1).ExportAsync( + range, + kind, + Arg.Is(dir => dir.Contains("exports")), + Arg.Any()); + } + + [Fact] + public async Task Should_ReturnAbsoluteOutputPath_When_ExporterSucceeds() + { + // Arrange + var range = new DateRange(DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + const string absolutePath = @"C:\exports\Marathon_2026-05-01_to_2026-05-07.xlsx"; + + _exporter + .ExportAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(absolutePath); + + var sut = CreateSut(); + + // Act + var result = await sut.ExecuteAsync(range, ExportKind.PreMatch, CancellationToken.None); + + // Assert + result.Should().Be(absolutePath); + } + + [Fact] + public async Task Should_PropagateExporterException_When_ExportFails() + { + // Arrange + var range = new DateRange(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow); + + _exporter + .ExportAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("disk full")); + + var sut = CreateSut(); + + // Act + var act = async () => await sut.ExecuteAsync(range, ExportKind.Live, CancellationToken.None); + + // Assert — ExportToExcelUseCase does not swallow exporter exceptions; callers decide how to handle + await act.Should().ThrowAsync().WithMessage("disk full"); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs new file mode 100644 index 0000000..712f147 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +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 PullLiveOddsUseCaseTests +{ + private readonly IOddsScraper _scraper = Substitute.For(); + private readonly IEventRepository _eventRepo = Substitute.For(); + private readonly ISnapshotRepository _snapshotRepo = Substitute.For(); + + private PullLiveOddsUseCase CreateSut() => + new(_scraper, _eventRepo, _snapshotRepo, + NullLogger.Instance); + + [Fact] + public async Task Should_CaptureOneSnapshotPerEvent_When_TwoLiveEventsExistInDatabase() + { + // Arrange: 2 events in the database + var ev1 = TestFixtures.MakeEvent("11111111"); + var ev2 = TestFixtures.MakeEvent("22222222"); + var storedEvents = new List { ev1, ev2 }.AsReadOnly(); + + _eventRepo.ListAsync(Arg.Any()).Returns(storedEvents); + + // ScrapeUpcomingAsync is also called (by implementation) — return empty to keep test focused + _scraper.ScrapeUpcomingAsync(null, Arg.Any()) + .Returns(Array.Empty()); + + _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()) + .Returns(TestFixtures.MakeSnapshot(ev1.Id, OddsSource.Live)); + _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()) + .Returns(TestFixtures.MakeSnapshot(ev2.Id, OddsSource.Live)); + + var sut = CreateSut(); + + // Act + var snapshotsCaptured = await sut.ExecuteAsync(CancellationToken.None); + + // Assert + snapshotsCaptured.Should().Be(2); + + await _scraper.Received(1).ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()); + await _scraper.Received(1).ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()); + await _snapshotRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_ContinueAfterSnapshotFailure_And_NotPropagateException() + { + // Arrange: 2 events — scraping the first throws + var ev1 = TestFixtures.MakeEvent("11111111"); + var ev2 = TestFixtures.MakeEvent("22222222"); + var storedEvents = new List { ev1, ev2 }.AsReadOnly(); + + _eventRepo.ListAsync(Arg.Any()).Returns(storedEvents); + _scraper.ScrapeUpcomingAsync(null, Arg.Any()) + .Returns(Array.Empty()); + + _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()) + .ThrowsAsync(new HttpRequestException("timeout")); + _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()) + .Returns(TestFixtures.MakeSnapshot(ev2.Id, OddsSource.Live)); + + var sut = CreateSut(); + + // Act — must not throw + var act = async () => await sut.ExecuteAsync(CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + + var result = await sut.ExecuteAsync(CancellationToken.None); + result.Should().Be(1, "only ev2 succeeded; ev1 failed silently"); + } + + [Fact] + public async Task Should_ReturnZero_When_NoEventsInDatabase() + { + _eventRepo.ListAsync(Arg.Any()) + .Returns(Array.Empty()); + _scraper.ScrapeUpcomingAsync(null, Arg.Any()) + .Returns(Array.Empty()); + + var sut = CreateSut(); + + var result = await sut.ExecuteAsync(CancellationToken.None); + + result.Should().Be(0); + await _scraper.DidNotReceive() + .ScrapeEventOddsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs new file mode 100644 index 0000000..bb1cb0f --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs @@ -0,0 +1,151 @@ +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(); + private readonly IEventRepository _eventRepo = Substitute.For(); + private readonly IResultRepository _resultRepo = Substitute.For(); + + private static readonly DateRange AnyRange = new( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow); + + private PullResultsUseCase CreateSut() => + new(_scraper, _eventRepo, _resultRepo, + NullLogger.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()).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 returns no results (Phase 3 no-op) + _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + var selection = new List { 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()); + } + + [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 { ev1, ev2, ev3 }.AsReadOnly(); + + _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) + .Returns(allEvents); + + _resultRepo.GetAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); + + _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + 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()); + } + + [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 { ev1, ev2 }.AsReadOnly(); + + _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) + .Returns(allEvents); + + _resultRepo.GetAsync(ev1.Id, Arg.Any()) + .Returns(TestFixtures.MakeResult(ev1.Id)); // ev1 already has result + _resultRepo.GetAsync(ev2.Id, Arg.Any()) + .Returns((EventResult?)null); + + _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + 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(), Arg.Any()); + } + + [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 { ev1 }.AsReadOnly(); + + _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) + .Returns(allEvents); + _resultRepo.GetAsync(ev1.Id, Arg.Any()) + .Returns((EventResult?)null); + _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) + .Returns(new List { 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()); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs new file mode 100644 index 0000000..26bb32c --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs @@ -0,0 +1,124 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +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 PullUpcomingEventsUseCaseTests +{ + private readonly IOddsScraper _scraper = Substitute.For(); + private readonly IEventRepository _eventRepo = Substitute.For(); + private readonly ISnapshotRepository _snapshotRepo = Substitute.For(); + + private PullUpcomingEventsUseCase CreateSut() => + new(_scraper, _eventRepo, _snapshotRepo, + NullLogger.Instance); + + [Fact] + public async Task Should_PersistNewEventsAndCaptureSnapshots_When_ScraperReturnsEvents() + { + // Arrange: scraper returns 2 events, neither exists in DB + var ev1 = TestFixtures.MakeEvent("11111111"); + var ev2 = TestFixtures.MakeEvent("22222222"); + var events = new List { ev1, ev2 }.AsReadOnly(); + + _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); + _eventRepo.GetAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); + _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) + .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg())); + + var sut = CreateSut(); + + // Act + var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); + + // Assert + processed.Should().Be(2); + newEvents.Should().Be(2); + snapshots.Should().Be(2); + + await _eventRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); + await _snapshotRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_SkipExistingEvents_When_EventAlreadyInDatabase() + { + // Arrange: 3 events from scraper — 1 already in DB, 2 new + var ev1 = TestFixtures.MakeEvent("11111111"); // already in DB + var ev2 = TestFixtures.MakeEvent("22222222"); // new + var ev3 = TestFixtures.MakeEvent("33333333"); // new + var events = new List { ev1, ev2, ev3 }.AsReadOnly(); + + _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); + + // ev1 exists, ev2/ev3 do not + _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); + _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns((Event?)null); + _eventRepo.GetAsync(ev3.Id, Arg.Any()).Returns((Event?)null); + + _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) + .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg())); + + var sut = CreateSut(); + + // Act + var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); + + // Assert + processed.Should().Be(3); + newEvents.Should().Be(2, "ev1 was already in the database"); + snapshots.Should().Be(3, "snapshots are captured for all events regardless of duplicate status"); + + await _eventRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); + await _eventRepo.DidNotReceive().AddAsync(ev1, Arg.Any()); + } + + [Fact] + public async Task Should_ContinueProcessing_When_SnapshotCaptureFailsForOneEvent() + { + // Arrange: 2 events — snapshot for first throws, second succeeds + var ev1 = TestFixtures.MakeEvent("11111111"); + var ev2 = TestFixtures.MakeEvent("22222222"); + var events = new List { ev1, ev2 }.AsReadOnly(); + + _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); + _eventRepo.GetAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); + + _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.PreMatch, Arg.Any()) + .ThrowsAsync(new HttpRequestException("site down")); + _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.PreMatch, Arg.Any()) + .Returns(TestFixtures.MakeSnapshot(ev2.Id)); + + var sut = CreateSut(); + + // Act — should not throw + var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); + + // Assert + processed.Should().Be(2); + newEvents.Should().Be(2); + snapshots.Should().Be(1, "only ev2 snapshot succeeded"); + } + + [Fact] + public async Task Should_ReturnZeros_When_ScraperReturnsNoEvents() + { + _scraper.ScrapeUpcomingAsync(null, Arg.Any()) + .Returns(Array.Empty()); + + var sut = CreateSut(); + + var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None); + + processed.Should().Be(0); + newEvents.Should().Be(0); + snapshots.Should().Be(0); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs b/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs new file mode 100644 index 0000000..3901942 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs @@ -0,0 +1,45 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Application.Tests.UseCases; + +/// +/// Shared factory helpers for domain objects used across use-case tests. +/// +internal static class TestFixtures +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + + /// Creates a minimal valid with the given event ID string. + public static Event MakeEvent(string eventIdValue = "12345678") + { + return new Event( + Id: new EventId(eventIdValue), + Sport: new SportCode(6), + CountryCode: "BY", + LeagueId: "league-1", + Category: "Group A", + ScheduledAt: new DateTimeOffset(2026, 5, 10, 18, 0, 0, MoscowOffset), + Side1Name: "Team A", + Side2Name: "Team B"); + } + + /// Creates a minimal valid for the given event. + public static OddsSnapshot MakeSnapshot(EventId eventId, OddsSource source = OddsSource.PreMatch) + { + var bets = new List + { + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, value: null, new OddsRate(1.85m)), + new Bet(MatchScope.Instance, BetType.Win, Side.Side2, value: null, new OddsRate(2.10m)), + }; + + return new OddsSnapshot(eventId, DateTimeOffset.UtcNow, source, bets); + } + + /// Creates a minimal valid for the given event ID. + public static EventResult MakeResult(EventId eventId) + { + return new EventResult(eventId, 2, 1, Side.Side1, DateTimeOffset.UtcNow); + } +} diff --git a/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs b/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs new file mode 100644 index 0000000..8efceb6 --- /dev/null +++ b/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.UseCases; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Marathon.Infrastructure.Configuration; +using Marathon.Infrastructure.Workers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Marathon.Infrastructure.Tests.Workers; + +/// +/// Tests for . +/// Uses real backed by NSubstitute interfaces, +/// so the poller's DI scope resolution is exercised end-to-end. +/// +public sealed class LiveOddsPollerTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + + private static OddsSnapshot MakeSnapshot(EventId eventId) => + new(eventId, DateTimeOffset.UtcNow, OddsSource.Live, new List + { + new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.80m)), + }); + + /// + /// Builds a DI ServiceProvider wiring a real + /// against the provided interface substitutes. + /// + private static IServiceProvider BuildServiceProvider( + IOddsScraper scraper, + IEventRepository eventRepo, + ISnapshotRepository snapshotRepo) + { + var services = new ServiceCollection(); + services.AddScoped(_ => scraper); + services.AddScoped(_ => eventRepo); + services.AddScoped(_ => snapshotRepo); + services.AddScoped(sp => + new PullLiveOddsUseCase( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + NullLogger.Instance)); + + return services.BuildServiceProvider(); + } + + private static IOptionsMonitor BuildOptions( + bool enabled = true, + int intervalSeconds = 0) + { + var opts = new WorkerOptions + { + LivePollerEnabled = enabled, + LivePollIntervalSeconds = intervalSeconds, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(opts); + return monitor; + } + + [Fact] + public async Task Should_InvokeUseCase_When_PollerIsEnabled() + { + // Arrange — scraper returns 1 event; snapshot succeeds + var eventId = new EventId("10000001"); + var ev = new Event(eventId, new SportCode(6), "BY", "league-1", "Group", + new DateTimeOffset(2026, 5, 10, 18, 0, 0, MoscowOffset), "A", "B"); + + var scraper = Substitute.For(); + var eventRepo = Substitute.For(); + var snapshotRepo = Substitute.For(); + + // ScrapeUpcomingAsync called by use case internally + scraper.ScrapeUpcomingAsync(null, Arg.Any()) + .Returns(Array.Empty()); + eventRepo.ListAsync(Arg.Any()) + .Returns(new List { ev }.AsReadOnly()); + scraper.ScrapeEventOddsAsync(eventId, OddsSource.Live, Arg.Any()) + .Returns(MakeSnapshot(eventId)); + + var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); + var opts = BuildOptions(enabled: true, intervalSeconds: 0); + var poller = new LiveOddsPoller(sp, opts, NullLogger.Instance); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + // Act + await poller.StartAsync(cts.Token); + await Task.Delay(300); // allow at least one cycle + await poller.StopAsync(CancellationToken.None); + + // Assert — snapshot was added at least once + await snapshotRepo.Received().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_NotInvokeUseCase_When_PollerIsDisabled() + { + // Arrange + var scraper = Substitute.For(); + var eventRepo = Substitute.For(); + var snapshotRepo = Substitute.For(); + + var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); + var opts = BuildOptions(enabled: false, intervalSeconds: 0); + var poller = new LiveOddsPoller(sp, opts, NullLogger.Instance); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + // Act + await poller.StartAsync(cts.Token); + await Task.Delay(300); + await poller.StopAsync(CancellationToken.None); + + // Assert — no snapshot attempts while disabled + await snapshotRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await eventRepo.DidNotReceive().ListAsync(Arg.Any()); + } + + [Fact] + public async Task Should_ContinueRunning_When_UseCaseThrowsException() + { + // Arrange — ListAsync always throws; poller must survive + var scraper = Substitute.For(); + var eventRepo = Substitute.For(); + var snapshotRepo = Substitute.For(); + + scraper.ScrapeUpcomingAsync(null, Arg.Any()) + .Returns(Array.Empty()); + eventRepo.ListAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB unavailable")); + + var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); + var opts = BuildOptions(enabled: true, intervalSeconds: 0); + var poller = new LiveOddsPoller(sp, opts, NullLogger.Instance); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + // Act — start, let failures occur, stop cleanly + await poller.StartAsync(cts.Token); + await Task.Delay(400); + + var stopAct = async () => await poller.StopAsync(CancellationToken.None); + + // Assert — StopAsync must not propagate the exception + await stopAct.Should().NotThrowAsync(); + + // DB was hit multiple times (poller didn't give up after first failure) + await eventRepo.Received().ListAsync(Arg.Any()); + } +}