Files
maraphon-app/tests/Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs
T
alexei.dolgolyov a6ff368015 feat(phase-7-backend): implement anomaly detection — SuspensionFlip detector, use case, poller, and tests
- AnomalyDetector (pure domain): detects odds-flip pattern from live snapshot
  timelines using implied-probability vectors (p=1/rate, normalised), flip score
  = max(|p_post−p_pre|), gated by both threshold AND favourite-changed test
- SuspensionInterval record: typed pair of (pre, post) OddsSnapshot bracketing a gap
- AnomalyOptions POCO (Application layer): bound to Anomaly:* config section with
  four fields (SuspensionGapSeconds=60, OddsFlipThreshold=0.30, MinSnapshotCount=3,
  DetectionIntervalSeconds=60)
- DetectAnomaliesUseCase: iterates all events, loads last-24h live snapshots, runs
  detector, persists new anomalies with 1-minute dedup window
- AnomalyDetectionPoller: BackgroundService polling every DetectionIntervalSeconds,
  gated by WorkerOptions.AnomalyDetectionEnabled (default true)
- DI wiring: DetectAnomaliesUseCase registered Scoped in ApplicationModule;
  AnomalyOptions bound + AnomalyDetectionPoller hosted in InfrastructureModule
- WorkerOptions.AnomalyDetectionEnabled added; appsettings.json updated
- 13 domain tests + 4 application tests; total 245/245 passing (no regression)
2026-05-05 13:15:50 +03:00

218 lines
8.7 KiB
C#

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;
/// <summary>
/// Unit tests for <see cref="DetectAnomaliesUseCase"/> using NSubstitute mocks.
/// </summary>
public sealed class DetectAnomaliesUseCaseTests
{
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
private readonly ISnapshotRepository _snapshotRepo = Substitute.For<ISnapshotRepository>();
private readonly IAnomalyRepository _anomalyRepo = Substitute.For<IAnomalyRepository>();
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
/// <summary>Default options matching appsettings.json.</summary>
private static IOptions<AnomalyOptions> 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<AnomalyOptions>? opts = null) =>
new(_eventRepo, _snapshotRepo, _anomalyRepo,
opts ?? DefaultOptions(),
NullLogger<DetectAnomaliesUseCase>.Instance);
// ── Helper: build a snapshot timeline with a clear flip ───────────────────
private static IReadOnlyList<OddsSnapshot> 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<Bet>
{
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<CancellationToken>())
.Returns(new[] { ev }.ToList().AsReadOnly());
_snapshotRepo
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(eventId));
// No existing anomalies → dedup will not filter anything.
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().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<Anomaly>(), Arg.Any<CancellationToken>());
await _anomalyRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
// ── 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<CancellationToken>())
.Returns(new[] { ev }.ToList().AsReadOnly());
_snapshotRepo
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.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<CancellationToken>())
.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<Anomaly>(), Arg.Any<CancellationToken>());
}
// ── 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<CancellationToken>())
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
// Event 1 — snapshot load throws.
_snapshotRepo
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error for event 1"));
// Event 2 — clean flip timeline.
_snapshotRepo
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(ev2Id));
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().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<CancellationToken>())
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
_snapshotRepo
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(ev1Id));
_snapshotRepo
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(ev2Id));
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().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<Anomaly>(), Arg.Any<CancellationToken>());
}
}