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:
2026-05-29 00:59:57 +03:00
parent 2e53dff853
commit 005d4e794a
9 changed files with 418 additions and 1 deletions
@@ -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();
}
}