test(core): add alert engine, evaluator, and sink tests

This commit is contained in:
dualfroz committed 2026-08-27 14:24:26 +02:00
1 parent f95edd06e0
commit 7639ebdd1d
3 files changed
+278

No files matched your search

@@ -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<AlertNotification> 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");
}
}
@@ -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);
}
}
@@ -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);
}
}