Files
git.stella-ops.org/tests/StellaOps.Router.Gateway.Tests/Properties/RoutingDecisionPropertyTests.cs
2025-12-24 12:38:34 +02:00

757 lines
24 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Property-based tests for routing decision logic using FsCheck.
/// Tests verify invariants of the DefaultRoutingPlugin routing algorithm.
/// </summary>
public sealed class RoutingDecisionPropertyTests
{
#region Generators
/// <summary>
/// Generates a random ConnectionState with valid values.
/// </summary>
private static Gen<ConnectionState> 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)
};
}
/// <summary>
/// Generates a list of connection candidates.
/// </summary>
private static Gen<List<ConnectionState>> 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();
}
/// <summary>
/// Generates RoutingOptions with valid combinations.
/// </summary>
private static Gen<RoutingOptions> GenerateRoutingOptions()
{
return from preferLocal in Arb.Generate<bool>()
from allowDegraded in Arb.Generate<bool>()
from strictVersion in Arb.Generate<bool>()
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<string?>();
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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> { 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<ConnectionState> candidates,
string gatewayRegion = "eu1")
{
return new RoutingContext
{
Method = "GET",
Path = "/test",
Headers = new Dictionary<string, string>(),
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<ConnectionState> 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
}
/// <summary>
/// Custom Arbitrary for generating ConnectionState instances.
/// </summary>
public class ConnectionArbitrary
{
public static Arbitrary<ConnectionState> 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
};
}
}