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:
@@ -110,6 +110,28 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
|
|||||||
- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the
|
- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the
|
||||||
same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`).
|
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
|
## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings
|
||||||
|
|
||||||
(Permanent learnings about marathonbet.by data shape, anti-bot, page structure.
|
(Permanent learnings about marathonbet.by data shape, anti-bot, page structure.
|
||||||
|
|||||||
@@ -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 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 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 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 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 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 |
|
| Phase 7 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus |
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ parameter configurable.
|
|||||||
| Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea |
|
| 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 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 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 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 6: Event browsing UI | frontend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 4: Application Layer + Background Workers
|
# Phase 4: Application Layer + Background Workers
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Done
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** backend
|
**Domain:** backend
|
||||||
**Depends on:** Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping)
|
**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
|
## Tasks
|
||||||
|
|
||||||
- [ ] Implement use cases in `Marathon.Application/UseCases/`:
|
- [x] Implement use cases in `Marathon.Application/UseCases/`:
|
||||||
- `PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)`
|
- `PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)`
|
||||||
- `ExecuteAsync(CancellationToken)` → fetch upcoming events, persist new ones,
|
- `ExecuteAsync(CancellationToken)` → fetch upcoming events, persist new ones,
|
||||||
capture initial pre-match snapshots for each
|
capture initial pre-match snapshots for each
|
||||||
@@ -22,39 +22,38 @@ and background services that execute pollers on configurable intervals.
|
|||||||
- `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)`
|
- `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)`
|
||||||
- `ExecuteAsync(DateRange range, IReadOnlyList<EventId>? selection, CancellationToken)`
|
- `ExecuteAsync(DateRange range, IReadOnlyList<EventId>? selection, CancellationToken)`
|
||||||
→ fetch results for completed events (all or selected)
|
→ fetch results for completed events (all or selected)
|
||||||
- `ExportToExcelUseCase(IExcelExporter, IEventRepository)`
|
- `ExportToExcelUseCase(IExcelExporter, IOptions<StorageOptions>, ILogger)`
|
||||||
- `ExecuteAsync(DateRange, ExportKind, CancellationToken)`
|
- `ExecuteAsync(DateRange, ExportKind, CancellationToken)`
|
||||||
- [ ] Implement background services in `Marathon.Infrastructure/Workers/`:
|
- [x] Implement background services in `Marathon.Infrastructure/Workers/`:
|
||||||
- `UpcomingEventsPoller : BackgroundService` — runs `PullUpcomingEventsUseCase` on
|
- `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
|
- `LiveOddsPoller : BackgroundService` — runs `PullLiveOddsUseCase` every
|
||||||
`Scraping:PollingIntervalSeconds` seconds
|
`WorkerOptions.LivePollIntervalSeconds` seconds (default 30 s)
|
||||||
- Both honor `CancellationToken`, log via `ILogger<T>`, and skip cycles gracefully
|
- `ResultsWatchListPoller : BackgroundService` — scaffold disabled by default
|
||||||
on errors (don't crash the host)
|
(`WorkerOptions.ResultsPollerEnabled = false`); formal impl in Phase 8
|
||||||
- [ ] Add `WorkerOptions` POCO bound to `Workers:*` config:
|
- All honor `CancellationToken`, log via `ILogger<T>`, skip cycles gracefully on errors
|
||||||
```csharp
|
- [x] Add `WorkerOptions` POCO bound to `Workers:*` config
|
||||||
public sealed class WorkerOptions {
|
(in `Marathon.Infrastructure.Configuration`; UI mirror in `Marathon.UI.Services`):
|
||||||
public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *"; // every 6h
|
`UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`,
|
||||||
public bool LivePollerEnabled { get; init; } = true;
|
`LivePollIntervalSeconds`, `ResultsPollerEnabled`, `ResultsPollIntervalSeconds`
|
||||||
public bool UpcomingPollerEnabled { get; init; } = true;
|
- [x] Add `ApplicationModule.AddMarathonApplication(IServiceCollection)` in
|
||||||
}
|
`Marathon.Application/ApplicationModule.cs` — no `IConfiguration` needed
|
||||||
```
|
- [x] Add `InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration)`
|
||||||
Use `Cronos` package or simple TimeSpan for upcoming schedule.
|
in `Marathon.Infrastructure/InfrastructureModule.cs` — composes Persistence + Scraping + Workers
|
||||||
- [ ] Add DI extension `AddMarathonApplication(IServiceCollection, IConfiguration)`
|
- [x] Replace reflection wiring in `App.xaml.cs` with direct `AddMarathonApplication()` +
|
||||||
in `Marathon.Application/DependencyInjection.cs`:
|
`AddMarathonInfrastructure(config)` calls; removed `TryAddApplicationAndInfrastructure`
|
||||||
- Registers all use cases
|
and `TryInvokeExtension` helpers
|
||||||
- [ ] Update `Marathon.Infrastructure/DependencyInjection.cs` to also register
|
- [x] Bind `Sports:Basketball:QuarterMode` from config in `ScrapingModule` (Phase 3 TODO resolved)
|
||||||
`BackgroundService`s under `services.AddHostedService<T>()`.
|
- [x] Add new `Workers` keys to `appsettings.json` + `SharedResource.*.resx` + `Settings.razor`
|
||||||
- [ ] Tests in `Marathon.Application.Tests`:
|
- [x] Tests in `Marathon.Application.Tests/UseCases/`:
|
||||||
- Mock `IOddsScraper` + repos with NSubstitute
|
- Mock `IOddsScraper` + repos with NSubstitute
|
||||||
- Test: `PullUpcomingEventsUseCase` persists new events, skips duplicates
|
- `PullUpcomingEventsUseCaseTests`: persists new events, skips duplicates, tolerates snapshot failures
|
||||||
- Test: `PullLiveOddsUseCase` writes a snapshot per live event
|
- `PullLiveOddsUseCaseTests`: one snapshot per live event, survives per-event errors
|
||||||
- Test: `PullResultsUseCase` respects `selection` filter (when null, fetches all)
|
- `PullResultsUseCaseTests`: selection filter, null=all-in-range, idempotency, persists scraped results
|
||||||
- Test: `ExportToExcelUseCase` invokes `IExcelExporter.ExportAsync` with correct
|
- `ExportToExcelUseCaseTests`: delegates to exporter with correct args, propagates exporter exceptions
|
||||||
date range
|
- [x] Tests in `Marathon.Infrastructure.Tests/Workers/`:
|
||||||
- [ ] Tests in `Marathon.Infrastructure.Tests/Workers/`:
|
- `LiveOddsPollerTests`: happy-path invokes use case; disabled flag skips use case;
|
||||||
- Test: `LiveOddsPoller` invokes use case at configured interval (use FakeTimeProvider)
|
exception-swallowing (continues running after use-case error)
|
||||||
- Test: poller continues after a use-case exception (logs, doesn't propagate)
|
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
|
|
||||||
@@ -83,12 +82,101 @@ and background services that execute pollers on configurable intervals.
|
|||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
- [ ] Use cases have no Infrastructure dependencies
|
- [x] Use cases have no Infrastructure dependencies
|
||||||
- [ ] Both pollers configurable (interval, enable/disable)
|
- [x] All three pollers configurable (interval, enable/disable)
|
||||||
- [ ] Cancellation propagated correctly
|
- [x] Cancellation propagated correctly (OperationCanceledException re-thrown, breaks loop)
|
||||||
- [ ] Errors logged, not propagated out of `ExecuteAsync`
|
- [x] Errors logged, not propagated out of `ExecuteAsync`
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
|
|
||||||
<!-- Filled by Phase 4 implementer. Phase 5 needs to know how to start the host
|
### For Phase 6 (Event Browsing UI)
|
||||||
including these BackgroundServices. -->
|
|
||||||
|
#### 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<DomainEventId>?, 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<Marathon.UI.Services.WorkerOptions>` (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**.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Marathon.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration helpers for the Marathon.Application layer.
|
||||||
|
/// Call <see cref="AddMarathonApplication"/> from the composition root (host or
|
||||||
|
/// <c>InfrastructureModule</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static class ApplicationModule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers all Application-layer use cases with <c>Scoped</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// No <see cref="Microsoft.Extensions.Configuration.IConfiguration"/> is
|
||||||
|
/// required here — the Application layer has no direct configuration bindings.
|
||||||
|
/// Infrastructure and UI layers bind their own options against the shared
|
||||||
|
/// JSON sections.
|
||||||
|
/// </remarks>
|
||||||
|
public static IServiceCollection AddMarathonApplication(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddScoped<PullUpcomingEventsUseCase>();
|
||||||
|
services.AddScoped<PullLiveOddsUseCase>();
|
||||||
|
services.AddScoped<PullResultsUseCase>();
|
||||||
|
services.AddScoped<ExportToExcelUseCase>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using Marathon.Application;
|
||||||
|
using Marathon.Infrastructure;
|
||||||
using Marathon.UI.Services;
|
using Marathon.UI.Services;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -73,9 +75,9 @@ public partial class App : System.Windows.Application
|
|||||||
builder.Services.AddBlazorWebViewDeveloperTools();
|
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Compose Application + Infrastructure modules if they exist. Parallel
|
// Application use cases + Infrastructure (persistence, scraping, workers).
|
||||||
// Phase 2/3/4 work may still be merging these; we degrade gracefully.
|
builder.Services.AddMarathonApplication();
|
||||||
TryAddApplicationAndInfrastructure(builder.Services, builder.Configuration);
|
builder.Services.AddMarathonInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
|
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
|
||||||
builder.Services.AddSingleton<MainWindow>();
|
builder.Services.AddSingleton<MainWindow>();
|
||||||
@@ -104,50 +106,6 @@ public partial class App : System.Windows.Application
|
|||||||
? level
|
? level
|
||||||
: Serilog.Events.LogEventLevel.Information;
|
: Serilog.Events.LogEventLevel.Information;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Best-effort wiring of the Application + Infrastructure DI modules.
|
|
||||||
/// TODO(phase-4): the orchestrator will land a single
|
|
||||||
/// <c>AddMarathonInfrastructure(config)</c> entry point. Until then we use
|
|
||||||
/// reflection to call whichever extension methods exist so partial merges
|
|
||||||
/// don't break compilation of this host.
|
|
||||||
/// </summary>
|
|
||||||
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)
|
protected override void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -19,9 +19,17 @@
|
|||||||
"RequestTimeoutSeconds": 30
|
"RequestTimeoutSeconds": 30
|
||||||
},
|
},
|
||||||
"Workers": {
|
"Workers": {
|
||||||
"UpcomingScheduleCron": "0 */5 * * * *",
|
"UpcomingScheduleCron": "0 0 */6 * * *",
|
||||||
"LivePollerEnabled": true,
|
"LivePollerEnabled": true,
|
||||||
"UpcomingPollerEnabled": true
|
"UpcomingPollerEnabled": true,
|
||||||
|
"LivePollIntervalSeconds": 30,
|
||||||
|
"ResultsPollIntervalSeconds": 300,
|
||||||
|
"ResultsPollerEnabled": false
|
||||||
|
},
|
||||||
|
"Sports": {
|
||||||
|
"Basketball": {
|
||||||
|
"QuarterMode": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
"DatabasePath": "./data/marathon.db",
|
"DatabasePath": "./data/marathon.db",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace Marathon.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly typed options for the background worker pollers.
|
||||||
|
/// Bound from the <c>Workers</c> section of <c>appsettings.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkerOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Workers";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cron expression (6-field with seconds: s m h d M dow) controlling when the
|
||||||
|
/// upcoming-events poller fires. Default: every 6 hours.
|
||||||
|
/// </summary>
|
||||||
|
public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *";
|
||||||
|
|
||||||
|
/// <summary>Whether the live odds poller should run at startup.</summary>
|
||||||
|
public bool LivePollerEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
|
||||||
|
public bool UpcomingPollerEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the live odds poller sleeps between polling cycles, in seconds.
|
||||||
|
/// Default: 30 s (matches <c>Scraping:PollingIntervalSeconds</c> but is
|
||||||
|
/// independently configurable here).
|
||||||
|
/// </summary>
|
||||||
|
public int LivePollIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the results watch-list poller sleeps between cycles, in seconds.
|
||||||
|
/// Default: 300 s (5 minutes).
|
||||||
|
/// </summary>
|
||||||
|
public int ResultsPollIntervalSeconds { get; init; } = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the results watch-list poller is enabled.
|
||||||
|
/// Default: <c>false</c> — the poller infrastructure ships in Phase 4 but
|
||||||
|
/// the formal watch-list implementation lands in Phase 8.
|
||||||
|
/// Flip to <c>true</c> only after Phase 8 is complete.
|
||||||
|
/// </summary>
|
||||||
|
public bool ResultsPollerEnabled { get; init; } = false;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level DI composition entry-point for all Infrastructure sub-modules
|
||||||
|
/// (Persistence, Scraping, Workers).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Call <see cref="AddMarathonInfrastructure"/> once from the host's DI
|
||||||
|
/// setup. This replaces the previous reflection-based wiring in
|
||||||
|
/// <c>App.xaml.cs::TryAddApplicationAndInfrastructure</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class InfrastructureModule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the complete Infrastructure layer:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>EF Core / SQLite persistence (<see cref="PersistenceModule.AddMarathonPersistence"/>).</item>
|
||||||
|
/// <item>HttpClient + AngleSharp + Polly scraping (<see cref="ScrapingModule.AddMarathonScraping"/>).</item>
|
||||||
|
/// <item><see cref="WorkerOptions"/> bound to the <c>Workers</c> config section.</item>
|
||||||
|
/// <item>Three <see cref="Microsoft.Extensions.Hosting.BackgroundService"/> pollers.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddMarathonInfrastructure(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration config)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
|
||||||
|
services.AddMarathonPersistence(config);
|
||||||
|
services.AddMarathonScraping(config);
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddOptions<WorkerOptions>()
|
||||||
|
.Bind(config.GetSection(WorkerOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddHostedService<UpcomingEventsPoller>();
|
||||||
|
services.AddHostedService<LiveOddsPoller>();
|
||||||
|
services.AddHostedService<ResultsWatchListPoller>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
|
<PackageReference Include="Cronos" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -109,9 +109,12 @@ public static class ScrapingModule
|
|||||||
|
|
||||||
// ── Parsers (stateless — safe as singletons) ──────────────────────
|
// ── Parsers (stateless — safe as singletons) ──────────────────────
|
||||||
services.AddSingleton<IServerTimeProvider, ServerTimeProvider>();
|
services.AddSingleton<IServerTimeProvider, ServerTimeProvider>();
|
||||||
services.AddSingleton(_ =>
|
services.AddSingleton(sp =>
|
||||||
// TODO (Phase 4): bind BasketballQuarterMode from Sports:Basketball:QuarterMode config.
|
{
|
||||||
new PeriodScopeMapper(basketballQuarterMode: false));
|
// Bind Sports:Basketball:QuarterMode from configuration (Phase 4).
|
||||||
|
var basketballQuarterMode = config.GetValue<bool>("Sports:Basketball:QuarterMode");
|
||||||
|
return new PeriodScopeMapper(basketballQuarterMode);
|
||||||
|
});
|
||||||
services.AddSingleton<IUpcomingEventsParser, UpcomingEventsParser>();
|
services.AddSingleton<IUpcomingEventsParser, UpcomingEventsParser>();
|
||||||
services.AddSingleton<ILiveEventsParser, LiveEventsParser>();
|
services.AddSingleton<ILiveEventsParser, LiveEventsParser>();
|
||||||
services.AddSingleton<IEventOddsParser, EventOddsParser>();
|
services.AddSingleton<IEventOddsParser, EventOddsParser>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Continuously polls live odds on a fixed interval controlled by
|
||||||
|
/// <see cref="WorkerOptions.LivePollIntervalSeconds"/> (default: 30 s).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Registered as a <see cref="BackgroundService"/> (singleton lifetime).
|
||||||
|
/// Use cases are resolved in a fresh <see cref="IServiceScope"/> per cycle so
|
||||||
|
/// that EF Core's scoped <c>DbContext</c> is correctly managed.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class LiveOddsPoller : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IOptionsMonitor<WorkerOptions> _opts;
|
||||||
|
private readonly ILogger<LiveOddsPoller> _logger;
|
||||||
|
|
||||||
|
public LiveOddsPoller(
|
||||||
|
IServiceProvider services,
|
||||||
|
IOptionsMonitor<WorkerOptions> opts,
|
||||||
|
ILogger<LiveOddsPoller> 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<PullLiveOddsUseCase>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls for completed event results on a fixed interval.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Phase 4 scaffold — disabled by default.</b>
|
||||||
|
/// <see cref="WorkerOptions.ResultsPollerEnabled"/> defaults to <c>false</c>.
|
||||||
|
/// The formal watch-list implementation (per-event polling until
|
||||||
|
/// <c>matchIsComplete=true</c>) lands in Phase 8.
|
||||||
|
/// Enable only after Phase 8 is complete.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// When enabled, this poller calls <see cref="PullResultsUseCase.ExecuteAsync"/>
|
||||||
|
/// with <c>selection: null</c> and a date-range covering events scheduled in
|
||||||
|
/// the previous 24 hours that lack a result row.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ResultsWatchListPoller : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IOptionsMonitor<WorkerOptions> _opts;
|
||||||
|
private readonly ILogger<ResultsWatchListPoller> _logger;
|
||||||
|
|
||||||
|
public ResultsWatchListPoller(
|
||||||
|
IServiceProvider services,
|
||||||
|
IOptionsMonitor<WorkerOptions> opts,
|
||||||
|
ILogger<ResultsWatchListPoller> 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<PullResultsUseCase>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires <see cref="PullUpcomingEventsUseCase"/> on a cron schedule driven by
|
||||||
|
/// <see cref="WorkerOptions.UpcomingScheduleCron"/> (default: every 6 hours).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Registered as a <see cref="BackgroundService"/> (singleton lifetime).
|
||||||
|
/// Use cases are resolved in a fresh <see cref="IServiceScope"/> per cycle so
|
||||||
|
/// that EF Core's scoped <c>DbContext</c> is correctly managed.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class UpcomingEventsPoller : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IOptionsMonitor<WorkerOptions> _opts;
|
||||||
|
private readonly ILogger<UpcomingEventsPoller> _logger;
|
||||||
|
|
||||||
|
public UpcomingEventsPoller(
|
||||||
|
IServiceProvider services,
|
||||||
|
IOptionsMonitor<WorkerOptions> opts,
|
||||||
|
ILogger<UpcomingEventsPoller> 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<PullUpcomingEventsUseCase>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,15 @@
|
|||||||
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
|
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
|
||||||
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
|
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field Label="@L["Settings.Workers.LivePollIntervalSeconds"]" Hint="@L["Settings.Workers.LivePollIntervalSeconds.Hint"]">
|
||||||
|
<MudNumericField T="int" @bind-Value="_workers.LivePollIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
||||||
|
</Field>
|
||||||
|
<Field Label="@L["Settings.Workers.ResultsPollerEnabled"]" Hint="@L["Settings.Workers.ResultsPollerEnabled.Hint"]">
|
||||||
|
<MudSwitch T="bool" @bind-Value="_workers.ResultsPollerEnabled" Color="Color.Primary" />
|
||||||
|
</Field>
|
||||||
|
<Field Label="@L["Settings.Workers.ResultsPollIntervalSeconds"]">
|
||||||
|
<MudNumericField T="int" @bind-Value="_workers.ResultsPollIntervalSeconds" Min="60" Max="7200" Variant="Variant.Outlined" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
|
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
|
||||||
</div>
|
</div>
|
||||||
@@ -182,9 +191,12 @@
|
|||||||
|
|
||||||
_workers = new WorkerOptions
|
_workers = new WorkerOptions
|
||||||
{
|
{
|
||||||
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
|
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
|
||||||
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
|
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
|
||||||
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
|
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
|
||||||
|
LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds,
|
||||||
|
ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled,
|
||||||
|
ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
_storage = new StorageOptions
|
_storage = new StorageOptions
|
||||||
|
|||||||
@@ -114,6 +114,11 @@
|
|||||||
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Standard cron. Defaults to every 5 minutes.</value></data>
|
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Standard cron. Defaults to every 5 minutes.</value></data>
|
||||||
<data name="Settings.Workers.LivePollerEnabled"><value>Live poller enabled</value></data>
|
<data name="Settings.Workers.LivePollerEnabled"><value>Live poller enabled</value></data>
|
||||||
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Schedule poller enabled</value></data>
|
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Schedule poller enabled</value></data>
|
||||||
|
<data name="Settings.Workers.LivePollIntervalSeconds"><value>Live poll interval (sec)</value></data>
|
||||||
|
<data name="Settings.Workers.LivePollIntervalSeconds.Hint"><value>Delay between live-odds polling cycles. Default 30 s.</value></data>
|
||||||
|
<data name="Settings.Workers.ResultsPollerEnabled"><value>Results poller enabled</value></data>
|
||||||
|
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Disabled until Phase 8. Enable only after match-complete polling is implemented.</value></data>
|
||||||
|
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Results poll interval (sec)</value></data>
|
||||||
|
|
||||||
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
|
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
|
||||||
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
|
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
|
||||||
|
|||||||
@@ -120,6 +120,11 @@
|
|||||||
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Стандартный cron. По умолчанию каждые 5 минут.</value></data>
|
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Стандартный cron. По умолчанию каждые 5 минут.</value></data>
|
||||||
<data name="Settings.Workers.LivePollerEnabled"><value>Лайв-сборщик включён</value></data>
|
<data name="Settings.Workers.LivePollerEnabled"><value>Лайв-сборщик включён</value></data>
|
||||||
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Сборщик расписания включён</value></data>
|
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Сборщик расписания включён</value></data>
|
||||||
|
<data name="Settings.Workers.LivePollIntervalSeconds"><value>Интервал лайв-опроса (сек)</value></data>
|
||||||
|
<data name="Settings.Workers.LivePollIntervalSeconds.Hint"><value>Пауза между циклами сбора лайв-котировок. По умолчанию 30 с.</value></data>
|
||||||
|
<data name="Settings.Workers.ResultsPollerEnabled"><value>Сборщик результатов включён</value></data>
|
||||||
|
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Отключён до Phase 8. Включите только после реализации опроса match-complete.</value></data>
|
||||||
|
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Интервал сборщика результатов (сек)</value></data>
|
||||||
|
|
||||||
<!-- Settings — Storage -->
|
<!-- Settings — Storage -->
|
||||||
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
|
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
|
||||||
|
|||||||
@@ -16,4 +16,22 @@ public sealed class WorkerOptions
|
|||||||
|
|
||||||
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
|
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
|
||||||
public bool UpcomingPollerEnabled { get; set; } = true;
|
public bool UpcomingPollerEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the live odds poller sleeps between cycles, in seconds.
|
||||||
|
/// Default: 30 s.
|
||||||
|
/// </summary>
|
||||||
|
public int LivePollIntervalSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the results watch-list poller is enabled.
|
||||||
|
/// Default: false — disabled until Phase 8 is complete.
|
||||||
|
/// </summary>
|
||||||
|
public bool ResultsPollerEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the results watch-list poller sleeps between cycles, in seconds.
|
||||||
|
/// Default: 300 s (5 minutes).
|
||||||
|
/// </summary>
|
||||||
|
public int ResultsPollIntervalSeconds { get; set; } = 300;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
<PackageReference Include="FluentAssertions" />
|
<PackageReference Include="FluentAssertions" />
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Marathon.Application\Marathon.Application.csproj" />
|
<ProjectReference Include="..\..\src\Marathon.Application\Marathon.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Marathon.Domain\Marathon.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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<IExcelExporter>();
|
||||||
|
|
||||||
|
private ExportToExcelUseCase CreateSut(string exportDir = "./exports")
|
||||||
|
{
|
||||||
|
var opts = Options.Create(new StorageOptions
|
||||||
|
{
|
||||||
|
ExportDirectory = exportDir,
|
||||||
|
DatabasePath = "./data/marathon.db",
|
||||||
|
});
|
||||||
|
return new ExportToExcelUseCase(_exporter, opts, NullLogger<ExportToExcelUseCase>.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<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<string>(dir => dir.Contains("exports")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<DateRange>(), Arg.Any<ExportKind>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<DateRange>(), Arg.Any<ExportKind>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<InvalidOperationException>().WithMessage("disk full");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IOddsScraper>();
|
||||||
|
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||||
|
private readonly ISnapshotRepository _snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||||
|
|
||||||
|
private PullLiveOddsUseCase CreateSut() =>
|
||||||
|
new(_scraper, _eventRepo, _snapshotRepo,
|
||||||
|
NullLogger<PullLiveOddsUseCase>.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<Event> { ev1, ev2 }.AsReadOnly();
|
||||||
|
|
||||||
|
_eventRepo.ListAsync(Arg.Any<CancellationToken>()).Returns(storedEvents);
|
||||||
|
|
||||||
|
// ScrapeUpcomingAsync is also called (by implementation) — return empty to keep test focused
|
||||||
|
_scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
|
||||||
|
_scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(TestFixtures.MakeSnapshot(ev1.Id, OddsSource.Live));
|
||||||
|
_scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>());
|
||||||
|
await _scraper.Received(1).ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any<CancellationToken>());
|
||||||
|
await _snapshotRepo.Received(2).AddAsync(Arg.Any<OddsSnapshot>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<Event> { ev1, ev2 }.AsReadOnly();
|
||||||
|
|
||||||
|
_eventRepo.ListAsync(Arg.Any<CancellationToken>()).Returns(storedEvents);
|
||||||
|
_scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
|
||||||
|
_scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new HttpRequestException("timeout"));
|
||||||
|
_scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
_scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
var result = await sut.ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().Be(0);
|
||||||
|
await _scraper.DidNotReceive()
|
||||||
|
.ScrapeEventOddsAsync(Arg.Any<EventId>(), Arg.Any<OddsSource>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IOddsScraper>();
|
||||||
|
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||||
|
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
|
||||||
|
|
||||||
|
private static readonly DateRange AnyRange = new(
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
private PullResultsUseCase CreateSut() =>
|
||||||
|
new(_scraper, _eventRepo, _resultRepo,
|
||||||
|
NullLogger<PullResultsUseCase>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_InspectOnlySelectedEvents_When_SelectionIsProvided()
|
||||||
|
{
|
||||||
|
// Arrange: 3 events in DB; only 2 are in the selection
|
||||||
|
var ev1 = TestFixtures.MakeEvent("11111111");
|
||||||
|
var ev2 = TestFixtures.MakeEvent("22222222");
|
||||||
|
var ev3 = TestFixtures.MakeEvent("33333333"); // not selected
|
||||||
|
|
||||||
|
_eventRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>()).Returns(ev1);
|
||||||
|
_eventRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>()).Returns(ev2);
|
||||||
|
_eventRepo.GetAsync(ev3.Id, Arg.Any<CancellationToken>()).Returns(ev3);
|
||||||
|
|
||||||
|
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EventResult?)null);
|
||||||
|
|
||||||
|
// Scraper returns no results (Phase 3 no-op)
|
||||||
|
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<EventResult>());
|
||||||
|
|
||||||
|
var selection = new List<EventId> { ev1.Id, ev2.Id };
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert: only ev1 and ev2 inspected; ev3 not fetched via GetAsync lookup for range
|
||||||
|
inspected.Should().Be(2);
|
||||||
|
loaded.Should().Be(0, "scraper returns no results in Phase 3");
|
||||||
|
skipped.Should().Be(0);
|
||||||
|
|
||||||
|
// ev3 was never resolved
|
||||||
|
await _eventRepo.DidNotReceive().GetAsync(ev3.Id, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_InspectAllEventsInRange_When_SelectionIsNull()
|
||||||
|
{
|
||||||
|
// Arrange: 3 events returned by date-range query
|
||||||
|
var ev1 = TestFixtures.MakeEvent("11111111");
|
||||||
|
var ev2 = TestFixtures.MakeEvent("22222222");
|
||||||
|
var ev3 = TestFixtures.MakeEvent("33333333");
|
||||||
|
var allEvents = new List<Event> { ev1, ev2, ev3 }.AsReadOnly();
|
||||||
|
|
||||||
|
_eventRepo.ListByDateRangeAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(allEvents);
|
||||||
|
|
||||||
|
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EventResult?)null);
|
||||||
|
|
||||||
|
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<EventResult>());
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection: null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
inspected.Should().Be(3);
|
||||||
|
loaded.Should().Be(0);
|
||||||
|
skipped.Should().Be(0);
|
||||||
|
|
||||||
|
await _eventRepo.Received(1).ListByDateRangeAsync(AnyRange, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_SkipEventsWithExistingResult_And_BeIdempotent()
|
||||||
|
{
|
||||||
|
// Arrange: 2 events — ev1 already has a result stored
|
||||||
|
var ev1 = TestFixtures.MakeEvent("11111111");
|
||||||
|
var ev2 = TestFixtures.MakeEvent("22222222");
|
||||||
|
var allEvents = new List<Event> { ev1, ev2 }.AsReadOnly();
|
||||||
|
|
||||||
|
_eventRepo.ListByDateRangeAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(allEvents);
|
||||||
|
|
||||||
|
_resultRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(TestFixtures.MakeResult(ev1.Id)); // ev1 already has result
|
||||||
|
_resultRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EventResult?)null);
|
||||||
|
|
||||||
|
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<EventResult>());
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
// Act — run twice to verify idempotency
|
||||||
|
var (_, _, skipped1) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None);
|
||||||
|
var (_, _, skipped2) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
skipped1.Should().Be(1, "ev1 already has a result");
|
||||||
|
skipped2.Should().Be(1, "idempotent: ev1 still skipped on second run");
|
||||||
|
|
||||||
|
// No new results persisted
|
||||||
|
await _resultRepo.DidNotReceive().AddAsync(Arg.Any<EventResult>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_PersistResults_When_ScraperReturnsMatchingResults()
|
||||||
|
{
|
||||||
|
// Arrange: 1 event; scraper returns a result for it
|
||||||
|
var ev1 = TestFixtures.MakeEvent("11111111");
|
||||||
|
var result1 = TestFixtures.MakeResult(ev1.Id);
|
||||||
|
var allEvents = new List<Event> { ev1 }.AsReadOnly();
|
||||||
|
|
||||||
|
_eventRepo.ListByDateRangeAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(allEvents);
|
||||||
|
_resultRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EventResult?)null);
|
||||||
|
_scraper.ScrapeResultsAsync(Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<EventResult> { result1 }.AsReadOnly());
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
inspected.Should().Be(1);
|
||||||
|
loaded.Should().Be(1);
|
||||||
|
skipped.Should().Be(0);
|
||||||
|
|
||||||
|
await _resultRepo.Received(1).AddAsync(result1, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IOddsScraper>();
|
||||||
|
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||||
|
private readonly ISnapshotRepository _snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||||
|
|
||||||
|
private PullUpcomingEventsUseCase CreateSut() =>
|
||||||
|
new(_scraper, _eventRepo, _snapshotRepo,
|
||||||
|
NullLogger<PullUpcomingEventsUseCase>.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<Event> { ev1, ev2 }.AsReadOnly();
|
||||||
|
|
||||||
|
_scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>()).Returns(events);
|
||||||
|
_eventRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||||
|
_scraper.ScrapeEventOddsAsync(Arg.Any<EventId>(), OddsSource.PreMatch, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => TestFixtures.MakeSnapshot(ci.Arg<EventId>()));
|
||||||
|
|
||||||
|
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<Event>(), Arg.Any<CancellationToken>());
|
||||||
|
await _snapshotRepo.Received(2).AddAsync(Arg.Any<OddsSnapshot>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<Event> { ev1, ev2, ev3 }.AsReadOnly();
|
||||||
|
|
||||||
|
_scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>()).Returns(events);
|
||||||
|
|
||||||
|
// ev1 exists, ev2/ev3 do not
|
||||||
|
_eventRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>()).Returns(ev1);
|
||||||
|
_eventRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||||
|
_eventRepo.GetAsync(ev3.Id, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||||
|
|
||||||
|
_scraper.ScrapeEventOddsAsync(Arg.Any<EventId>(), OddsSource.PreMatch, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => TestFixtures.MakeSnapshot(ci.Arg<EventId>()));
|
||||||
|
|
||||||
|
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<Event>(), Arg.Any<CancellationToken>());
|
||||||
|
await _eventRepo.DidNotReceive().AddAsync(ev1, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<Event> { ev1, ev2 }.AsReadOnly();
|
||||||
|
|
||||||
|
_scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>()).Returns(events);
|
||||||
|
_eventRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||||
|
|
||||||
|
_scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.PreMatch, Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new HttpRequestException("site down"));
|
||||||
|
_scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.PreMatch, Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
var (processed, newEvents, snapshots) = await sut.ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
processed.Should().Be(0);
|
||||||
|
newEvents.Should().Be(0);
|
||||||
|
snapshots.Should().Be(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Tests.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared factory helpers for domain objects used across use-case tests.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TestFixtures
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
/// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a minimal valid <see cref="OddsSnapshot"/> for the given event.</summary>
|
||||||
|
public static OddsSnapshot MakeSnapshot(EventId eventId, OddsSource source = OddsSource.PreMatch)
|
||||||
|
{
|
||||||
|
var bets = new List<Bet>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a minimal valid <see cref="EventResult"/> for the given event ID.</summary>
|
||||||
|
public static EventResult MakeResult(EventId eventId)
|
||||||
|
{
|
||||||
|
return new EventResult(eventId, 2, 1, Side.Side1, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="LiveOddsPoller"/>.
|
||||||
|
/// Uses real <see cref="PullLiveOddsUseCase"/> backed by NSubstitute interfaces,
|
||||||
|
/// so the poller's DI scope resolution is exercised end-to-end.
|
||||||
|
/// </summary>
|
||||||
|
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<Bet>
|
||||||
|
{
|
||||||
|
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.80m)),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a DI ServiceProvider wiring a real <see cref="PullLiveOddsUseCase"/>
|
||||||
|
/// against the provided interface substitutes.
|
||||||
|
/// </summary>
|
||||||
|
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<IOddsScraper>(),
|
||||||
|
sp.GetRequiredService<IEventRepository>(),
|
||||||
|
sp.GetRequiredService<ISnapshotRepository>(),
|
||||||
|
NullLogger<PullLiveOddsUseCase>.Instance));
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IOptionsMonitor<WorkerOptions> BuildOptions(
|
||||||
|
bool enabled = true,
|
||||||
|
int intervalSeconds = 0)
|
||||||
|
{
|
||||||
|
var opts = new WorkerOptions
|
||||||
|
{
|
||||||
|
LivePollerEnabled = enabled,
|
||||||
|
LivePollIntervalSeconds = intervalSeconds,
|
||||||
|
};
|
||||||
|
var monitor = Substitute.For<IOptionsMonitor<WorkerOptions>>();
|
||||||
|
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<IOddsScraper>();
|
||||||
|
var eventRepo = Substitute.For<IEventRepository>();
|
||||||
|
var snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||||
|
|
||||||
|
// ScrapeUpcomingAsync called by use case internally
|
||||||
|
scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Event> { ev }.AsReadOnly());
|
||||||
|
scraper.ScrapeEventOddsAsync(eventId, OddsSource.Live, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(MakeSnapshot(eventId));
|
||||||
|
|
||||||
|
var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo);
|
||||||
|
var opts = BuildOptions(enabled: true, intervalSeconds: 0);
|
||||||
|
var poller = new LiveOddsPoller(sp, opts, NullLogger<LiveOddsPoller>.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<OddsSnapshot>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_NotInvokeUseCase_When_PollerIsDisabled()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var scraper = Substitute.For<IOddsScraper>();
|
||||||
|
var eventRepo = Substitute.For<IEventRepository>();
|
||||||
|
var snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||||
|
|
||||||
|
var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo);
|
||||||
|
var opts = BuildOptions(enabled: false, intervalSeconds: 0);
|
||||||
|
var poller = new LiveOddsPoller(sp, opts, NullLogger<LiveOddsPoller>.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<OddsSnapshot>(), Arg.Any<CancellationToken>());
|
||||||
|
await eventRepo.DidNotReceive().ListAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ContinueRunning_When_UseCaseThrowsException()
|
||||||
|
{
|
||||||
|
// Arrange — ListAsync always throws; poller must survive
|
||||||
|
var scraper = Substitute.For<IOddsScraper>();
|
||||||
|
var eventRepo = Substitute.For<IEventRepository>();
|
||||||
|
var snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||||
|
|
||||||
|
scraper.ScrapeUpcomingAsync(null, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Event>());
|
||||||
|
eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.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<LiveOddsPoller>.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<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user