364 lines
13 KiB
C#
364 lines
13 KiB
C#
// <copyright file="ConvergenceTrackerTests.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Testing.Temporal;
|
|
|
|
namespace StellaOps.Testing.Chaos.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="DefaultConvergenceTracker"/>.
|
|
/// </summary>
|
|
public sealed class ConvergenceTrackerTests
|
|
{
|
|
private readonly SimulatedTimeProvider _timeProvider;
|
|
private readonly DefaultConvergenceTracker _tracker;
|
|
|
|
public ConvergenceTrackerTests()
|
|
{
|
|
_timeProvider = new SimulatedTimeProvider();
|
|
_tracker = new DefaultConvergenceTracker(
|
|
_timeProvider,
|
|
NullLogger<DefaultConvergenceTracker>.Instance,
|
|
pollInterval: TimeSpan.FromMilliseconds(1)); // Use 1ms to avoid real delays
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureSnapshotAsync_NoProbes_ReturnsEmptySnapshot()
|
|
{
|
|
// Act
|
|
var snapshot = await _tracker.CaptureSnapshotAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Empty(snapshot.ProbeResults);
|
|
Assert.Equal(_timeProvider.GetUtcNow(), snapshot.CapturedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureSnapshotAsync_WithProbes_CapturesAllResults()
|
|
{
|
|
// Arrange
|
|
var probe1 = new DelegateProbe("probe-1", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, [])));
|
|
var probe2 = new DelegateProbe("probe-2", _ => Task.FromResult(
|
|
new ProbeResult(false, ImmutableDictionary<string, object>.Empty, ["error"])));
|
|
|
|
_tracker.RegisterProbe(probe1);
|
|
_tracker.RegisterProbe(probe2);
|
|
|
|
// Act
|
|
var snapshot = await _tracker.CaptureSnapshotAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Equal(2, snapshot.ProbeResults.Count);
|
|
Assert.True(snapshot.ProbeResults["probe-1"].IsHealthy);
|
|
Assert.False(snapshot.ProbeResults["probe-2"].IsHealthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureSnapshotAsync_ProbeThrows_RecordsFailure()
|
|
{
|
|
// Arrange
|
|
var failingProbe = new DelegateProbe("failing", _ =>
|
|
throw new InvalidOperationException("Probe failed"));
|
|
|
|
_tracker.RegisterProbe(failingProbe);
|
|
|
|
// Act
|
|
var snapshot = await _tracker.CaptureSnapshotAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(snapshot.ProbeResults);
|
|
Assert.False(snapshot.ProbeResults["failing"].IsHealthy);
|
|
Assert.Contains("Probe failed", snapshot.ProbeResults["failing"].Anomalies[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterProbe_AddsProbe()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("test", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, [])));
|
|
|
|
// Act
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
// Assert - should be included in snapshot
|
|
var snapshot = await _tracker.CaptureSnapshotAsync(TestContext.Current.CancellationToken);
|
|
Assert.Contains("test", snapshot.ProbeResults.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnregisterProbe_RemovesProbe()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("test", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, [])));
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
// Act
|
|
_tracker.UnregisterProbe("test");
|
|
|
|
// Assert - should not be in snapshot
|
|
var snapshot = await _tracker.CaptureSnapshotAsync(TestContext.Current.CancellationToken);
|
|
Assert.DoesNotContain("test", snapshot.ProbeResults.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_AllHealthy_ReturnsConverged()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("healthy", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, [])));
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
var expectations = new ConvergenceExpectations(RequireAllHealthy: true);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(result.HasConverged);
|
|
Assert.Empty(result.Violations);
|
|
Assert.Equal(1, result.ConvergenceAttempts);
|
|
Assert.NotNull(result.TimeToConverge);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_UnhealthyComponent_ReturnsNotConverged()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("unhealthy", _ => Task.FromResult(
|
|
new ProbeResult(false, ImmutableDictionary<string, object>.Empty, [])));
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
var expectations = new ConvergenceExpectations(RequireAllHealthy: true);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.HasConverged);
|
|
Assert.Contains("Unhealthy components: unhealthy", result.Violations);
|
|
Assert.Null(result.TimeToConverge);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_EventuallyConverges_ReturnsSuccess()
|
|
{
|
|
// Arrange
|
|
var callCount = 0;
|
|
var probe = new DelegateProbe("eventual", _ =>
|
|
{
|
|
callCount++;
|
|
var isHealthy = callCount >= 3; // Becomes healthy after 2 failures
|
|
return Task.FromResult(
|
|
new ProbeResult(isHealthy, ImmutableDictionary<string, object>.Empty, []));
|
|
});
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
var expectations = new ConvergenceExpectations(RequireAllHealthy: true);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(result.HasConverged);
|
|
Assert.True(result.ConvergenceAttempts >= 3); // At least 3 attempts to converge
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_RequiredComponent_NotFound_ReportsViolation()
|
|
{
|
|
// Arrange
|
|
var expectations = new ConvergenceExpectations(
|
|
RequireAllHealthy: false,
|
|
RequiredHealthyComponents: ["missing-component"]);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.HasConverged);
|
|
Assert.Contains("Required component 'missing-component' not found", result.Violations);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_RequiredComponent_Unhealthy_ReportsViolation()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("critical-service", _ => Task.FromResult(
|
|
new ProbeResult(false, ImmutableDictionary<string, object>.Empty, [])));
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
var expectations = new ConvergenceExpectations(
|
|
RequireAllHealthy: false,
|
|
RequiredHealthyComponents: ["critical-service"]);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.HasConverged);
|
|
Assert.Contains("Required component 'critical-service' is unhealthy", result.Violations);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_Cancellation_Throws()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("slow", async ct =>
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
|
return new ProbeResult(true, ImmutableDictionary<string, object>.Empty, []);
|
|
});
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<OperationCanceledException>(
|
|
() => _tracker.WaitForConvergenceAsync(
|
|
new ConvergenceExpectations(),
|
|
TimeSpan.FromSeconds(10),
|
|
cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_OrphanedResources_ReportsViolation()
|
|
{
|
|
// Arrange
|
|
var probe = new DelegateProbe("resource-tracker", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, ["orphan file detected"])));
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
var expectations = new ConvergenceExpectations(RequireNoOrphanedResources: true);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.HasConverged);
|
|
Assert.Contains(result.Violations, v => v.Contains("Orphaned resources"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitForConvergenceAsync_MetricValidation_ReportsViolation()
|
|
{
|
|
// Arrange
|
|
var metrics = new Dictionary<string, object> { ["cpu_usage"] = 95.0 };
|
|
var probe = new DelegateProbe("metrics", _ => Task.FromResult(
|
|
new ProbeResult(true, metrics.ToImmutableDictionary(), [])));
|
|
_tracker.RegisterProbe(probe);
|
|
|
|
var validators = new Dictionary<string, Func<object, bool>>
|
|
{
|
|
["cpu_usage"] = value => (double)value < 80.0 // Should fail - CPU is 95%
|
|
}.ToImmutableDictionary();
|
|
|
|
var expectations = new ConvergenceExpectations(
|
|
RequireAllHealthy: false,
|
|
RequireMetricsAccurate: true,
|
|
MetricValidators: validators);
|
|
|
|
// Act
|
|
var result = await _tracker.WaitForConvergenceAsync(expectations, TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.HasConverged);
|
|
Assert.Contains("Metric 'cpu_usage' failed validation", result.Violations);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unit tests for probe implementations.
|
|
/// </summary>
|
|
public sealed class ProbeTests
|
|
{
|
|
[Fact]
|
|
public async Task ComponentHealthProbe_ReturnsInjectorHealth()
|
|
{
|
|
// Arrange
|
|
var registry = new FailureInjectorRegistry();
|
|
var injector = registry.GetOrCreateInjector("postgres-main");
|
|
await injector.InjectAsync("postgres-main", FailureType.Degraded, TestContext.Current.CancellationToken);
|
|
|
|
var probe = new ComponentHealthProbe(registry, "postgres-main");
|
|
|
|
// Act
|
|
var result = await probe.ProbeAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.IsHealthy);
|
|
Assert.Equal("component:postgres-main", probe.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DelegateProbe_ExecutesDelegate()
|
|
{
|
|
// Arrange
|
|
var executed = false;
|
|
var probe = new DelegateProbe("custom", _ =>
|
|
{
|
|
executed = true;
|
|
return Task.FromResult(new ProbeResult(
|
|
true,
|
|
ImmutableDictionary<string, object>.Empty,
|
|
[]));
|
|
});
|
|
|
|
// Act
|
|
var result = await probe.ProbeAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(executed);
|
|
Assert.True(result.IsHealthy);
|
|
Assert.Equal("custom", probe.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AggregateProbe_CombinesResults()
|
|
{
|
|
// Arrange
|
|
var probe1 = new DelegateProbe("p1", _ => Task.FromResult(
|
|
new ProbeResult(true, new Dictionary<string, object> { ["m1"] = 1 }.ToImmutableDictionary(), [])));
|
|
var probe2 = new DelegateProbe("p2", _ => Task.FromResult(
|
|
new ProbeResult(false, new Dictionary<string, object> { ["m2"] = 2 }.ToImmutableDictionary(), ["error"])));
|
|
|
|
var aggregate = new AggregateProbe("combined", [probe1, probe2]);
|
|
|
|
// Act
|
|
var result = await aggregate.ProbeAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(result.IsHealthy); // One unhealthy means aggregate is unhealthy
|
|
Assert.Equal(2, result.Metrics.Count);
|
|
Assert.Contains("p1:m1", result.Metrics.Keys);
|
|
Assert.Contains("p2:m2", result.Metrics.Keys);
|
|
Assert.Single(result.Anomalies);
|
|
Assert.Contains("p2: error", result.Anomalies);
|
|
Assert.Equal("combined", aggregate.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AggregateProbe_AllHealthy_IsHealthy()
|
|
{
|
|
// Arrange
|
|
var probe1 = new DelegateProbe("p1", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, [])));
|
|
var probe2 = new DelegateProbe("p2", _ => Task.FromResult(
|
|
new ProbeResult(true, ImmutableDictionary<string, object>.Empty, [])));
|
|
|
|
var aggregate = new AggregateProbe("all-healthy", [probe1, probe2]);
|
|
|
|
// Act
|
|
var result = await aggregate.ProbeAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(result.IsHealthy);
|
|
Assert.Empty(result.Anomalies);
|
|
}
|
|
}
|