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:
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user