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(), 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 { 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? 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 candidates) { return new RoutingContext { Method = "GET", Path = "/items", Headers = new Dictionary(), 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 }; } }