//
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
//
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Testing.Temporal;
namespace StellaOps.Testing.Chaos.Tests;
///
/// Unit tests for .
///
public sealed class ConvergenceTrackerTests
{
private readonly SimulatedTimeProvider _timeProvider;
private readonly DefaultConvergenceTracker _tracker;
public ConvergenceTrackerTests()
{
_timeProvider = new SimulatedTimeProvider();
_tracker = new DefaultConvergenceTracker(
_timeProvider,
NullLogger.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.Empty, [])));
var probe2 = new DelegateProbe("probe-2", _ => Task.FromResult(
new ProbeResult(false, ImmutableDictionary.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.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.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.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.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.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.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.Empty, []);
});
_tracker.RegisterProbe(probe);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
// Act & Assert
await Assert.ThrowsAsync(
() => _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.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 { ["cpu_usage"] = 95.0 };
var probe = new DelegateProbe("metrics", _ => Task.FromResult(
new ProbeResult(true, metrics.ToImmutableDictionary(), [])));
_tracker.RegisterProbe(probe);
var validators = new Dictionary>
{
["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);
}
}
///
/// Unit tests for probe implementations.
///
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.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 { ["m1"] = 1 }.ToImmutableDictionary(), [])));
var probe2 = new DelegateProbe("p2", _ => Task.FromResult(
new ProbeResult(false, new Dictionary { ["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.Empty, [])));
var probe2 = new DelegateProbe("p2", _ => Task.FromResult(
new ProbeResult(true, ImmutableDictionary.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);
}
}