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; namespace Marathon.Application.Tests.UseCases; public sealed class BuildBetJournalReportUseCaseTests { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset Placed = new(2026, 5, 16, 12, 0, 0, MoscowOffset); private static readonly DateTimeOffset Kickoff = new(2026, 5, 16, 18, 0, 0, MoscowOffset); private readonly IPlacedBetRepository _bets = Substitute.For(); private readonly IEventRepository _events = Substitute.For(); private readonly ISnapshotRepository _snapshots = Substitute.For(); public BuildBetJournalReportUseCaseTests() { // Use case batches event loads via GetManyAsync; route through per-id stubs. TestFixtures.BridgeGetMany(_events); } private BuildBetJournalReportUseCase CreateSut() => new(_bets, _events, _snapshots, NullLogger.Instance); private static PlacedBet MakeBet( EventId id, BetOutcome outcome, Side side = Side.Side1, decimal stake = 100m, decimal rate = 2.10m) => new( Guid.NewGuid(), id, new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(rate)), stake, Placed, outcome, null); private static Event MakeEvent(EventId id, DateTimeOffset scheduledAt) => new(id, new SportCode(11), "BY", "L1", "Cat", scheduledAt, "Team A", "Team B"); private static OddsSnapshot MakeSnapshot(EventId id, DateTimeOffset at, decimal rateSide1) => new(id, at, OddsSource.PreMatch, new[] { new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rateSide1)), new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(2.00m)), }); [Fact] public async Task Should_ReturnEmptyReport_When_NoBets() { _bets.ListAsync(Arg.Any()) .Returns(Array.Empty().ToList().AsReadOnly()); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Bets.Should().BeEmpty(); report.Stats.TotalBets.Should().Be(0); report.Stats.RoiPercent.Should().BeNull(); } [Fact] public async Task Should_AggregateStats_AcrossMixedOutcomes() { var id1 = new EventId("e-1"); var id2 = new EventId("e-2"); var id3 = new EventId("e-3"); _bets.ListAsync(Arg.Any()) .Returns(new[] { MakeBet(id1, BetOutcome.Won, stake: 100m, rate: 2.00m), // gross 200, +100 MakeBet(id2, BetOutcome.Lost, stake: 100m, rate: 2.00m), // gross 0, -100 MakeBet(id3, BetOutcome.Pending), }.ToList().AsReadOnly()); // Wire events so the report can compute CLV (we don't need actual CLV here — leave snapshots empty). foreach (var id in new[] { id1, id2, id3 }) { _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); _snapshots.GetLatestPreMatchAsync(id, Arg.Any(), Arg.Any()) .Returns((OddsSnapshot?)null); } var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Stats.TotalBets.Should().Be(3); report.Stats.PendingCount.Should().Be(1); report.Stats.WonCount.Should().Be(1); report.Stats.LostCount.Should().Be(1); report.Stats.TotalStaked.Should().Be(200m, "pending bets are excluded from totals"); report.Stats.TotalReturned.Should().Be(200m); report.Stats.NetProfit.Should().Be(0m); report.Stats.RoiPercent.Should().Be(0m); report.Stats.StrikeRatePercent.Should().Be(50m); } [Fact] public async Task Should_ComputeClv_AgainstClosingSnapshot() { var id = new EventId("clv-event"); var bet = MakeBet(id, BetOutcome.Won, rate: 2.20m, stake: 100m); _bets.ListAsync(Arg.Any()) .Returns(new[] { bet }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); // Closing snapshot returned by the dedicated repo method. _snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any()) .Returns(MakeSnapshot(id, Kickoff.AddMinutes(-5), rateSide1: 2.00m)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Bets.Should().HaveCount(1); report.Bets[0].ClvProbabilityDelta.Should().NotBeNull(); // taken 2.20 vs closing 2.00 → +0.04545 report.Bets[0].ClvProbabilityDelta!.Value.Should().BeApproximately(0.04545m, 0.00001m); report.Stats.AverageClvProbabilityDelta.Should().NotBeNull(); } [Fact] public async Task Should_LeaveClvNull_When_NoClosingSnapshotAvailable() { var id = new EventId("no-close"); _bets.ListAsync(Arg.Any()) .Returns(new[] { MakeBet(id, BetOutcome.Won) }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); _snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any()) .Returns((OddsSnapshot?)null); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Bets[0].ClvProbabilityDelta.Should().BeNull(); report.Stats.AverageClvProbabilityDelta.Should().BeNull( "no rows had a computable CLV — average is undefined"); } [Fact] public async Task Should_ExcludeVoidStakes_FromRoiTurnover() { // 1 Won (+100), 1 Lost (-100), 1 Void (stake returned). Industry-standard // ROI excludes pushes from turnover, so total staked = 200, returned 200, // net 0, ROI 0%. If voids were included turnover would be 300 → ROI ≈ 0% // numerator but inflated denominator semantics. var ids = Enumerable.Range(1, 3) .Select(i => new EventId($"void-{i}")).ToArray(); _bets.ListAsync(Arg.Any()) .Returns(new[] { MakeBet(ids[0], BetOutcome.Won, stake: 100m, rate: 2.00m), MakeBet(ids[1], BetOutcome.Lost, stake: 100m, rate: 2.00m), MakeBet(ids[2], BetOutcome.Void, stake: 100m, rate: 2.00m), }.ToList().AsReadOnly()); foreach (var id in ids) { _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); _snapshots.GetLatestPreMatchAsync(id, Arg.Any(), Arg.Any()) .Returns((OddsSnapshot?)null); } var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Stats.VoidCount.Should().Be(1); report.Stats.TotalStaked.Should().Be(200m, "void bets are pushes — the stake was returned and should not count as turnover"); report.Stats.TotalReturned.Should().Be(200m); report.Stats.NetProfit.Should().Be(0m); report.Stats.RoiPercent.Should().Be(0m); } [Fact] public async Task Should_OrderBets_NewestPlacedFirst() { var ids = Enumerable.Range(0, 3).Select(i => new EventId($"ord-{i}")).ToArray(); var older = new DateTimeOffset(2026, 5, 10, 12, 0, 0, MoscowOffset); var newer = new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset); // Bet 0 is the middle one, bet 1 oldest, bet 2 newest. var b0 = new PlacedBet(Guid.NewGuid(), ids[0], new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), 100m, older.AddDays(1), BetOutcome.Won, null); var b1 = new PlacedBet(Guid.NewGuid(), ids[1], new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), 100m, older, BetOutcome.Lost, null); var b2 = new PlacedBet(Guid.NewGuid(), ids[2], new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), 100m, newer, BetOutcome.Pending, null); _bets.ListAsync(Arg.Any()) .Returns(new[] { b0, b1, b2 }.ToList().AsReadOnly()); foreach (var id in ids) { _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); _snapshots.GetLatestPreMatchAsync(id, Arg.Any(), Arg.Any()) .Returns((OddsSnapshot?)null); } var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Bets.Select(r => r.Bet.Id).Should().ContainInOrder(b2.Id, b0.Id, b1.Id); } }