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());
}
}