553 lines
19 KiB
C#
553 lines
19 KiB
C#
using StellaOps.Router.Common.Abstractions;
|
|
using StellaOps.Router.Common.Enums;
|
|
using StellaOps.Router.Common.Models;
|
|
|
|
using StellaOps.TestKit;
|
|
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
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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]);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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"));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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"));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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"));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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);
|
|
});
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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");
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[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
|
|
}
|