// ----------------------------------------------------------------------------- // RoutingDecisionPropertyTests.cs // Sprint: SPRINT_5100_0007_0001_testing_strategy_2026 // Task: TEST-STRAT-5100-004 - Property-based tests for routing/decision logic // Description: FsCheck property tests for DefaultRoutingPlugin routing invariants // ----------------------------------------------------------------------------- using FluentAssertions; using FsCheck; using FsCheck.Xunit; using Microsoft.Extensions.Options; using StellaOps.Router.Common.Enums; using StellaOps.Router.Common.Models; using StellaOps.Router.Gateway.Configuration; using StellaOps.Router.Gateway.Routing; using Xunit; namespace StellaOps.Router.Gateway.Tests.Properties; /// /// Property-based tests for routing decision logic using FsCheck. /// Tests verify invariants of the DefaultRoutingPlugin routing algorithm. /// public sealed class RoutingDecisionPropertyTests { #region Generators /// /// Generates a random ConnectionState with valid values. /// private static Gen GenerateConnection( string? forcedRegion = null, InstanceHealthStatus? forcedStatus = null, string? forcedVersion = null) { return from connectionId in Gen.Elements("conn-1", "conn-2", "conn-3", "conn-4", "conn-5") from serviceName in Gen.Constant("test-service") from version in forcedVersion != null ? Gen.Constant(forcedVersion) : Gen.Elements("1.0.0", "1.1.0", "2.0.0") from region in forcedRegion != null ? Gen.Constant(forcedRegion) : Gen.Elements("eu1", "eu2", "us1", "us2", "ap1") from status in forcedStatus.HasValue ? Gen.Constant(forcedStatus.Value) : Gen.Elements(InstanceHealthStatus.Healthy, InstanceHealthStatus.Degraded, InstanceHealthStatus.Unhealthy) from pingMs in Gen.Choose(1, 500) select new ConnectionState { ConnectionId = $"{connectionId}-{region}", Instance = new ServiceInstance { InstanceId = $"{connectionId}-{region}", ServiceName = serviceName, Version = version, Region = region }, Status = status, AveragePingMs = pingMs, LastHeartbeatUtc = DateTimeOffset.UtcNow.AddSeconds(-pingMs % 60) }; } /// /// Generates a list of connection candidates. /// private static Gen> GenerateCandidates( int minCount = 1, int maxCount = 10, string? forcedRegion = null, InstanceHealthStatus? forcedStatus = null) { return from count in Gen.Choose(minCount, maxCount) from connections in Gen.ListOf(count, GenerateConnection(forcedRegion, forcedStatus)) select connections.DistinctBy(c => c.ConnectionId).ToList(); } /// /// Generates RoutingOptions with valid combinations. /// private static Gen GenerateRoutingOptions() { return from preferLocal in Arb.Generate() from allowDegraded in Arb.Generate() from strictVersion in Arb.Generate() from tieBreaker in Gen.Elements(TieBreakerMode.Random, TieBreakerMode.RoundRobin, TieBreakerMode.LowestLatency) select new RoutingOptions { PreferLocalRegion = preferLocal, AllowDegradedInstances = allowDegraded, StrictVersionMatching = strictVersion, TieBreaker = tieBreaker, RoutingTimeoutMs = 5000, DefaultVersion = null }; } #endregion #region Property Tests - Determinism [Property(MaxTest = 100, Arbitrary = new[] { typeof(ConnectionArbitrary) })] public void SameInputs_ProduceDeterministicDecisions() { // Arrange var options = new RoutingOptions { PreferLocalRegion = true, AllowDegradedInstances = true, StrictVersionMatching = true, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var candidates = CreateFixedCandidates(); // Act - Run routing multiple times var decisions = new List(); for (int i = 0; i < 10; i++) { var decision = plugin.ChooseInstanceAsync( CreateContext("1.0.0", candidates), CancellationToken.None).GetAwaiter().GetResult(); decisions.Add(decision?.Connection?.ConnectionId); } // Assert - All decisions should be identical decisions.All(d => d == decisions[0]).Should().BeTrue( "same inputs with deterministic tie-breaker should produce same routing decision"); } [Property(MaxTest = 100)] public void EmptyCandidates_AlwaysReturnsNull() { // Arrange var optionsGen = GenerateRoutingOptions(); var options = optionsGen.Sample(1, 1).First(); var plugin = CreatePlugin("eu1", options); // Act var decision = plugin.ChooseInstanceAsync( CreateContext("1.0.0", []), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().BeNull("empty candidates should always return null"); } #endregion #region Property Tests - Health Preference [Property(MaxTest = 100)] public void HealthyPreferred_WhenHealthyExists_NeverChoosesDegraded() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = true, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); // Create mixed candidates with both healthy and degraded var healthy = new ConnectionState { ConnectionId = "healthy-1", Instance = new ServiceInstance { InstanceId = "healthy-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 100 // Higher latency but healthy }; var degraded = new ConnectionState { ConnectionId = "degraded-1", Instance = new ServiceInstance { InstanceId = "degraded-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Degraded, AveragePingMs = 1 // Lower latency but degraded }; var candidates = new List { degraded, healthy }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull(); decision!.Connection.Status.Should().Be(InstanceHealthStatus.Healthy, "healthy instances should always be preferred over degraded"); } [Property(MaxTest = 100)] public void WhenOnlyDegraded_AndAllowDegradedTrue_SelectsDegraded() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = true, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var degraded1 = new ConnectionState { ConnectionId = "degraded-1", Instance = new ServiceInstance { InstanceId = "degraded-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Degraded, AveragePingMs = 10 }; var degraded2 = new ConnectionState { ConnectionId = "degraded-2", Instance = new ServiceInstance { InstanceId = "degraded-2", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Degraded, AveragePingMs = 20 }; var candidates = new List { degraded1, degraded2 }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull("degraded instances should be selected when no healthy available and AllowDegradedInstances=true"); decision!.Connection.Status.Should().Be(InstanceHealthStatus.Degraded); } [Property(MaxTest = 100)] public void WhenOnlyDegraded_AndAllowDegradedFalse_ReturnsNull() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = false, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var degraded = new ConnectionState { ConnectionId = "degraded-1", Instance = new ServiceInstance { InstanceId = "degraded-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Degraded, AveragePingMs = 10 }; var candidates = new List { degraded }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().BeNull("degraded instances should not be selected when AllowDegradedInstances=false"); } #endregion #region Property Tests - Region Tier Preference [Property(MaxTest = 100)] public void LocalRegion_AlwaysPreferred_WhenAvailable() { // Arrange var options = new RoutingOptions { PreferLocalRegion = true, AllowDegradedInstances = false, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var gatewayRegion = "eu1"; var plugin = CreatePlugin(gatewayRegion, options); var localInstance = new ConnectionState { ConnectionId = "local-1", Instance = new ServiceInstance { InstanceId = "local-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" // Same as gateway }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 100 // Higher latency }; var remoteInstance = new ConnectionState { ConnectionId = "remote-1", Instance = new ServiceInstance { InstanceId = "remote-1", ServiceName = "test-service", Version = "1.0.0", Region = "us1" // Different region }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 1 // Lower latency }; var candidates = new List { remoteInstance, localInstance }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates, gatewayRegion), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull(); decision!.Connection.Instance.Region.Should().Be(gatewayRegion, "local region should always be preferred when PreferLocalRegion=true"); } [Property(MaxTest = 100)] public void WhenNoLocalRegion_FallsBackToRemote() { // Arrange var options = new RoutingOptions { PreferLocalRegion = true, AllowDegradedInstances = false, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var gatewayRegion = "eu1"; var plugin = CreatePlugin(gatewayRegion, options); var remoteInstance = new ConnectionState { ConnectionId = "remote-1", Instance = new ServiceInstance { InstanceId = "remote-1", ServiceName = "test-service", Version = "1.0.0", Region = "us1" // Different region }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 10 }; var candidates = new List { remoteInstance }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates, gatewayRegion), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull("should fallback to remote region when no local available"); decision!.Connection.Instance.Region.Should().Be("us1"); } #endregion #region Property Tests - Version Matching [Property(MaxTest = 100)] public void StrictVersionMatching_RejectsNonMatchingVersions() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = true, StrictVersionMatching = true, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var v1Instance = new ConnectionState { ConnectionId = "v1-1", Instance = new ServiceInstance { InstanceId = "v1-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 10 }; var v2Instance = new ConnectionState { ConnectionId = "v2-1", Instance = new ServiceInstance { InstanceId = "v2-1", ServiceName = "test-service", Version = "2.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 10 }; var candidates = new List { v1Instance, v2Instance }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext("2.0.0", candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull(); decision!.Connection.Instance.Version.Should().Be("2.0.0", "strict version matching should only select matching version"); } [Property(MaxTest = 100)] public void RequestedVersion_NotAvailable_ReturnsNull() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = true, StrictVersionMatching = true, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var v1Instance = new ConnectionState { ConnectionId = "v1-1", Instance = new ServiceInstance { InstanceId = "v1-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 10 }; var candidates = new List { v1Instance }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext("3.0.0", candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().BeNull("requested version not available should return null"); } #endregion #region Property Tests - Tie-Breaker Behavior [Property(MaxTest = 100)] public void LowestLatency_TieBreaker_SelectsLowestPing() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = false, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var highLatency = new ConnectionState { ConnectionId = "high-1", Instance = new ServiceInstance { InstanceId = "high-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 100 }; var lowLatency = new ConnectionState { ConnectionId = "low-1", Instance = new ServiceInstance { InstanceId = "low-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 10 }; var candidates = new List { highLatency, lowLatency }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull(); decision!.Connection.ConnectionId.Should().Be("low-1", "lowest latency tie-breaker should select instance with lowest ping"); } #endregion #region Property Tests - Invariants [Property(MaxTest = 100)] public void DecisionAlwaysIncludesEndpoint() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = true, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var candidates = CreateFixedCandidates(); // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().NotBeNull(); decision!.Endpoint.Should().NotBeNull("decision should always include endpoint"); decision.Connection.Should().NotBeNull("decision should always include connection"); } [Property(MaxTest = 100)] public void UnhealthyInstances_NeverSelected() { // Arrange var options = new RoutingOptions { PreferLocalRegion = false, AllowDegradedInstances = true, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var plugin = CreatePlugin("eu1", options); var unhealthy = new ConnectionState { ConnectionId = "unhealthy-1", Instance = new ServiceInstance { InstanceId = "unhealthy-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Unhealthy, AveragePingMs = 1 // Even with lowest latency }; var candidates = new List { unhealthy }; // Act var decision = plugin.ChooseInstanceAsync( CreateContext(null, candidates), CancellationToken.None).GetAwaiter().GetResult(); // Assert decision.Should().BeNull("unhealthy instances should never be selected"); } #endregion #region Helpers private static DefaultRoutingPlugin CreatePlugin(string gatewayRegion, RoutingOptions? options = null) { options ??= new RoutingOptions { PreferLocalRegion = true, AllowDegradedInstances = true, StrictVersionMatching = false, TieBreaker = TieBreakerMode.LowestLatency, RoutingTimeoutMs = 5000 }; var gatewayConfig = new RouterNodeConfig { Region = gatewayRegion, NeighborRegions = ["eu2", "eu3"] }; return new DefaultRoutingPlugin( Options.Create(options), Options.Create(gatewayConfig)); } private static RoutingContext CreateContext( string? requestedVersion, List candidates, string gatewayRegion = "eu1") { return new RoutingContext { Method = "GET", Path = "/test", Headers = new Dictionary(), Endpoint = new EndpointDescriptor { ServiceName = "test-service", Version = "1.0.0", Method = "GET", Path = "/test" }, AvailableConnections = candidates, GatewayRegion = gatewayRegion, RequestedVersion = requestedVersion, CancellationToken = CancellationToken.None }; } private static List CreateFixedCandidates() { return [ new ConnectionState { ConnectionId = "conn-1", Instance = new ServiceInstance { InstanceId = "conn-1", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 10 }, new ConnectionState { ConnectionId = "conn-2", Instance = new ServiceInstance { InstanceId = "conn-2", ServiceName = "test-service", Version = "1.0.0", Region = "eu1" }, Status = InstanceHealthStatus.Healthy, AveragePingMs = 20 } ]; } #endregion } /// /// Custom Arbitrary for generating ConnectionState instances. /// public class ConnectionArbitrary { public static Arbitrary ConnectionState() { return Arb.From(Gen.Elements( CreateConn("c1", "eu1", InstanceHealthStatus.Healthy, 10), CreateConn("c2", "eu1", InstanceHealthStatus.Healthy, 20), CreateConn("c3", "eu2", InstanceHealthStatus.Healthy, 30), CreateConn("c4", "us1", InstanceHealthStatus.Degraded, 5))); } private static ConnectionState CreateConn(string id, string region, InstanceHealthStatus status, int pingMs) { return new ConnectionState { ConnectionId = id, Instance = new ServiceInstance { InstanceId = id, ServiceName = "test-service", Version = "1.0.0", Region = region }, Status = status, AveragePingMs = pingMs }; } }