consolidate the tests locations

This commit is contained in:
StellaOps Bot
2025-12-26 01:48:24 +02:00
parent 17613acf57
commit 39359da171
2031 changed files with 2607 additions and 476 deletions

View File

@@ -0,0 +1,226 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Services;
using StellaOps.Router.Gateway.State;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class ConnectionManagerTests
{
[Fact]
public async Task StartAsync_WhenHelloInvalid_RejectsAndClosesChannel()
{
var (manager, server, registry, routingState) = Create();
var client = CreateClient(registry, server);
try
{
await manager.StartAsync(CancellationToken.None);
var invalid = new InstanceDescriptor
{
InstanceId = "inv-1",
ServiceName = "",
Version = "1.0.0",
Region = "eu1"
};
await client.ConnectAsync(
invalid,
endpoints:
[
new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
}
],
CancellationToken.None);
await EventuallyAsync(
() => registry.Count == 0,
timeout: TimeSpan.FromSeconds(5));
routingState.GetAllConnections().Should().BeEmpty();
}
finally
{
client.Dispose();
await manager.StopAsync(CancellationToken.None);
server.Dispose();
registry.Dispose();
}
}
[Fact]
public async Task WhenClientDisconnects_RemovesFromRoutingState()
{
var (manager, server, registry, routingState) = Create();
var client = CreateClient(registry, server);
try
{
await manager.StartAsync(CancellationToken.None);
await client.ConnectAsync(
CreateInstance("inventory", "1.0.0", "inv-1"),
endpoints:
[
new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
}
],
CancellationToken.None);
await EventuallyAsync(
() => routingState.GetAllConnections().Count == 1,
timeout: TimeSpan.FromSeconds(5));
client.Dispose();
await EventuallyAsync(
() => routingState.GetAllConnections().Count == 0,
timeout: TimeSpan.FromSeconds(5));
}
finally
{
client.Dispose();
await manager.StopAsync(CancellationToken.None);
server.Dispose();
registry.Dispose();
}
}
[Fact]
public async Task WhenMultipleClientsConnect_TracksAndCleansIndependently()
{
var (manager, server, registry, routingState) = Create();
var client1 = CreateClient(registry, server);
var client2 = CreateClient(registry, server);
try
{
await manager.StartAsync(CancellationToken.None);
var endpoints =
new[]
{
new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
}
};
await client1.ConnectAsync(
CreateInstance("inventory", "1.0.0", "inv-1"),
endpoints,
CancellationToken.None);
await client2.ConnectAsync(
CreateInstance("inventory", "1.0.0", "inv-2"),
endpoints,
CancellationToken.None);
await EventuallyAsync(
() => routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items").Count == 2,
timeout: TimeSpan.FromSeconds(5));
var before = routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items")
.Select(c => c.Instance.InstanceId)
.ToHashSet(StringComparer.Ordinal);
before.Should().BeEquivalentTo(new[] { "inv-1", "inv-2" });
client1.Dispose();
await EventuallyAsync(
() => routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items").Count == 1,
timeout: TimeSpan.FromSeconds(5));
var after = routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items")
.Single()
.Instance.InstanceId;
after.Should().Be("inv-2");
}
finally
{
client1.Dispose();
client2.Dispose();
await manager.StopAsync(CancellationToken.None);
server.Dispose();
registry.Dispose();
}
}
private static (ConnectionManager Manager, InMemoryTransportServer Server, InMemoryConnectionRegistry Registry, InMemoryRoutingState RoutingState) Create()
{
var registry = new InMemoryConnectionRegistry();
var server = new InMemoryTransportServer(
registry,
Options.Create(new InMemoryTransportOptions()),
NullLogger<InMemoryTransportServer>.Instance);
var routingState = new InMemoryRoutingState();
var manager = new ConnectionManager(
server,
registry,
routingState,
NullLogger<ConnectionManager>.Instance);
return (manager, server, registry, routingState);
}
private static InMemoryTransportClient CreateClient(InMemoryConnectionRegistry registry, InMemoryTransportServer server)
{
return new InMemoryTransportClient(
registry,
Options.Create(new InMemoryTransportOptions()),
NullLogger<InMemoryTransportClient>.Instance,
server);
}
private static InstanceDescriptor CreateInstance(string serviceName, string version, string instanceId)
{
return new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = serviceName,
Version = version,
Region = "eu1"
};
}
private static async Task EventuallyAsync(Func<bool> predicate, TimeSpan timeout, TimeSpan? pollInterval = null)
{
pollInterval ??= TimeSpan.FromMilliseconds(25);
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (predicate())
{
return;
}
await Task.Delay(pollInterval.Value);
}
predicate().Should().BeTrue("condition should become true within {0}", timeout);
}
}

View File

@@ -0,0 +1,259 @@
using FluentAssertions;
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;
public sealed class DefaultRoutingPluginTests
{
[Fact]
public async Task ChooseInstanceAsync_WhenNoCandidates_ReturnsNull()
{
var plugin = CreatePlugin(gatewayRegion: "eu1");
var decision = await plugin.ChooseInstanceAsync(
new RoutingContext
{
Method = "GET",
Path = "/items",
Headers = new Dictionary<string, string>(),
Endpoint = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
},
AvailableConnections = [],
GatewayRegion = "eu1",
RequestedVersion = null,
CancellationToken = CancellationToken.None
},
CancellationToken.None);
decision.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_WhenRequestedVersionDoesNotMatch_ReturnsNull()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null
});
var candidates = new List<ConnectionState>
{
CreateConnection("inv-1", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy)
};
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "2.0.0",
candidates: candidates),
CancellationToken.None);
decision.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_PrefersHealthyOverDegraded()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
});
var degraded = CreateConnection("inv-degraded", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Degraded);
degraded.AveragePingMs = 1;
var healthy = CreateConnection("inv-healthy", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy);
healthy.AveragePingMs = 50;
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "1.0.0",
candidates: [degraded, healthy]),
CancellationToken.None);
decision.Should().NotBeNull();
decision!.Connection.ConnectionId.Should().Be("inv-healthy");
}
[Fact]
public async Task ChooseInstanceAsync_PrefersLocalRegionOverRemote()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
});
var remote = CreateConnection("inv-us1", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
var local = CreateConnection("inv-eu1", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy);
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "1.0.0",
candidates: [remote, local]),
CancellationToken.None);
decision.Should().NotBeNull();
decision!.Connection.ConnectionId.Should().Be("inv-eu1");
}
[Fact]
public async Task ChooseInstanceAsync_WhenNoLocal_UsesNeighborRegion()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
gatewayNeighbors: ["eu2"],
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
});
var neighbor = CreateConnection("inv-eu2", "inventory", "1.0.0", region: "eu2", status: InstanceHealthStatus.Healthy);
var remote = CreateConnection("inv-us1", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "1.0.0",
candidates: [remote, neighbor]),
CancellationToken.None);
decision.Should().NotBeNull();
decision!.Connection.ConnectionId.Should().Be("inv-eu2");
}
[Fact]
public async Task ChooseInstanceAsync_WhenTied_UsesRoundRobin()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = false,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin,
PingToleranceMs = 1_000
});
var heartbeat = DateTime.UtcNow;
var a = CreateConnection("inv-a", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
a.AveragePingMs = 10;
a.LastHeartbeatUtc = heartbeat;
var b = CreateConnection("inv-b", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
b.AveragePingMs = 10;
b.LastHeartbeatUtc = heartbeat;
var ctx = CreateContext(requestedVersion: "1.0.0", candidates: [a, b]);
var decision1 = await plugin.ChooseInstanceAsync(ctx, CancellationToken.None);
var decision2 = await plugin.ChooseInstanceAsync(ctx, CancellationToken.None);
decision1.Should().NotBeNull();
decision2.Should().NotBeNull();
decision1!.Connection.ConnectionId.Should().Be("inv-a");
decision2!.Connection.ConnectionId.Should().Be("inv-b");
}
private static DefaultRoutingPlugin CreatePlugin(
string gatewayRegion,
RoutingOptions? options = null,
IReadOnlyList<string>? gatewayNeighbors = null)
{
options ??= new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
};
var node = new RouterNodeConfig
{
Region = gatewayRegion,
NeighborRegions = gatewayNeighbors?.ToList() ?? []
};
return new DefaultRoutingPlugin(
Options.Create(options),
Options.Create(node));
}
private static RoutingContext CreateContext(
string? requestedVersion,
IReadOnlyList<ConnectionState> candidates)
{
return new RoutingContext
{
Method = "GET",
Path = "/items",
Headers = new Dictionary<string, string>(),
Endpoint = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
},
AvailableConnections = candidates,
GatewayRegion = "eu1",
RequestedVersion = requestedVersion,
CancellationToken = CancellationToken.None
};
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
string version,
string region,
InstanceHealthStatus status)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = serviceName,
Version = version,
Region = region
},
Status = status,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
}

View File

@@ -0,0 +1,139 @@
using FluentAssertions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.State;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class InMemoryRoutingStateTests
{
[Fact]
public void ResolveEndpoint_WhenExactMatch_ReturnsEndpointDescriptor()
{
var routingState = new InMemoryRoutingState();
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
connection.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
routingState.AddConnection(connection);
var resolved = routingState.ResolveEndpoint("GET", "/items");
resolved.Should().NotBeNull();
resolved!.ServiceName.Should().Be("inventory");
resolved.Version.Should().Be("1.0.0");
resolved.Method.Should().Be("GET");
resolved.Path.Should().Be("/items");
}
[Fact]
public void ResolveEndpoint_WhenTemplateMatch_ReturnsEndpointDescriptor()
{
var routingState = new InMemoryRoutingState();
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
connection.Endpoints[("GET", "/items/{sku}")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items/{sku}"
};
routingState.AddConnection(connection);
var resolved = routingState.ResolveEndpoint("GET", "/items/SKU-001");
resolved.Should().NotBeNull();
resolved!.Path.Should().Be("/items/{sku}");
}
[Fact]
public void RemoveConnection_RemovesEndpointsFromIndex()
{
var routingState = new InMemoryRoutingState();
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
connection.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
routingState.AddConnection(connection);
routingState.ResolveEndpoint("GET", "/items").Should().NotBeNull();
routingState.RemoveConnection("conn-1");
routingState.ResolveEndpoint("GET", "/items").Should().BeNull();
routingState.GetAllConnections().Should().BeEmpty();
}
[Fact]
public void GetConnectionsFor_FiltersByServiceAndVersion()
{
var routingState = new InMemoryRoutingState();
var inventoryV1 = CreateConnection("inv-v1", "inventory", "1.0.0", region: "test");
inventoryV1.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
var inventoryV2 = CreateConnection("inv-v2", "inventory", "2.0.0", region: "test");
inventoryV2.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "2.0.0",
Method = "GET",
Path = "/items"
};
routingState.AddConnection(inventoryV1);
routingState.AddConnection(inventoryV2);
var connections = routingState.GetConnectionsFor(
serviceName: "inventory",
version: "1.0.0",
method: "GET",
path: "/items");
connections.Should().HaveCount(1);
connections[0].ConnectionId.Should().Be("inv-v1");
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
string version,
string region)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = serviceName,
Version = version,
Region = region
},
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
}

View File

@@ -0,0 +1,48 @@
using FluentAssertions;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class InMemoryValkeyRateLimitStoreTests
{
[Fact]
public async Task IncrementAndCheckAsync_UsesSmallestWindowAsRepresentativeWhenAllowed()
{
var store = new InMemoryValkeyRateLimitStore();
var rules = new[]
{
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
new RateLimitRule { PerSeconds = 60, MaxRequests = 10 },
};
var result = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
result.Allowed.Should().BeTrue();
result.WindowSeconds.Should().Be(60);
result.Limit.Should().Be(10);
result.CurrentCount.Should().Be(1);
result.RetryAfterSeconds.Should().Be(0);
}
[Fact]
public async Task IncrementAndCheckAsync_DeniesWhenLimitExceeded()
{
var store = new InMemoryValkeyRateLimitStore();
var rules = new[]
{
new RateLimitRule { PerSeconds = 300, MaxRequests = 1 },
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
};
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
denied.Allowed.Should().BeFalse();
denied.WindowSeconds.Should().Be(300);
denied.Limit.Should().Be(1);
denied.CurrentCount.Should().Be(2);
denied.RetryAfterSeconds.Should().BeGreaterThan(0);
}
}

View File

@@ -0,0 +1,47 @@
using FluentAssertions;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class InstanceRateLimiterTests
{
[Fact]
public void TryAcquire_ReportsMostConstrainedRuleWhenAllowed()
{
var limiter = new InstanceRateLimiter(
[
new RateLimitRule { PerSeconds = 300, MaxRequests = 2 },
new RateLimitRule { PerSeconds = 30, MaxRequests = 1 },
]);
var decision = limiter.TryAcquire("svc");
decision.Allowed.Should().BeTrue();
decision.Scope.Should().Be(RateLimitScope.Instance);
decision.WindowSeconds.Should().Be(30);
decision.Limit.Should().Be(1);
decision.CurrentCount.Should().Be(1);
}
[Fact]
public void TryAcquire_DeniesWhenAnyRuleIsExceeded()
{
var limiter = new InstanceRateLimiter(
[
new RateLimitRule { PerSeconds = 300, MaxRequests = 2 },
new RateLimitRule { PerSeconds = 30, MaxRequests = 1 },
]);
limiter.TryAcquire("svc").Allowed.Should().BeTrue();
var decision = limiter.TryAcquire("svc");
decision.Allowed.Should().BeFalse();
decision.Scope.Should().Be(RateLimitScope.Instance);
decision.WindowSeconds.Should().Be(30);
decision.Limit.Should().Be(1);
decision.RetryAfterSeconds.Should().BeGreaterThan(0);
}
}

View File

@@ -0,0 +1,40 @@
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
internal static class IntegrationTestSettings
{
public static bool IsEnabled
{
get
{
var value = Environment.GetEnvironmentVariable("STELLAOPS_INTEGRATION_TESTS");
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase);
}
}
}
public sealed class IntegrationFactAttribute : FactAttribute
{
public IntegrationFactAttribute()
{
if (!IntegrationTestSettings.IsEnabled)
{
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
}
}
}
public sealed class IntegrationTheoryAttribute : TheoryAttribute
{
public IntegrationTheoryAttribute()
{
if (!IntegrationTestSettings.IsEnabled)
{
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
}
}
}

View File

@@ -0,0 +1,166 @@
using FluentAssertions;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class LimitInheritanceResolverTests
{
[Fact]
public void ResolveEnvironmentTarget_UsesEnvironmentDefaultsWhenNoMicroserviceOverride()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
}
}.Validate();
var resolver = new LimitInheritanceResolver(config);
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
target.Enabled.Should().BeTrue();
target.Kind.Should().Be(RateLimitTargetKind.EnvironmentDefault);
target.RouteName.Should().BeNull();
target.TargetKey.Should().Be("scanner");
target.Rules.Should().ContainSingle();
target.Rules[0].PerSeconds.Should().Be(60);
target.Rules[0].MaxRequests.Should().Be(600);
}
[Fact]
public void ResolveEnvironmentTarget_UsesMicroserviceOverrideWhenPresent()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scanner"] = new MicroserviceLimitsConfig
{
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }],
}
}
}
}.Validate();
var resolver = new LimitInheritanceResolver(config);
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
target.Enabled.Should().BeTrue();
target.Kind.Should().Be(RateLimitTargetKind.Microservice);
target.RouteName.Should().BeNull();
target.TargetKey.Should().Be("scanner");
target.Rules.Should().ContainSingle();
target.Rules[0].PerSeconds.Should().Be(10);
target.Rules[0].MaxRequests.Should().Be(1);
}
[Fact]
public void ResolveEnvironmentTarget_DisablesWhenNoRulesAtAnyLevel()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
}
}.Validate();
var resolver = new LimitInheritanceResolver(config);
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
target.Enabled.Should().BeFalse();
}
[Fact]
public void ResolveEnvironmentTarget_UsesRouteOverrideWhenPresent()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scanner"] = new MicroserviceLimitsConfig
{
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scan_submit"] = new RouteLimitsConfig
{
Pattern = "/api/scans",
MatchType = RouteMatchType.Exact,
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
}
}
}
}
}
}.Validate();
var resolver = new LimitInheritanceResolver(config);
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
target.Enabled.Should().BeTrue();
target.Kind.Should().Be(RateLimitTargetKind.Route);
target.RouteName.Should().Be("scan_submit");
target.TargetKey.Should().Be("scanner:scan_submit");
target.Rules.Should().ContainSingle();
target.Rules[0].PerSeconds.Should().Be(300);
target.Rules[0].MaxRequests.Should().Be(1);
}
[Fact]
public void ResolveEnvironmentTarget_DoesNotTreatRouteAsOverrideWhenItHasNoRules()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scanner"] = new MicroserviceLimitsConfig
{
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["named_only"] = new RouteLimitsConfig
{
Pattern = "/api/scans",
MatchType = RouteMatchType.Exact
}
}
}
}
}
}.Validate();
var resolver = new LimitInheritanceResolver(config);
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
target.Enabled.Should().BeTrue();
target.Kind.Should().Be(RateLimitTargetKind.Microservice);
target.RouteName.Should().BeNull();
target.TargetKey.Should().Be("scanner");
}
}

View File

@@ -0,0 +1,218 @@
using System.Security.Claims;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.Middleware;
using StellaOps.Router.Gateway.State;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class MiddlewareErrorScenarioTests
{
[Fact]
public async Task EndpointResolutionMiddleware_WhenNoEndpoint_Returns404StructuredError()
{
var context = CreateContext(method: "GET", path: "/missing");
var routingState = new InMemoryRoutingState();
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(context, routingState);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("Endpoint not found");
body.GetProperty("status").GetInt32().Should().Be(404);
body.GetProperty("method").GetString().Should().Be("GET");
body.GetProperty("path").GetString().Should().Be("/missing");
body.GetProperty("traceId").GetString().Should().Be("trace-1");
}
[Fact]
public async Task RoutingDecisionMiddleware_WhenNoInstances_Returns503StructuredError()
{
var context = CreateContext(method: "GET", path: "/items");
context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
var routingState = new InMemoryRoutingState();
var plugin = new NullRoutingPlugin();
var nextCalled = false;
var middleware = new RoutingDecisionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(
context,
plugin,
routingState,
Options.Create(new RouterNodeConfig { Region = "eu1", NodeId = "gw-eu1-01" }),
Options.Create(new RoutingOptions { DefaultVersion = null }));
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("No instances available");
body.GetProperty("status").GetInt32().Should().Be(503);
body.GetProperty("service").GetString().Should().Be("inventory");
body.GetProperty("version").GetString().Should().Be("1.0.0");
}
[Fact]
public async Task AuthorizationMiddleware_WhenMissingClaim_Returns403StructuredError()
{
var context = CreateContext(method: "GET", path: "/items");
context.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("scope", "user")],
authenticationType: "test"));
context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items",
RequiringClaims = []
};
var claimsStore = new StaticClaimsStore(
[new ClaimRequirement { Type = "scope", Value = "admin" }]);
var nextCalled = false;
var middleware = new AuthorizationMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
claimsStore,
NullLogger<AuthorizationMiddleware>.Instance);
await middleware.InvokeAsync(context);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("Forbidden");
body.GetProperty("status").GetInt32().Should().Be(403);
body.GetProperty("service").GetString().Should().Be("inventory");
body.GetProperty("version").GetString().Should().Be("1.0.0");
body.GetProperty("details").GetProperty("requiredClaimType").GetString().Should().Be("scope");
body.GetProperty("details").GetProperty("requiredClaimValue").GetString().Should().Be("admin");
}
[Fact]
public async Task GlobalErrorHandlerMiddleware_WhenUnhandledException_Returns500StructuredError()
{
var context = CreateContext(method: "GET", path: "/boom");
var environment = new TestHostEnvironment { EnvironmentName = Environments.Development };
var middleware = new GlobalErrorHandlerMiddleware(
_ => throw new InvalidOperationException("boom"),
NullLogger<GlobalErrorHandlerMiddleware>.Instance,
environment);
await middleware.Invoke(context);
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("Internal Server Error");
body.GetProperty("status").GetInt32().Should().Be(500);
body.GetProperty("message").GetString().Should().Be("boom");
}
private static DefaultHttpContext CreateContext(string method, string path, string? queryString = null)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddOptions();
var context = new DefaultHttpContext
{
RequestServices = services.BuildServiceProvider()
};
context.TraceIdentifier = "trace-1";
context.Request.Method = method;
context.Request.Path = path;
context.Request.QueryString = string.IsNullOrWhiteSpace(queryString) ? QueryString.Empty : new QueryString(queryString);
context.Response.Body = new MemoryStream();
return context;
}
private static JsonElement ReadJson(DefaultHttpContext context)
{
context.Response.Body.Position = 0;
using var doc = JsonDocument.Parse(context.Response.Body);
return doc.RootElement.Clone();
}
private sealed class NullRoutingPlugin : IRoutingPlugin
{
public Task<RoutingDecision?> ChooseInstanceAsync(RoutingContext context, CancellationToken cancellationToken)
{
return Task.FromResult<RoutingDecision?>(null);
}
}
private sealed class StaticClaimsStore : IEffectiveClaimsStore
{
private readonly IReadOnlyList<ClaimRequirement> _claims;
public StaticClaimsStore(IReadOnlyList<ClaimRequirement> claims)
{
_claims = claims;
}
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path) => _claims;
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
{
}
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
}
public void RemoveService(string serviceName)
{
}
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Production;
public string ApplicationName { get; set; } = "StellaOps.Router.Gateway.Tests";
public string ContentRootPath { get; set; } = Environment.CurrentDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}

View File

@@ -0,0 +1,756 @@
// -----------------------------------------------------------------------------
// 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
};
}
}

View File

@@ -0,0 +1,67 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class RateLimitConfigTests
{
[Fact]
public void Load_BindsRoutesAndRules()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["rate_limiting:process_back_pressure_when_more_than_per_5min"] = "0",
["rate_limiting:for_environment:valkey_connection"] = "localhost:6379",
["rate_limiting:for_environment:valkey_bucket"] = "stella-router-rate-limit",
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:pattern"] = "/api/scans",
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:match_type"] = "Exact",
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:rules:0:per_seconds"] = "10",
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:rules:0:max_requests"] = "50",
})
.Build();
var config = RateLimitConfig.Load(configuration);
config.ActivationThresholdPer5Min.Should().Be(0);
config.ForEnvironment.Should().NotBeNull();
config.ForEnvironment!.Microservices.Should().NotBeNull();
var scanner = config.ForEnvironment.Microservices!["scanner"];
scanner.Routes.Should().ContainKey("scan_submit");
var route = scanner.Routes["scan_submit"];
route.MatchType.Should().Be(RouteMatchType.Exact);
route.Pattern.Should().Be("/api/scans");
route.Rules.Should().HaveCount(1);
route.Rules[0].PerSeconds.Should().Be(10);
route.Rules[0].MaxRequests.Should().Be(50);
}
[Fact]
public void Load_ThrowsForInvalidRegexRoute()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["rate_limiting:process_back_pressure_when_more_than_per_5min"] = "0",
["rate_limiting:for_environment:valkey_connection"] = "localhost:6379",
["rate_limiting:for_environment:valkey_bucket"] = "stella-router-rate-limit",
["rate_limiting:for_environment:microservices:scanner:routes:bad:pattern"] = "[",
["rate_limiting:for_environment:microservices:scanner:routes:bad:match_type"] = "Regex",
["rate_limiting:for_environment:microservices:scanner:routes:bad:rules:0:per_seconds"] = "10",
["rate_limiting:for_environment:microservices:scanner:routes:bad:rules:0:max_requests"] = "1",
})
.Build();
var act = () => RateLimitConfig.Load(configuration);
act.Should().Throw<ArgumentException>()
.WithMessage("*Invalid regex pattern*");
}
}

View File

@@ -0,0 +1,97 @@
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Router.Gateway;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class RateLimitMiddlewareTests
{
[Fact]
public async Task InvokeAsync_EnforcesEnvironmentLimit_WithRetryAfterAndJsonBody()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scanner"] = new MicroserviceLimitsConfig
{
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scan_submit"] = new RouteLimitsConfig
{
Pattern = "/api/scans",
MatchType = RouteMatchType.Exact,
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
}
}
}
}
}
}.Validate();
var store = new InMemoryValkeyRateLimitStore();
var circuitBreaker = new CircuitBreaker(failureThreshold: 5, timeoutSeconds: 30, halfOpenTimeout: 10);
var environmentLimiter = new EnvironmentRateLimiter(store, circuitBreaker, NullLogger<EnvironmentRateLimiter>.Instance);
var service = new RateLimitService(config, instanceLimiter: null, environmentLimiter, NullLogger<RateLimitService>.Instance);
var nextCalled = 0;
var middleware = new RateLimitMiddleware(
next: async ctx =>
{
nextCalled++;
ctx.Response.StatusCode = StatusCodes.Status200OK;
await ctx.Response.WriteAsync("ok");
},
rateLimitService: service,
logger: NullLogger<RateLimitMiddleware>.Instance);
// First request allowed
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/scans";
context.Response.Body = new MemoryStream();
context.Items[RouterHttpContextKeys.TargetMicroservice] = "scanner";
await middleware.InvokeAsync(context);
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
context.Response.Headers.ContainsKey("Retry-After").Should().BeFalse();
context.Response.Headers["X-RateLimit-Limit"].ToString().Should().Be("1");
nextCalled.Should().Be(1);
}
// Second request denied
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/scans";
context.Response.Body = new MemoryStream();
context.Items[RouterHttpContextKeys.TargetMicroservice] = "scanner";
await middleware.InvokeAsync(context);
context.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
context.Response.Headers.ContainsKey("Retry-After").Should().BeTrue();
context.Response.Body.Position = 0;
var body = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync();
using var json = JsonDocument.Parse(body);
json.RootElement.GetProperty("error").GetString().Should().Be("rate_limit_exceeded");
json.RootElement.GetProperty("scope").GetString().Should().Be("environment");
json.RootElement.GetProperty("limit").GetInt64().Should().Be(1);
nextCalled.Should().Be(1);
}
}
}

View File

@@ -0,0 +1,77 @@
using FluentAssertions;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class RateLimitRouteMatcherTests
{
[Fact]
public void TryMatch_ExactBeatsPrefixAndRegex()
{
var microservice = new MicroserviceLimitsConfig
{
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["exact"] = new RouteLimitsConfig
{
Pattern = "/api/scans",
MatchType = RouteMatchType.Exact,
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
},
["prefix"] = new RouteLimitsConfig
{
Pattern = "/api/scans/*",
MatchType = RouteMatchType.Prefix,
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
},
["regex"] = new RouteLimitsConfig
{
Pattern = "^/api/scans/[a-f0-9-]+$",
MatchType = RouteMatchType.Regex,
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
},
}
};
microservice.Validate("microservice");
var matcher = new RateLimitRouteMatcher();
var match = matcher.TryMatch(microservice, "/api/scans");
match.Should().NotBeNull();
match!.Value.Name.Should().Be("exact");
}
[Fact]
public void TryMatch_LongestPrefixWins()
{
var microservice = new MicroserviceLimitsConfig
{
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["short"] = new RouteLimitsConfig
{
Pattern = "/api/*",
MatchType = RouteMatchType.Prefix,
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
},
["long"] = new RouteLimitsConfig
{
Pattern = "/api/scans/*",
MatchType = RouteMatchType.Prefix,
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
},
}
};
microservice.Validate("microservice");
var matcher = new RateLimitRouteMatcher();
var match = matcher.TryMatch(microservice, "/api/scans/123");
match.Should().NotBeNull();
match!.Value.Name.Should().Be("long");
}
}

View File

@@ -0,0 +1,107 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class RateLimitServiceTests
{
private sealed class CountingStore : IValkeyRateLimitStore
{
public int Calls { get; private set; }
public Task<RateLimitStoreResult> IncrementAndCheckAsync(
string key,
IReadOnlyList<RateLimitRule> rules,
CancellationToken cancellationToken)
{
Calls++;
return Task.FromResult(new RateLimitStoreResult(
Allowed: false,
CurrentCount: 2,
Limit: 1,
WindowSeconds: 300,
RetryAfterSeconds: 10));
}
}
[Fact]
public async Task CheckLimitAsync_DoesNotInvokeEnvironmentLimiterUntilActivationGateTriggers()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 2,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
}
}.Validate();
var store = new CountingStore();
var circuitBreaker = new CircuitBreaker(failureThreshold: 5, timeoutSeconds: 30, halfOpenTimeout: 10);
var environmentLimiter = new EnvironmentRateLimiter(store, circuitBreaker, NullLogger<EnvironmentRateLimiter>.Instance);
var service = new RateLimitService(
config,
instanceLimiter: null,
environmentLimiter,
NullLogger<RateLimitService>.Instance);
var first = await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None);
first.Allowed.Should().BeTrue();
store.Calls.Should().Be(0);
var second = await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None);
second.Allowed.Should().BeFalse();
store.Calls.Should().Be(1);
}
[Fact]
public async Task CheckLimitAsync_EnforcesPerRouteEnvironmentRules()
{
var config = new RateLimitConfig
{
ActivationThresholdPer5Min = 0,
ForEnvironment = new EnvironmentLimitsConfig
{
ValkeyConnection = "localhost:6379",
ValkeyBucket = "bucket",
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scanner"] = new MicroserviceLimitsConfig
{
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
{
["scan_submit"] = new RouteLimitsConfig
{
Pattern = "/api/scans",
MatchType = RouteMatchType.Exact,
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
}
}
}
}
}
}.Validate();
var store = new InMemoryValkeyRateLimitStore();
var circuitBreaker = new CircuitBreaker(failureThreshold: 5, timeoutSeconds: 30, halfOpenTimeout: 10);
var environmentLimiter = new EnvironmentRateLimiter(store, circuitBreaker, NullLogger<EnvironmentRateLimiter>.Instance);
var service = new RateLimitService(
config,
instanceLimiter: null,
environmentLimiter,
NullLogger<RateLimitService>.Instance);
(await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None)).Allowed.Should().BeTrue();
var denied = await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None);
denied.Allowed.Should().BeFalse();
denied.Scope.Should().Be(RateLimitScope.Environment);
denied.WindowSeconds.Should().Be(300);
denied.Limit.Should().Be(1);
}
}

View File

@@ -0,0 +1,40 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.DependencyInjection;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class RouterNodeConfigValidationTests
{
[Fact]
public void RouterNodeConfig_WhenRegionMissing_ThrowsOptionsValidationException()
{
var services = new ServiceCollection();
services.AddRouterGatewayCore();
using var provider = services.BuildServiceProvider();
var act = () => provider.GetRequiredService<IOptions<RouterNodeConfig>>().Value;
act.Should().Throw<OptionsValidationException>();
}
[Fact]
public void RouterNodeConfig_WhenRegionProvided_GeneratesNodeIdIfMissing()
{
var services = new ServiceCollection();
services.AddRouterGatewayCore();
services.Configure<RouterNodeConfig>(c => c.Region = "test");
using var provider = services.BuildServiceProvider();
var config = provider.GetRequiredService<IOptions<RouterNodeConfig>>().Value;
config.Region.Should().Be("test");
config.NodeId.Should().StartWith("gw-test-");
config.NodeId.Should().HaveLength("gw-test-".Length + 8);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="FsCheck" Version="2.16.6" />
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Testcontainers" Version="4.4.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,81 @@
using FluentAssertions;
using StellaOps.Router.Gateway.RateLimit;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
[Collection(nameof(ValkeyTestcontainerCollection))]
public sealed class ValkeyRateLimitStoreIntegrationTests
{
private readonly ValkeyTestcontainerFixture _valkey;
public ValkeyRateLimitStoreIntegrationTests(ValkeyTestcontainerFixture valkey)
{
_valkey = valkey;
}
[IntegrationFact]
public async Task IncrementAndCheckAsync_UsesSmallestWindowAsRepresentativeWhenAllowed()
{
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
var rules = new[]
{
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
new RateLimitRule { PerSeconds = 60, MaxRequests = 10 },
};
var result = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
result.Allowed.Should().BeTrue();
result.WindowSeconds.Should().Be(60);
result.Limit.Should().Be(10);
result.CurrentCount.Should().Be(1);
result.RetryAfterSeconds.Should().Be(0);
}
[IntegrationFact]
public async Task IncrementAndCheckAsync_DeniesWhenLimitExceeded()
{
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
var rules = new[]
{
new RateLimitRule { PerSeconds = 2, MaxRequests = 1 },
};
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
denied.Allowed.Should().BeFalse();
denied.WindowSeconds.Should().Be(2);
denied.Limit.Should().Be(1);
denied.CurrentCount.Should().Be(2);
denied.RetryAfterSeconds.Should().BeInRange(1, 2);
}
[IntegrationFact]
public async Task IncrementAndCheckAsync_ReturnsMostRestrictiveRetryAfterAcrossRules()
{
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
var rules = new[]
{
new RateLimitRule { PerSeconds = 60, MaxRequests = 1 },
new RateLimitRule { PerSeconds = 2, MaxRequests = 100 },
};
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
denied.Allowed.Should().BeFalse();
denied.WindowSeconds.Should().Be(60);
denied.Limit.Should().Be(1);
denied.CurrentCount.Should().Be(2);
denied.RetryAfterSeconds.Should().BeInRange(1, 60);
}
}

View File

@@ -0,0 +1,48 @@
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class ValkeyTestcontainerFixture : IAsyncLifetime
{
private IContainer? _container;
public string ConnectionString { get; private set; } = "";
public async Task InitializeAsync()
{
if (!IntegrationTestSettings.IsEnabled)
{
return;
}
_container = new ContainerBuilder()
.WithImage("valkey/valkey:8-alpine")
.WithPortBinding(6379, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
.Build();
await _container.StartAsync();
var port = _container.GetMappedPublicPort(6379);
ConnectionString = $"{_container.Hostname}:{port}";
}
public async Task DisposeAsync()
{
if (_container is null)
{
return;
}
await _container.StopAsync();
await _container.DisposeAsync();
}
}
[CollectionDefinition(nameof(ValkeyTestcontainerCollection))]
public sealed class ValkeyTestcontainerCollection : ICollectionFixture<ValkeyTestcontainerFixture>
{
}