From 7639ebdd1da3b012a9bf9dcb586759498f135851 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 27 Aug 2026 14:24:26 +0200 Subject: [PATCH] test(core): add alert engine, evaluator, and sink tests --- .../Alerts/AlertEngineTests.cs | 106 ++++++++++++++++++ .../Alerts/AlertRuleEvaluatorTests.cs | 96 ++++++++++++++++ .../Alerts/AlertSinkTests.cs | 76 +++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 tests/Vantage.Core.Tests/Alerts/AlertEngineTests.cs create mode 100644 tests/Vantage.Core.Tests/Alerts/AlertRuleEvaluatorTests.cs create mode 100644 tests/Vantage.Core.Tests/Alerts/AlertSinkTests.cs diff --git a/tests/Vantage.Core.Tests/Alerts/AlertEngineTests.cs b/tests/Vantage.Core.Tests/Alerts/AlertEngineTests.cs new file mode 100644 index 0000000..5057686 --- /dev/null +++ b/tests/Vantage.Core.Tests/Alerts/AlertEngineTests.cs @@ -0,0 +1,106 @@ +using Vantage.Core.Alerts; +using Vantage.Core.Models; +using Xunit; + +namespace Vantage.Core.Tests.Alerts; + +public class AlertEngineTests +{ + private static readonly DateTimeOffset T0 = new(2026, 8, 26, 12, 0, 0, TimeSpan.Zero); + + private sealed class CollectingSink : IAlertSink + { + public List Received { get; } = new(); + + public Task NotifyAsync(AlertNotification notification, CancellationToken cancellationToken = default) + { + Received.Add(notification); + return Task.CompletedTask; + } + } + + private static HostMetrics MetricsWithCpu(double cpu) => new() + { + CpuUsagePercent = cpu, + MemoryTotalBytes = 100, + MemoryUsedBytes = 50, + CollectedAt = T0 + }; + + [Fact] + public async Task Ingest_FiresSinkWhenThresholdBreached() + { + var rule = new AlertRule { Name = "CPU", Metric = "cpu", Threshold = 80, For = TimeSpan.Zero }; + var sink = new CollectingSink(); + var engine = new AlertEngine(new[] { rule }, new[] { sink }); + + await engine.IngestAsync("h1", MetricsWithCpu(85), T0); + + Assert.Single(sink.Received); + Assert.Equal(AlertTransitionKind.Fired, sink.Received[0].Kind); + Assert.Equal("h1", sink.Received[0].HostId); + Assert.Single(engine.ActiveAlerts()); + } + + [Fact] + public async Task Ingest_BelowThreshold_NotifiesNothing() + { + var rule = new AlertRule { Name = "CPU", Metric = "cpu", Threshold = 80, For = TimeSpan.Zero }; + var sink = new CollectingSink(); + var engine = new AlertEngine(new[] { rule }, new[] { sink }); + + await engine.IngestAsync("h1", MetricsWithCpu(20), T0); + + Assert.Empty(sink.Received); + Assert.Empty(engine.ActiveAlerts()); + } + + [Fact] + public async Task HostScopedRule_IgnoresOtherHosts() + { + var rule = new AlertRule { Name = "CPU", Metric = "cpu", Threshold = 50, For = TimeSpan.Zero, HostId = "h1" }; + var sink = new CollectingSink(); + var engine = new AlertEngine(new[] { rule }, new[] { sink }); + + await engine.IngestAsync("h2", MetricsWithCpu(99), T0); + Assert.Empty(sink.Received); + + await engine.IngestAsync("h1", MetricsWithCpu(99), T0); + Assert.Single(sink.Received); + } + + [Fact] + public async Task WildcardRule_TracksEachHostIndependently() + { + var rule = new AlertRule { Name = "CPU", Metric = "cpu", Threshold = 80, For = TimeSpan.Zero }; + var sink = new CollectingSink(); + var engine = new AlertEngine(new[] { rule }, new[] { sink }); + + await engine.IngestAsync("h1", MetricsWithCpu(85), T0); + await engine.IngestAsync("h2", MetricsWithCpu(90), T0); + + Assert.Equal(2, sink.Received.Count); + Assert.Equal(2, engine.ActiveAlerts().Count); + } + + [Fact] + public async Task SinkThrow_DoesNotBreakEvaluationAndSurfacesError() + { + var rule = new AlertRule { Name = "CPU", Metric = "cpu", Threshold = 80, For = TimeSpan.Zero }; + var throwing = new ThrowingSink(); + var engine = new AlertEngine(new[] { rule }, new[] { throwing }); + var failures = 0; + engine.SinkFailed += (_, _, _) => failures++; + + await engine.IngestAsync("h1", MetricsWithCpu(85), T0); + + Assert.Equal(1, failures); + Assert.Single(engine.ActiveAlerts()); + } + + private sealed class ThrowingSink : IAlertSink + { + public Task NotifyAsync(AlertNotification notification, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("boom"); + } +} diff --git a/tests/Vantage.Core.Tests/Alerts/AlertRuleEvaluatorTests.cs b/tests/Vantage.Core.Tests/Alerts/AlertRuleEvaluatorTests.cs new file mode 100644 index 0000000..8f1a95f --- /dev/null +++ b/tests/Vantage.Core.Tests/Alerts/AlertRuleEvaluatorTests.cs @@ -0,0 +1,96 @@ +using Vantage.Core.Alerts; +using Xunit; + +namespace Vantage.Core.Tests.Alerts; + +public class AlertRuleEvaluatorTests +{ + private static readonly DateTimeOffset T0 = new(2026, 8, 26, 12, 0, 0, TimeSpan.Zero); + + private static AlertRule CpuRule(TimeSpan forDuration) => new() + { + Id = "rule-cpu", + Name = "High CPU", + Metric = "cpu", + Comparison = AlertComparison.GreaterThan, + Threshold = 90, + For = forDuration + }; + + [Fact] + public void Breach_HeldForDuration_FiresOnceThenStaysFiring() + { + var evaluator = new AlertRuleEvaluator(CpuRule(TimeSpan.FromSeconds(60)), "h1"); + + Assert.Null(evaluator.Update(95, T0)); + Assert.Equal(AlertState.Pending, evaluator.State); + + Assert.Null(evaluator.Update(96, T0.AddSeconds(30))); + Assert.Equal(AlertState.Pending, evaluator.State); + + var fired = evaluator.Update(97, T0.AddSeconds(60)); + Assert.NotNull(fired); + Assert.Equal(AlertTransitionKind.Fired, fired!.Kind); + Assert.Equal(AlertState.Firing, evaluator.State); + + Assert.Null(evaluator.Update(98, T0.AddSeconds(90))); + Assert.Equal(AlertState.Firing, evaluator.State); + } + + [Fact] + public void Recovery_AfterFiring_EmitsResolved() + { + var evaluator = new AlertRuleEvaluator(CpuRule(TimeSpan.Zero), "h1"); + + var fired = evaluator.Update(95, T0); + Assert.Equal(AlertTransitionKind.Fired, fired!.Kind); + + var resolved = evaluator.Update(40, T0.AddSeconds(10)); + Assert.NotNull(resolved); + Assert.Equal(AlertTransitionKind.Resolved, resolved!.Kind); + Assert.Equal(AlertState.Ok, evaluator.State); + } + + [Fact] + public void BreachThatClearsBeforeDuration_NeverFires() + { + var evaluator = new AlertRuleEvaluator(CpuRule(TimeSpan.FromSeconds(60)), "h1"); + + Assert.Null(evaluator.Update(95, T0)); + Assert.Null(evaluator.Update(97, T0.AddSeconds(30))); + Assert.Null(evaluator.Update(20, T0.AddSeconds(45))); + Assert.Equal(AlertState.Ok, evaluator.State); + + Assert.Null(evaluator.Update(95, T0.AddSeconds(50))); + Assert.Equal(AlertState.Pending, evaluator.State); + } + + [Fact] + public void ValueNeverBreaches_ProducesNoTransition() + { + var evaluator = new AlertRuleEvaluator(CpuRule(TimeSpan.Zero), "h1"); + + for (var i = 0; i < 5; i++) + Assert.Null(evaluator.Update(50, T0.AddSeconds(i * 10))); + + Assert.Equal(AlertState.Ok, evaluator.State); + } + + [Fact] + public void LessThanComparison_FiresWhenBelowThreshold() + { + var rule = new AlertRule + { + Name = "Disk low", + Metric = "disk", + Comparison = AlertComparison.LessThan, + Threshold = 10, + For = TimeSpan.Zero + }; + var evaluator = new AlertRuleEvaluator(rule, "h1"); + + Assert.Null(evaluator.Update(50, T0)); + var fired = evaluator.Update(5, T0.AddSeconds(5)); + Assert.Equal(AlertTransitionKind.Fired, fired!.Kind); + } +} diff --git a/tests/Vantage.Core.Tests/Alerts/AlertSinkTests.cs b/tests/Vantage.Core.Tests/Alerts/AlertSinkTests.cs new file mode 100644 index 0000000..43e1cac --- /dev/null +++ b/tests/Vantage.Core.Tests/Alerts/AlertSinkTests.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Vantage.Core.Alerts; +using Vantage.Core.Tests.Fakes; +using Xunit; + +namespace Vantage.Core.Tests.Alerts; + +public class AlertSinkTests +{ + private static readonly DateTimeOffset T0 = new(2026, 8, 26, 12, 0, 0, TimeSpan.Zero); + + private static AlertNotification Fired() => new( + "rule-1", "High CPU", "vps01", "cpu", AlertSeverity.Critical, + AlertTransitionKind.Fired, 95.5, 90, ">", TimeSpan.FromMinutes(1), T0); + + [Fact] + public async Task WebhookSink_PostsJsonPayload() + { + var handler = StubHttpMessageHandler.Capturing(); + var sink = new WebhookAlertSink(new HttpClient(handler), new Uri("https://hooks.example/vantage")); + + await sink.NotifyAsync(Fired()); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://hooks.example/vantage", request.RequestUri!.ToString()); + + using var body = JsonDocument.Parse(handler.Bodies[0]); + var root = body.RootElement; + Assert.Equal("firing", root.GetProperty("status").GetString()); + Assert.Equal("critical", root.GetProperty("severity").GetString()); + Assert.Equal("High CPU", root.GetProperty("rule").GetString()); + Assert.Equal("vps01", root.GetProperty("host").GetString()); + Assert.Equal(90, root.GetProperty("threshold").GetDouble()); + } + + [Fact] + public async Task LogSink_AppendsSummaryLine() + { + var log = new InMemoryAlertLog(); + var sink = new LogAlertSink(log); + + await sink.NotifyAsync(Fired()); + + var entry = Assert.Single(log.Recent(10)); + Assert.Equal(AlertSeverity.Critical, entry.Severity); + Assert.Contains("FIRING", entry.Line); + Assert.Contains("vps01", entry.Line); + } + + [Fact] + public async Task DesktopSink_RoutesToNotifier() + { + var notifier = new BufferedDesktopNotifier(); + var sink = new DesktopAlertSink(notifier); + + await sink.NotifyAsync(Fired()); + + var toast = Assert.Single(notifier.DrainPending()); + Assert.Contains("High CPU", toast.Title); + Assert.Equal(AlertSeverity.Critical, toast.Severity); + } + + [Fact] + public void InMemoryAlertLog_KeepsMostRecentWithinCapacity() + { + var log = new InMemoryAlertLog(capacity: 3); + for (var i = 0; i < 5; i++) + log.Append(new AlertLogEntry(T0.AddSeconds(i), AlertSeverity.Info, $"line {i}")); + + var recent = log.Recent(10); + Assert.Equal(3, recent.Count); + Assert.Equal("line 4", recent[0].Line); + Assert.Equal("line 2", recent[2].Line); + } +}