using System.Net; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using SQLInsight.Core.Findings; namespace SQLInsight.Notifications.Tests; public class NotificationTests { private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero); private sealed class ManualTime(DateTimeOffset now) : TimeProvider { public DateTimeOffset Now { get; set; } = now; public override DateTimeOffset GetUtcNow() => Now; } private sealed class RecordingChannel(Severity min, bool fail = false) : INotificationChannel { public List> Batches { get; } = []; public string Name => "recording"; public Severity MinSeverity => min; public Task SendAsync(IReadOnlyList notifications, CancellationToken cancellationToken) { if (fail) { throw new HttpRequestException("down"); } Batches.Add(notifications); return Task.CompletedTask; } } private static Notification Opened(Severity severity, string title = "max server memory er ikke sat") => NotificationFactory.Opened("prod-sql01", 42, new Finding { RuleId = "SQLI-CFG-001", Severity = severity, Title = title, Explanation = "SQL Server må bruge al hukommelse. Mere tekst.", Recommendation = "Sæt den.", }, new Uri("https://sqlinsight.local/"), Now); private static NotificationDispatcher Dispatcher(INotificationChannel channel, TimeProvider? time = null, int max = 20, bool resolved = true) => new([channel], new NotificationOptions { MaxMessagesPerHour = max, NotifyResolved = resolved }, time ?? new ManualTime(Now), NullLogger.Instance); [Fact] public async Task Findings_below_the_channels_severity_are_not_sent_but_outages_always_are() { var channel = new RecordingChannel(Severity.High); await Dispatcher(channel).DispatchAsync([Opened(Severity.Medium), Opened(Severity.Critical), NotificationFactory.Unreachable("legacy", "timeout", null, Now)], default); var batch = Assert.Single(channel.Batches); Assert.Equal([NotificationKind.FindingOpened, NotificationKind.InstanceUnreachable], batch.Select(n => n.Kind)); } [Fact] public async Task Resolved_can_be_switched_off() { var channel = new RecordingChannel(Severity.Low); await Dispatcher(channel, resolved: false).DispatchAsync([NotificationFactory.Resolved("x", 1, "R", "t", Severity.High, null, Now)], default); Assert.Empty(channel.Batches); } [Fact] public async Task Messages_are_capped_per_hour() { var time = new ManualTime(Now); var channel = new RecordingChannel(Severity.Low); var dispatcher = Dispatcher(channel, time, max: 2); for (var i = 0; i < 5; i++) { await dispatcher.DispatchAsync([Opened(Severity.High)], default); } Assert.Equal(2, channel.Batches.Count); Assert.Equal(3, dispatcher.Suppressed["recording"]); time.Now = Now.AddMinutes(61); await dispatcher.DispatchAsync([Opened(Severity.High)], default); Assert.Equal(3, channel.Batches.Count); } [Fact] public async Task A_failing_channel_does_not_throw() { await Dispatcher(new RecordingChannel(Severity.Low, fail: true)).DispatchAsync([Opened(Severity.High)], default); } [Fact] public void Opened_notification_links_to_the_finding_and_keeps_only_the_first_sentence() { var n = Opened(Severity.High); Assert.Equal("https://sqlinsight.local/fund/42", n.Url!.ToString()); Assert.Equal("SQL Server må bruge al hukommelse.", n.Summary); } [Fact] public void Email_is_html_encoded_and_has_a_useful_subject() { var options = new EmailOptions { From = "sqlinsight@x.local" }; options.To.Add("dba@x.local"); var message = EmailChannel.Build(options, [Opened(Severity.Critical, "")]); Assert.Equal("SQLInsight: [Kritisk] prod-sql01: ", message.Subject); Assert.Contains("<script>", message.HtmlBody, StringComparison.Ordinal); Assert.DoesNotContain("