feat(phase-4): application layer + background workers — 202/202 tests green

Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
This commit is contained in:
2026-05-05 12:28:15 +03:00
parent c4d87b59d6
commit 2acbaa5b77
31 changed files with 1719 additions and 94 deletions
@@ -12,6 +12,8 @@
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -20,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Marathon.Application\Marathon.Application.csproj" />
<ProjectReference Include="..\..\src\Marathon.Domain\Marathon.Domain.csproj" />
</ItemGroup>
</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>());
}
}