test(core): add alert engine, evaluator, and sink tests
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user