Files
maraphon-app/tests/Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs
T
alexei.dolgolyov 66ae038243 perf(detect-anomalies): batch snapshot loads into a single query (HIGH)
DetectAnomaliesUseCase was issuing one ISnapshotRepository.ListByEventAsync
call per event each cycle, with each call rehydrating that event's bets via
Include(s => s.Bets) — O(N) SQLite round-trips and N Include payloads on
every detection cycle.

* Add ISnapshotRepository.ListByEventsAsync(IReadOnlyCollection<EventId>, …)
  returning a per-event dictionary; events with no snapshots in range get
  Array.Empty<OddsSnapshot>() so the caller doesn't need a presence check.
* Implementation uses a single .Where(s => ids.Contains(s.EventCode))
  query and groups in memory.
* DetectAnomaliesUseCase loads the whole batch once before the foreach,
  then ProcessEventAsync receives the per-event slice as a parameter.
* Tests updated to stub the new method; per-event-failure test now
  exercises an AddAsync throw rather than a snapshot-load throw, since
  individual snapshot loads no longer fail per-event.
2026-05-09 15:17:49 +03:00

232 lines
9.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
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(SnapshotsByEvent(eventId, 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
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(SnapshotsByEvent(eventId, 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 — persistence on event 1 throws, event 2 has a detectable flip.
// (The batched ListByEventsAsync no longer fails per-event; the per-event try/catch
// protects against persistence failures while iterating detected anomalies.)
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());
// Both events have detectable flip timelines.
_snapshotRepo
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(SnapshotsByEvent(
(ev1Id, BuildFlipTimeline(ev1Id)),
(ev2Id, BuildFlipTimeline(ev2Id))));
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
// Event 1's anomaly persistence throws; event 2's succeeds.
_anomalyRepo
.AddAsync(Arg.Is<Anomaly>(a => a.EventId == ev1Id), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error for event 1 anomaly"));
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
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(SnapshotsByEvent(
(ev1Id, BuildFlipTimeline(ev1Id)),
(ev2Id, 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>());
}
// ── Helper: build the dictionary returned by ISnapshotRepository.ListByEventsAsync ─
private static IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>> SnapshotsByEvent(
EventId id, IReadOnlyList<OddsSnapshot> snapshots) =>
new Dictionary<EventId, IReadOnlyList<OddsSnapshot>> { [id] = snapshots };
private static IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>> SnapshotsByEvent(
params (EventId Id, IReadOnlyList<OddsSnapshot> Snapshots)[] entries) =>
entries.ToDictionary(e => e.Id, e => e.Snapshots);
}