using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Marathon.UI.Services; using NSubstitute; namespace Marathon.UI.Tests.Services; public sealed class PaperTradingServiceTests { private static readonly TimeSpan Msk = TimeSpan.FromHours(3); private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk); private readonly IPaperBetRepository _paperBets = Substitute.For(); private readonly IEventRepository _events = Substitute.For(); private PaperTradingService CreateSut() => new(_paperBets, _events); private static PaperBet Bet(string eventCode, decimal rate, BetOutcome outcome) { var open = PaperBet.Open(Guid.NewGuid(), new EventId(eventCode), Side.Side1, rate, 10m, T0); return outcome switch { BetOutcome.Won => open.SettleAgainst(Side.Side1, T0.AddHours(2)), BetOutcome.Lost => open.SettleAgainst(Side.Side2, T0.AddHours(2)), _ => open, }; } private static Event Ev(string code, string s1, string s2) => new(new EventId(code), new SportCode(11), "England", "league", string.Empty, T0.AddDays(1), s1, s2); [Fact] public async Task GetAsync_Empty_When_NoBets() { _paperBets.ListAsync(Arg.Any()).Returns(Array.Empty()); (await CreateSut().GetAsync(CancellationToken.None)).Should().Be(PaperTradingVm.Empty); } [Fact] public async Task GetAsync_Aggregates_SettledOnlyPnL_AndJoinsTitles() { var bets = new[] { Bet("A", 1.90m, BetOutcome.Won), // payout 19 Bet("A", 2.00m, BetOutcome.Lost), // payout 0 Bet("B", 1.50m, BetOutcome.Pending), // excluded from P&L }; _paperBets.ListAsync(Arg.Any()).Returns(bets); _events .GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary { [new EventId("A")] = Ev("A", "Home", "Away") }); var vm = await CreateSut().GetAsync(CancellationToken.None); vm.OpenCount.Should().Be(1); vm.SettledCount.Should().Be(2); vm.Wins.Should().Be(1); vm.Losses.Should().Be(1); vm.TotalStaked.Should().Be(20m); vm.TotalReturned.Should().Be(19m); vm.NetProfit.Should().Be(-1m); vm.RoiPercent.Should().Be(-5m); // -1 / 20 * 100 vm.HitRatePercent.Should().Be(50m); // 1 / 2 * 100 vm.Bets.Should().HaveCount(3); vm.Bets.Single(b => b.Rate == 1.90m).EventTitle.Should().Be("Home vs Away"); // Event B isn't in the lookup (e.g. pruned) — falls back to the raw id. vm.Bets.Single(b => b.Rate == 1.50m).EventTitle.Should().Be("B"); } }