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