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 OpenPaperBetsUseCaseTests { private static readonly TimeSpan Msk = TimeSpan.FromHours(3); private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk); private readonly IAnomalyRepository _anomalies = Substitute.For(); private readonly IPaperBetRepository _paperBets = Substitute.For(); public OpenPaperBetsUseCaseTests() { // Default: nothing already forward-tested. _paperBets .GetExistingAnomalyIdsAsync(Arg.Any>(), Arg.Any()) .Returns(new HashSet()); } private OpenPaperBetsUseCase CreateSut() => new(_anomalies, _paperBets, NullLogger.Instance); // Flip: pre favourite Side1, post favourite Side2 @ 1.60. private const string FlipToSide2 = """ {"suspensionGapSeconds":90, "preSuspension":{"capturedAt":"2026-05-20T18:00:00+03:00","p1":0.6,"p2":0.4,"rate1":1.6,"rate2":2.5}, "postSuspension":{"capturedAt":"2026-05-20T18:02:00+03:00","p1":0.4,"p2":0.6,"rate1":2.5,"rate2":1.6}} """; private static Anomaly Anomaly(AnomalyKind kind, decimal score, string evidence) => new(Guid.NewGuid(), new EventId("26000001"), T0.AddMinutes(2), kind, score, evidence); private void HaveAnomalies(params Anomaly[] anomalies) => _anomalies .ListByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(anomalies); [Fact] public async Task Opens_DirectionalAboveThreshold_WithPostFavouriteAndRate() { var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2); HaveAnomalies(a); var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), minScore: 0.5m, flatStake: 10m); opened.Should().ContainSingle(); opened[0].AnomalyId.Should().Be(a.Id); opened[0].PickedSide.Should().Be(Side.Side2); opened[0].Rate.Should().Be(1.6m); opened[0].Stake.Should().Be(10m); opened[0].Outcome.Should().Be(BetOutcome.Pending); await _paperBets.Received(1).AddAsync(Arg.Any(), Arg.Any()); await _paperBets.Received(1).SaveChangesAsync(Arg.Any()); } [Fact] public async Task Skips_NonDirectionalKinds() { HaveAnomalies(Anomaly(AnomalyKind.SuspensionFreeze, 0.9m, FlipToSide2)); var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m); opened.Should().BeEmpty(); await _paperBets.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Skips_BelowThreshold() { HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.3m, FlipToSide2)); (await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty(); } [Fact] public async Task Skips_AnomaliesThatAlreadyHaveAPaperBet() { var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2); HaveAnomalies(a); _paperBets .GetExistingAnomalyIdsAsync(Arg.Any>(), Arg.Any()) .Returns(new HashSet { a.Id }); (await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty(); await _paperBets.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Skips_AnomaliesWithUnparseableEvidence() { HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.7m, "not json")); (await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty(); } [Fact] public async Task Throws_When_FlatStakeNotPositive() { var act = async () => await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 0m); await act.Should().ThrowAsync(); } }