66ae038243
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.
232 lines
9.7 KiB
C#
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);
|
|
}
|