using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.Configuration; using Marathon.Application.UseCases; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; namespace Marathon.Application.Tests.UseCases; /// /// Unit tests for using NSubstitute mocks. /// public sealed class DetectAnomaliesUseCaseTests { private readonly IEventRepository _eventRepo = Substitute.For(); private readonly ISnapshotRepository _snapshotRepo = Substitute.For(); private readonly IAnomalyRepository _anomalyRepo = Substitute.For(); private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset); /// Default options matching appsettings.json. private static IOptions DefaultOptions( int gapSeconds = 60, decimal threshold = 0.30m, int minSnapshots = 3) => Options.Create(new AnomalyOptions { SuspensionGapSeconds = gapSeconds, OddsFlipThreshold = threshold, MinSnapshotCount = minSnapshots, DetectionIntervalSeconds = 60, }); private DetectAnomaliesUseCase CreateSut(IOptions? opts = null) => new(_eventRepo, _snapshotRepo, _anomalyRepo, opts ?? DefaultOptions(), NullLogger.Instance); // ── Helper: build a snapshot timeline with a clear flip ─────────────────── private static IReadOnlyList BuildFlipTimeline(EventId eventId) { // 4 live snapshots: first two normal, then 90s gap (>60s threshold), // then two with flipped odds. Rates 1.3/4.0 → 4.0/1.3 produce a large flip score. static OddsSnapshot MakeSnap(EventId id, DateTimeOffset at, decimal r1, decimal r2) => new(id, at, OddsSource.Live, new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)), new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)), }); return new[] { MakeSnap(eventId, BaseTime, 1.3m, 4.0m), MakeSnap(eventId, BaseTime.AddSeconds(30), 1.3m, 4.0m), MakeSnap(eventId, BaseTime.AddSeconds(120), 4.0m, 1.3m), // flipped — 90 s gap MakeSnap(eventId, BaseTime.AddSeconds(150), 4.0m, 1.3m), }; } // ── Test 1: Iterates events, detects, and persists new anomalies ────────── [Fact] public async Task Should_PersistNewAnomalies_When_FlipDetectedForEvent() { // Arrange var eventId = new EventId("11111111"); var ev = TestFixtures.MakeEvent(eventId.Value); _eventRepo.ListAsync(Arg.Any()) .Returns(new[] { ev }.ToList().AsReadOnly()); _snapshotRepo .ListByEventAsync(eventId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(BuildFlipTimeline(eventId)); // No existing anomalies → dedup will not filter anything. _anomalyRepo.ListAsync(Arg.Any()) .Returns(Array.Empty().ToList().AsReadOnly()); var sut = CreateSut(); // Act var count = await sut.ExecuteAsync(CancellationToken.None); // Assert count.Should().Be(1, "one flip was detected"); await _anomalyRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); await _anomalyRepo.Received(1).SaveChangesAsync(Arg.Any()); } // ── Test 2: Deduplication — already-persisted anomaly is not re-added ───── [Fact] public async Task Should_SkipAlreadyPersistedAnomalies_When_DuplicateDetected() { // Arrange var eventId = new EventId("22222222"); var ev = TestFixtures.MakeEvent(eventId.Value); _eventRepo.ListAsync(Arg.Any()) .Returns(new[] { ev }.ToList().AsReadOnly()); _snapshotRepo .ListByEventAsync(eventId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(BuildFlipTimeline(eventId)); // Existing anomaly with same EventId, Kind=SuspensionFlip, and DetectedAt ≈ now (within dedup window). var existingAnomaly = new Anomaly( Id: Guid.NewGuid(), EventId: eventId, DetectedAt: DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)), Kind: AnomalyKind.SuspensionFlip, Score: 0.5m, EvidenceJson: "{\"dummy\":true}"); _anomalyRepo.ListAsync(Arg.Any()) .Returns(new[] { existingAnomaly }.ToList().AsReadOnly()); var sut = CreateSut(); // Act var count = await sut.ExecuteAsync(CancellationToken.None); // Assert count.Should().Be(0, "anomaly already exists — dedup prevents re-adding"); await _anomalyRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } // ── Test 3: Per-event failure does not abort the cycle ──────────────────── [Fact] public async Task Should_ContinueAfterPerEventFailure_And_ReturnPartialCount() { // Arrange: two events — first throws on snapshot load, second has a detectable flip. var ev1Id = new EventId("33333333"); var ev2Id = new EventId("44444444"); var ev1 = TestFixtures.MakeEvent(ev1Id.Value); var ev2 = TestFixtures.MakeEvent(ev2Id.Value); _eventRepo.ListAsync(Arg.Any()) .Returns(new[] { ev1, ev2 }.ToList().AsReadOnly()); // Event 1 — snapshot load throws. _snapshotRepo .ListByEventAsync(ev1Id, Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("DB error for event 1")); // Event 2 — clean flip timeline. _snapshotRepo .ListByEventAsync(ev2Id, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(BuildFlipTimeline(ev2Id)); _anomalyRepo.ListAsync(Arg.Any()) .Returns(Array.Empty().ToList().AsReadOnly()); var sut = CreateSut(); // Act — must not throw despite event 1 failing. var act = async () => await sut.ExecuteAsync(CancellationToken.None); await act.Should().NotThrowAsync(); var count = await sut.ExecuteAsync(CancellationToken.None); // Assert: event 2 succeeded — at least 1 anomaly persisted. count.Should().BeGreaterThan(0, "event 2 should succeed despite event 1 failure"); } // ── Test 4: Returns count of new anomalies across multiple events ────────── [Fact] public async Task Should_ReturnTotalNewAnomalyCount_When_MultipleEventsHaveFlips() { // Arrange: two events, both with a flip. var ev1Id = new EventId("55555555"); var ev2Id = new EventId("66666666"); var ev1 = TestFixtures.MakeEvent(ev1Id.Value); var ev2 = TestFixtures.MakeEvent(ev2Id.Value); _eventRepo.ListAsync(Arg.Any()) .Returns(new[] { ev1, ev2 }.ToList().AsReadOnly()); _snapshotRepo .ListByEventAsync(ev1Id, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(BuildFlipTimeline(ev1Id)); _snapshotRepo .ListByEventAsync(ev2Id, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(BuildFlipTimeline(ev2Id)); _anomalyRepo.ListAsync(Arg.Any()) .Returns(Array.Empty().ToList().AsReadOnly()); var sut = CreateSut(); // Act var count = await sut.ExecuteAsync(CancellationToken.None); // Assert count.Should().Be(2, "two events, one flip each → 2 new anomalies"); await _anomalyRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); } }