product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Property-based tests ensuring routing determinism: same message + same configuration = same route.
|
||||
/// </summary>
|
||||
public sealed class RoutingDeterminismTests
|
||||
{
|
||||
#region Core Determinism Property Tests
|
||||
|
||||
[Fact]
|
||||
public void SameContextAndConnections_AlwaysSelectsSameRoute()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act - Run selection multiple times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be identical
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentConnectionOrder_ProducesSameResult()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections1 = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var connections2 = CreateConnectionSet(
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var result1 = selector.SelectConnection(context, connections1);
|
||||
var result2 = selector.SelectConnection(context, connections2);
|
||||
|
||||
// Assert - Should select same connection regardless of input order
|
||||
result1.ConnectionId.Should().Be(result2.ConnectionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePathAndMethod_WithSameHeaders_ProducesSameRouteKey()
|
||||
{
|
||||
// Arrange
|
||||
var context1 = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/123",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-Correlation-Id"] = "corr-456",
|
||||
["Accept"] = "application/json"
|
||||
},
|
||||
GatewayRegion = "us-east"
|
||||
};
|
||||
|
||||
var context2 = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/123",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Accept"] = "application/json",
|
||||
["X-Correlation-Id"] = "corr-456"
|
||||
},
|
||||
GatewayRegion = "us-east"
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = ComputeRouteKey(context1);
|
||||
var key2 = ComputeRouteKey(context2);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Region Affinity Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void SameRegion_AlwaysPreferredWhenAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithRegion("us-east");
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-remote", "instance-1", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-local", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select local region
|
||||
results.Should().AllSatisfy(r => r.Instance.Region.Should().Be("us-east"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoLocalRegion_FallbackIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithRegion("ap-southeast");
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be identical (deterministic fallback)
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Selection Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void SameRequestedVersion_AlwaysSelectsMatchingConnection()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithVersion("2.0.0");
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v2", "instance-2", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v3", "instance-3", "service-a", "3.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select version 2.0.0
|
||||
results.Should().AllSatisfy(r => r.Instance.Version.Should().Be("2.0.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoVersionRequested_LatestStableIsSelectedDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithVersion(null);
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "instance-1", "service-a", "1.2.3", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v2", "instance-2", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v3", "instance-3", "service-a", "1.9.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results identical (should pick highest version deterministically)
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Health Status Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void HealthyConnectionsPreferred_Deterministically()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-unhealthy", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Unhealthy),
|
||||
("conn-healthy", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-degraded", "instance-3", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Degraded));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select healthy connection
|
||||
results.Should().AllSatisfy(r => r.ConnectionId.Should().Be("conn-healthy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DegradedConnectionSelected_WhenNoHealthyAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-unhealthy", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Unhealthy),
|
||||
("conn-degraded-1", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Degraded),
|
||||
("conn-degraded-2", "instance-3", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Degraded));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results identical (deterministic selection among degraded)
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
results[0].Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrainingConnectionsExcluded()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-draining", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Draining),
|
||||
("conn-healthy", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var result = selector.SelectConnection(context, connections);
|
||||
|
||||
// Assert - Never select draining connections
|
||||
result.ConnectionId.Should().Be("conn-healthy");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Criteria Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void RegionThenVersionThenHealth_OrderingIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var context = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/data",
|
||||
GatewayRegion = "us-east",
|
||||
RequestedVersion = "2.0.0",
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "2.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Degraded),
|
||||
("conn-4", "instance-4", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Should select conn-4: us-east region + version 2.0.0 + healthy
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Instance.Region.Should().Be("us-east");
|
||||
r.Instance.Version.Should().Be("2.0.0");
|
||||
r.Status.Should().Be(InstanceHealthStatus.Healthy);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TieBreaker_UsesConnectionIdForConsistency()
|
||||
{
|
||||
// Arrange - Two identical connections except ID
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-zzz", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-aaa", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select alphabetically first connection ID for tie-breaking
|
||||
results.Should().AllSatisfy(r => r.ConnectionId.Should().Be("conn-aaa"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Matching Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void PathParameterMatching_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}/orders/{orderId}");
|
||||
var testPaths = new[]
|
||||
{
|
||||
"/api/users/123/orders/456",
|
||||
"/api/users/abc/orders/xyz",
|
||||
"/api/users/user-1/orders/order-2"
|
||||
};
|
||||
|
||||
// Act & Assert - Each path should always produce same match result
|
||||
foreach (var path in testPaths)
|
||||
{
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => matcher.IsMatch(path))
|
||||
.ToList();
|
||||
|
||||
results.Should().AllBeEquivalentTo(results[0], $"Path {path} should match consistently");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleEndpoints_SamePath_SelectsFirstMatchDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
CreateEndpoint("GET", "/api/users/{id}", "service-users", "1.0.0"),
|
||||
CreateEndpoint("GET", "/api/{resource}/{id}", "service-generic", "1.0.0")
|
||||
};
|
||||
|
||||
var selector = new EndpointMatcher(endpoints);
|
||||
var path = "/api/users/123";
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.FindBestMatch("GET", path))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always selects most specific match
|
||||
results.Should().AllSatisfy(r =>
|
||||
r.ServiceName.Should().Be("service-users"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static RoutingContext CreateDeterministicContext()
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = "us-east",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-Request-Id"] = "deterministic-request-id"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContextWithRegion(string region)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = region,
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContextWithVersion(string? version)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = "us-east",
|
||||
RequestedVersion = version,
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateConnectionSet(
|
||||
params (string connId, string instId, string service, string version, string region, InstanceHealthStatus status)[] connections)
|
||||
{
|
||||
return connections.Select(c => new ConnectionState
|
||||
{
|
||||
ConnectionId = c.connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = c.instId,
|
||||
ServiceName = c.service,
|
||||
Version = c.version,
|
||||
Region = c.region
|
||||
},
|
||||
Status = c.status,
|
||||
TransportType = TransportType.InMemory,
|
||||
ConnectedAtUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
LastHeartbeatUtc = new DateTime(2025, 1, 1, 0, 0, 1, DateTimeKind.Utc)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(string method, string path, string service, string version)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = service,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeRouteKey(RoutingContext context)
|
||||
{
|
||||
// Route key computation should be deterministic regardless of header order
|
||||
var sortedHeaders = context.Headers
|
||||
.OrderBy(h => h.Key, StringComparer.Ordinal)
|
||||
.Select(h => $"{h.Key}={h.Value}");
|
||||
|
||||
return $"{context.Method}|{context.Path}|{string.Join("&", sortedHeaders)}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Support Classes
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic route selector for testing.
|
||||
/// Implements the same algorithm that production code should use.
|
||||
/// </summary>
|
||||
private sealed class DeterministicRouteSelector
|
||||
{
|
||||
public ConnectionState SelectConnection(RoutingContext context, IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
// Filter out draining and unhealthy connections
|
||||
var candidates = connections
|
||||
.Where(c => c.Status is InstanceHealthStatus.Healthy or InstanceHealthStatus.Degraded)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No available connections");
|
||||
}
|
||||
|
||||
// Apply version filter if requested
|
||||
if (!string.IsNullOrEmpty(context.RequestedVersion))
|
||||
{
|
||||
var versionMatches = candidates
|
||||
.Where(c => c.Instance.Version == context.RequestedVersion)
|
||||
.ToList();
|
||||
|
||||
if (versionMatches.Count > 0)
|
||||
{
|
||||
candidates = versionMatches;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer local region
|
||||
var localRegion = candidates
|
||||
.Where(c => c.Instance.Region == context.GatewayRegion)
|
||||
.ToList();
|
||||
|
||||
if (localRegion.Count > 0)
|
||||
{
|
||||
candidates = localRegion;
|
||||
}
|
||||
|
||||
// Prefer healthy over degraded
|
||||
var healthy = candidates
|
||||
.Where(c => c.Status == InstanceHealthStatus.Healthy)
|
||||
.ToList();
|
||||
|
||||
if (healthy.Count > 0)
|
||||
{
|
||||
candidates = healthy;
|
||||
}
|
||||
|
||||
// Deterministic tie-breaker: sort by connection ID
|
||||
return candidates
|
||||
.OrderBy(c => c.ConnectionId, StringComparer.Ordinal)
|
||||
.First();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint matcher for testing deterministic endpoint selection.
|
||||
/// </summary>
|
||||
private sealed class EndpointMatcher
|
||||
{
|
||||
private readonly IReadOnlyList<(PathMatcher Matcher, EndpointDescriptor Endpoint)> _endpoints;
|
||||
|
||||
public EndpointMatcher(IEnumerable<EndpointDescriptor> endpoints)
|
||||
{
|
||||
// Sort by specificity: more specific paths first (fewer parameters)
|
||||
_endpoints = endpoints
|
||||
.OrderBy(e => e.Path.Count(c => c == '{'))
|
||||
.ThenBy(e => e.Path, StringComparer.Ordinal)
|
||||
.Select(e => (new PathMatcher(e.Path), e))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public EndpointDescriptor FindBestMatch(string method, string path)
|
||||
{
|
||||
foreach (var (matcher, endpoint) in _endpoints)
|
||||
{
|
||||
if (endpoint.Method == method && matcher.IsMatch(path))
|
||||
{
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No endpoint found for {method} {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user