feat(notifications): config-gated Telegram anomaly alerts
- INotificationSink + AnomalyNotification (Application) and a testable GetPendingAnomalyNotificationsUseCase (date+score filter, event-title join, oldest-first). 4 use-case tests. - TelegramNotificationSink posts to the Bot API via HttpClient (no SDK dependency); no-ops with a warning when unconfigured and never logs the token. - AnomalyNotificationDispatcher BackgroundService: startup-baselined marker advanced past the newest sent (gap- and dup-free); idles until Notifications:Enabled. - Wire options + named client + sink + dispatcher in InfrastructureModule. Add a secret-free Notifications section + steam-move tuning to appsettings.json (bot token + chat id go in appsettings.Local.json only).
This commit is contained in:
+95
@@ -0,0 +1,95 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
public sealed class GetPendingAnomalyNotificationsUseCaseTests
|
||||
{
|
||||
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||
|
||||
public GetPendingAnomalyNotificationsUseCaseTests()
|
||||
{
|
||||
// Event titles are resolved via the batched GetManyAsync; route it through
|
||||
// the per-id GetAsync stubs each test configures.
|
||||
TestFixtures.BridgeGetMany(_events);
|
||||
}
|
||||
|
||||
private GetPendingAnomalyNotificationsUseCase CreateSut() =>
|
||||
new(_anomalies, _events, NullLogger<GetPendingAnomalyNotificationsUseCase>.Instance);
|
||||
|
||||
private static Anomaly MakeAnomaly(EventId id, decimal score, DateTimeOffset detectedAt) =>
|
||||
new(Guid.NewGuid(), id, detectedAt, AnomalyKind.SuspensionFlip, score, "{}");
|
||||
|
||||
private static Event MakeEvent(EventId id) =>
|
||||
new(id, new SportCode(11), "BY", "L1", "Cat", BaseTime, "Team A", "Team B");
|
||||
|
||||
private void StubAnomalies(params Anomaly[] anomalies) =>
|
||||
_anomalies.ListByDateRangeAsync(
|
||||
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(anomalies.ToList().AsReadOnly());
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ExcludeAnomaliesBelowMinScore()
|
||||
{
|
||||
var id = new EventId("11111111");
|
||||
StubAnomalies(
|
||||
MakeAnomaly(id, 0.30m, BaseTime),
|
||||
MakeAnomaly(id, 0.70m, BaseTime.AddMinutes(1)));
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
|
||||
|
||||
var result = await CreateSut().ExecuteAsync(BaseTime.AddHours(-1), minScore: 0.45m, CancellationToken.None);
|
||||
|
||||
result.Should().ContainSingle();
|
||||
result[0].Score.Should().Be(0.70m);
|
||||
result[0].EventTitle.Should().Be("Team A vs Team B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_OrderOldestFirst()
|
||||
{
|
||||
var id1 = new EventId("11111111");
|
||||
var id2 = new EventId("22222222");
|
||||
StubAnomalies(
|
||||
MakeAnomaly(id2, 0.60m, BaseTime.AddMinutes(5)),
|
||||
MakeAnomaly(id1, 0.50m, BaseTime));
|
||||
_events.GetAsync(id1, Arg.Any<CancellationToken>()).Returns(MakeEvent(id1));
|
||||
_events.GetAsync(id2, Arg.Any<CancellationToken>()).Returns(MakeEvent(id2));
|
||||
|
||||
var result = await CreateSut().ExecuteAsync(BaseTime.AddHours(-1), 0.45m, CancellationToken.None);
|
||||
|
||||
result.Select(n => n.DetectedAt).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_FallBackToEventId_When_EventMissing()
|
||||
{
|
||||
var id = new EventId("orphan00");
|
||||
StubAnomalies(MakeAnomaly(id, 0.55m, BaseTime));
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||
|
||||
var result = await CreateSut().ExecuteAsync(BaseTime.AddHours(-1), 0.45m, CancellationToken.None);
|
||||
|
||||
result.Should().ContainSingle();
|
||||
result[0].EventTitle.Should().Be("orphan00");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ReturnEmpty_When_NothingQualifies()
|
||||
{
|
||||
StubAnomalies(Array.Empty<Anomaly>());
|
||||
|
||||
var result = await CreateSut().ExecuteAsync(BaseTime, 0.45m, CancellationToken.None);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user