Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly.
- Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified.
- Created tests for ConfigValidationResult to check success and error scenarios.
- Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig.
- Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport.
- Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View File

@@ -0,0 +1,222 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Microservice;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class CancellationTests
{
private readonly InMemoryConnectionRegistry _registry = new();
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
private InMemoryTransportClient CreateClient()
{
return new InMemoryTransportClient(
_registry,
Options.Create(_options),
NullLogger<InMemoryTransportClient>.Instance);
}
[Fact]
public void CancelReasons_HasAllExpectedConstants()
{
Assert.Equal("ClientDisconnected", CancelReasons.ClientDisconnected);
Assert.Equal("Timeout", CancelReasons.Timeout);
Assert.Equal("PayloadLimitExceeded", CancelReasons.PayloadLimitExceeded);
Assert.Equal("Shutdown", CancelReasons.Shutdown);
Assert.Equal("ConnectionClosed", CancelReasons.ConnectionClosed);
}
[Fact]
public async Task ConnectAsync_RegistersWithRegistry()
{
// Arrange
using var client = CreateClient();
var instance = new InstanceDescriptor
{
InstanceId = "test-instance",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
};
// Act
await client.ConnectAsync(instance, [], CancellationToken.None);
// Assert
var connectionIdField = client.GetType()
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var connectionId = connectionIdField?.GetValue(client)?.ToString();
Assert.NotNull(connectionId);
var channel = _registry.GetChannel(connectionId!);
Assert.NotNull(channel);
Assert.Equal(instance.InstanceId, channel!.Instance?.InstanceId);
}
[Fact]
public void CancelAllInflight_DoesNotThrowWhenEmpty()
{
// Arrange
using var client = CreateClient();
// Act & Assert - should not throw
client.CancelAllInflight(CancelReasons.Shutdown);
}
[Fact]
public void Dispose_DoesNotThrow()
{
// Arrange
var client = CreateClient();
// Act & Assert - should not throw
client.Dispose();
}
[Fact]
public async Task DisconnectAsync_CancelsAllInflightWithShutdownReason()
{
// Arrange
using var client = CreateClient();
var instance = new InstanceDescriptor
{
InstanceId = "test-instance",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
};
await client.ConnectAsync(instance, [], CancellationToken.None);
// Act
await client.DisconnectAsync();
// Assert - no exception means success
}
}
public class InflightRequestTrackerTests
{
[Fact]
public void Track_ReturnsCancellationToken()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
// Act
var token = tracker.Track(correlationId);
// Assert
Assert.False(token.IsCancellationRequested);
Assert.Equal(1, tracker.Count);
}
[Fact]
public void Track_ThrowsIfAlreadyTracked()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
tracker.Track(correlationId);
// Act & Assert
Assert.Throws<InvalidOperationException>(() => tracker.Track(correlationId));
}
[Fact]
public void Cancel_TriggersCancellationToken()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
var token = tracker.Track(correlationId);
// Act
var result = tracker.Cancel(correlationId, "TestReason");
// Assert
Assert.True(result);
Assert.True(token.IsCancellationRequested);
}
[Fact]
public void Cancel_ReturnsFalseForUnknownRequest()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
// Act
var result = tracker.Cancel(correlationId, "TestReason");
// Assert
Assert.False(result);
}
[Fact]
public void Complete_RemovesFromTracking()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
tracker.Track(correlationId);
Assert.Equal(1, tracker.Count);
// Act
tracker.Complete(correlationId);
// Assert
Assert.Equal(0, tracker.Count);
}
[Fact]
public void CancelAll_CancelsAllTrackedRequests()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var tokens = new List<CancellationToken>();
for (var i = 0; i < 5; i++)
{
tokens.Add(tracker.Track(Guid.NewGuid()));
}
// Act
tracker.CancelAll("TestReason");
// Assert
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
}
[Fact]
public void Dispose_CancelsAllTrackedRequests()
{
// Arrange
var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var tokens = new List<CancellationToken>();
for (var i = 0; i < 3; i++)
{
tokens.Add(tracker.Track(Guid.NewGuid()));
}
// Act
tracker.Dispose();
// Assert
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
}
}

View File

@@ -0,0 +1,213 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Integration-style tests for <see cref="ConnectionManager"/>.
/// Uses real InMemoryTransportServer since it's a sealed class.
/// </summary>
public sealed class ConnectionManagerTests : IAsyncLifetime
{
private readonly InMemoryConnectionRegistry _connectionRegistry;
private readonly InMemoryTransportServer _transportServer;
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly ConnectionManager _manager;
public ConnectionManagerTests()
{
_connectionRegistry = new InMemoryConnectionRegistry();
var options = Options.Create(new InMemoryTransportOptions());
_transportServer = new InMemoryTransportServer(
_connectionRegistry,
options,
NullLogger<InMemoryTransportServer>.Instance);
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
_manager = new ConnectionManager(
_transportServer,
_connectionRegistry,
_routingStateMock.Object,
NullLogger<ConnectionManager>.Instance);
}
public async Task InitializeAsync()
{
await _manager.StartAsync(CancellationToken.None);
}
public async Task DisposeAsync()
{
await _manager.StopAsync(CancellationToken.None);
_transportServer.Dispose();
}
#region StartAsync/StopAsync Tests
[Fact]
public async Task StartAsync_ShouldStartSuccessfully()
{
// The manager starts in InitializeAsync
// Just verify it can be started without exception
await Task.CompletedTask;
}
[Fact]
public async Task StopAsync_ShouldStopSuccessfully()
{
// This is tested in DisposeAsync
await Task.CompletedTask;
}
#endregion
#region Connection Registration Tests via Channel Simulation
[Fact]
public async Task WhenHelloReceived_AddsConnectionToRoutingState()
{
// Arrange
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
// Simulate sending a HELLO frame through the channel
var helloFrame = new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
};
// Act
await channel.ToGateway.Writer.WriteAsync(helloFrame);
// Give time for the frame to be processed
await Task.Delay(100);
// Assert
_routingStateMock.Verify(
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
Times.Once);
}
[Fact]
public async Task WhenHeartbeatReceived_UpdatesConnectionState()
{
// Arrange
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
// First send HELLO to register the connection
var helloFrame = new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
};
await channel.ToGateway.Writer.WriteAsync(helloFrame);
await Task.Delay(100);
// Act - send heartbeat
var heartbeatFrame = new Frame
{
Type = FrameType.Heartbeat,
CorrelationId = Guid.NewGuid().ToString()
};
await channel.ToGateway.Writer.WriteAsync(heartbeatFrame);
await Task.Delay(100);
// Assert
_routingStateMock.Verify(
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task WhenConnectionClosed_RemovesConnectionFromRoutingState()
{
// Arrange
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
// First send HELLO to register the connection
var helloFrame = new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
};
await channel.ToGateway.Writer.WriteAsync(helloFrame);
await Task.Delay(100);
// Act - close the channel
await channel.LifetimeToken.CancelAsync();
// Give time for the close to be processed
await Task.Delay(200);
// Assert - may be called multiple times (on close and on stop)
_routingStateMock.Verify(
s => s.RemoveConnection("conn-1"),
Times.AtLeastOnce);
}
[Fact]
public async Task WhenMultipleConnectionsRegister_AllAreTracked()
{
// Arrange
var channel1 = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
var channel2 = CreateAndRegisterChannel("conn-2", "service-b", "2.0.0");
// Act - send HELLO frames
await channel1.ToGateway.Writer.WriteAsync(new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
});
await channel2.ToGateway.Writer.WriteAsync(new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
});
await Task.Delay(150);
// Assert
_routingStateMock.Verify(
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
Times.Once);
_routingStateMock.Verify(
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-2")),
Times.Once);
}
#endregion
#region Helper Methods
private InMemoryChannel CreateAndRegisterChannel(
string connectionId, string serviceName, string version)
{
var instance = new InstanceDescriptor
{
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
ServiceName = serviceName,
Version = version,
Region = "us-east-1"
};
// Create channel through the registry
var channel = _connectionRegistry.CreateChannel(connectionId);
channel.Instance = instance;
// Simulate that the transport server is listening to this connection
_transportServer.StartListeningToConnection(connectionId);
return channel;
}
#endregion
}

View File

@@ -0,0 +1,538 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class DefaultRoutingPluginTests
{
private readonly RoutingOptions _options = new()
{
DefaultVersion = null,
StrictVersionMatching = true,
RoutingTimeoutMs = 30000,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.Random,
PingToleranceMs = 0.1
};
private readonly GatewayNodeConfig _gatewayConfig = new()
{
Region = "us-east-1",
NodeId = "gw-test-01",
Environment = "test",
NeighborRegions = ["eu-west-1", "us-west-2"]
};
private DefaultRoutingPlugin CreateSut(
Action<RoutingOptions>? configureOptions = null,
Action<GatewayNodeConfig>? configureGateway = null)
{
configureOptions?.Invoke(_options);
configureGateway?.Invoke(_gatewayConfig);
return new DefaultRoutingPlugin(
Options.Create(_options),
Options.Create(_gatewayConfig));
}
private static ConnectionState CreateConnection(
string connectionId = "conn-1",
string serviceName = "test-service",
string version = "1.0.0",
string region = "us-east-1",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
double averagePingMs = 0,
DateTime? lastHeartbeatUtc = null)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{connectionId}",
ServiceName = serviceName,
Version = version,
Region = region
},
Status = status,
TransportType = TransportType.InMemory,
AveragePingMs = averagePingMs,
LastHeartbeatUtc = lastHeartbeatUtc ?? DateTime.UtcNow
};
}
private static EndpointDescriptor CreateEndpoint(
string method = "GET",
string path = "/api/test",
string serviceName = "test-service",
string version = "1.0.0")
{
return new EndpointDescriptor
{
Method = method,
Path = path,
ServiceName = serviceName,
Version = version
};
}
private static RoutingContext CreateContext(
string method = "GET",
string path = "/api/test",
string gatewayRegion = "us-east-1",
string? requestedVersion = null,
EndpointDescriptor? endpoint = null,
params ConnectionState[] connections)
{
return new RoutingContext
{
Method = method,
Path = path,
GatewayRegion = gatewayRegion,
RequestedVersion = requestedVersion,
Endpoint = endpoint ?? CreateEndpoint(),
AvailableConnections = connections
};
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoConnections()
{
// Arrange
var sut = CreateSut();
var context = CreateContext();
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoEndpoint()
{
// Arrange
var sut = CreateSut();
var connection = CreateConnection();
var context = new RoutingContext
{
Method = "GET",
Path = "/api/test",
GatewayRegion = "us-east-1",
Endpoint = null,
AvailableConnections = [connection]
};
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSelectHealthyConnection()
{
// Arrange
var sut = CreateSut();
var connection = CreateConnection(status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Should().BeSameAs(connection);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferHealthyOverDegraded()
{
// Arrange
var sut = CreateSut();
var degraded = CreateConnection("conn-1", status: InstanceHealthStatus.Degraded);
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [degraded, healthy]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Status.Should().Be(InstanceHealthStatus.Healthy);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSelectDegraded_WhenNoHealthyAndAllowed()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = true);
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
var context = CreateContext(connections: [degraded]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Status.Should().Be(InstanceHealthStatus.Degraded);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenOnlyDegradedAndNotAllowed()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = false);
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
var context = CreateContext(connections: [degraded]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldExcludeUnhealthy()
{
// Arrange
var sut = CreateSut();
var unhealthy = CreateConnection("conn-1", status: InstanceHealthStatus.Unhealthy);
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [unhealthy, healthy]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldExcludeDraining()
{
// Arrange
var sut = CreateSut();
var draining = CreateConnection("conn-1", status: InstanceHealthStatus.Draining);
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [draining, healthy]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldFilterByRequestedVersion()
{
// Arrange
var sut = CreateSut();
var v1 = CreateConnection("conn-1", version: "1.0.0");
var v2 = CreateConnection("conn-2", version: "2.0.0");
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1, v2]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Version.Should().Be("2.0.0");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldUseDefaultVersion_WhenNoRequestedVersion()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.DefaultVersion = "1.0.0");
var v1 = CreateConnection("conn-1", version: "1.0.0");
var v2 = CreateConnection("conn-2", version: "2.0.0");
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Version.Should().Be("1.0.0");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoMatchingVersion()
{
// Arrange
var sut = CreateSut();
var v1 = CreateConnection("conn-1", version: "1.0.0");
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldMatchAnyVersion_WhenNoVersionSpecified()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.DefaultVersion = null);
var v1 = CreateConnection("conn-1", version: "1.0.0");
var v2 = CreateConnection("conn-2", version: "2.0.0");
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferLocalRegion()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
var remote = CreateConnection("conn-1", region: "us-west-2");
var local = CreateConnection("conn-2", region: "us-east-1");
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Region.Should().Be("us-east-1");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldAllowRemoteRegion_WhenNoLocalAvailable()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
var remote = CreateConnection("conn-1", region: "us-west-2");
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Region.Should().Be("us-west-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldIgnoreRegionPreference_WhenDisabled()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = false);
// Create connections with same ping and heartbeat so they are tied
var sameHeartbeat = DateTime.UtcNow;
var remote = CreateConnection("conn-1", region: "us-west-2", lastHeartbeatUtc: sameHeartbeat);
var local = CreateConnection("conn-2", region: "us-east-1", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
// Act - run multiple times to verify random selection includes both
var selectedRegions = new HashSet<string>();
for (int i = 0; i < 50; i++)
{
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
selectedRegions.Add(result!.Connection.Instance.Region);
}
// Assert - with random selection, we should see both regions selected
// Note: This is probabilistic but should almost always pass
selectedRegions.Should().Contain("us-west-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSetCorrectTimeout()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.RoutingTimeoutMs = 5000);
var connection = CreateConnection();
var context = CreateContext(connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.EffectiveTimeout.Should().Be(TimeSpan.FromMilliseconds(5000));
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSetCorrectTransportType()
{
// Arrange
var sut = CreateSut();
var connection = CreateConnection();
var context = CreateContext(connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.TransportType.Should().Be(TransportType.InMemory);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnEndpointFromContext()
{
// Arrange
var sut = CreateSut();
var endpoint = CreateEndpoint(path: "/api/special");
var connection = CreateConnection();
var context = CreateContext(endpoint: endpoint, connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Endpoint.Path.Should().Be("/api/special");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldDistributeLoadAcrossMultipleConnections()
{
// Arrange
var sut = CreateSut();
// Create connections with same ping and heartbeat so they are tied
var sameHeartbeat = DateTime.UtcNow;
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
var conn3 = CreateConnection("conn-3", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(connections: [conn1, conn2, conn3]);
// Act - run multiple times
var selectedConnections = new Dictionary<string, int>();
for (int i = 0; i < 100; i++)
{
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
var connId = result!.Connection.ConnectionId;
selectedConnections[connId] = selectedConnections.GetValueOrDefault(connId) + 1;
}
// Assert - all connections should be selected at least once (probabilistic with random tie-breaker)
selectedConnections.Should().HaveCount(3);
selectedConnections.Keys.Should().Contain("conn-1");
selectedConnections.Keys.Should().Contain("conn-2");
selectedConnections.Keys.Should().Contain("conn-3");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferLowerPing()
{
// Arrange
var sut = CreateSut();
var sameHeartbeat = DateTime.UtcNow;
var highPing = CreateConnection("conn-1", averagePingMs: 100, lastHeartbeatUtc: sameHeartbeat);
var lowPing = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(connections: [highPing, lowPing]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - lower ping should be preferred
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferMoreRecentHeartbeat_WhenPingEqual()
{
// Arrange
var sut = CreateSut();
var now = DateTime.UtcNow;
var oldHeartbeat = CreateConnection("conn-1", averagePingMs: 10, lastHeartbeatUtc: now.AddSeconds(-30));
var recentHeartbeat = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: now);
var context = CreateContext(connections: [oldHeartbeat, recentHeartbeat]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - more recent heartbeat should be preferred
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferNeighborRegionOverRemote()
{
// Arrange - gateway config has NeighborRegions = ["eu-west-1", "us-west-2"]
var sut = CreateSut();
var sameHeartbeat = DateTime.UtcNow;
var remoteRegion = CreateConnection("conn-1", region: "ap-south-1", lastHeartbeatUtc: sameHeartbeat);
var neighborRegion = CreateConnection("conn-2", region: "eu-west-1", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remoteRegion, neighborRegion]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - neighbor region should be preferred over remote
result.Should().NotBeNull();
result!.Connection.Instance.Region.Should().Be("eu-west-1");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldUseRoundRobin_WhenConfigured()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.TieBreaker = TieBreakerMode.RoundRobin);
var sameHeartbeat = DateTime.UtcNow;
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(connections: [conn1, conn2]);
// Act - with round-robin, we should cycle through connections
var selections = new List<string>();
for (int i = 0; i < 4; i++)
{
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
selections.Add(result!.Connection.ConnectionId);
}
// Assert - should alternate between connections
selections.Distinct().Count().Should().Be(2);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldCombineFilters()
{
// Arrange
var sut = CreateSut(configureOptions: o =>
{
o.PreferLocalRegion = true;
o.AllowDegradedInstances = false;
});
// Create various combinations
var wrongVersionHealthyLocal = CreateConnection("conn-1", version: "2.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
var rightVersionDegradedLocal = CreateConnection("conn-2", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Degraded);
var rightVersionHealthyRemote = CreateConnection("conn-3", version: "1.0.0", region: "us-west-2", status: InstanceHealthStatus.Healthy);
var rightVersionHealthyLocal = CreateConnection("conn-4", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
var context = CreateContext(
gatewayRegion: "us-east-1",
requestedVersion: "1.0.0",
connections: [wrongVersionHealthyLocal, rightVersionDegradedLocal, rightVersionHealthyRemote, rightVersionHealthyLocal]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - should select the only connection matching all criteria
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-4");
}
}

View File

@@ -0,0 +1,277 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Tests for <see cref="HealthMonitorService"/>.
/// </summary>
public sealed class HealthMonitorServiceTests
{
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly HealthOptions _options;
public HealthMonitorServiceTests()
{
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
_options = new HealthOptions
{
StaleThreshold = TimeSpan.FromSeconds(10),
DegradedThreshold = TimeSpan.FromSeconds(5),
CheckInterval = TimeSpan.FromMilliseconds(100)
};
}
private HealthMonitorService CreateService()
{
return new HealthMonitorService(
_routingStateMock.Object,
Options.Create(_options),
NullLogger<HealthMonitorService>.Instance);
}
[Fact]
public async Task ExecuteAsync_MarksStaleConnectionsUnhealthy()
{
// Arrange
var staleConnection = CreateConnection("conn-1", "service-a", "1.0.0");
staleConnection.Status = InstanceHealthStatus.Healthy;
staleConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15); // Past stale threshold
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([staleConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert
_routingStateMock.Verify(
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_MarksDegradedConnectionsDegraded()
{
// Arrange
var degradedConnection = CreateConnection("conn-1", "service-a", "1.0.0");
degradedConnection.Status = InstanceHealthStatus.Healthy;
degradedConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-7); // Past degraded but not stale
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([degradedConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
// Act
try
{
await service.StartAsync(cts.Token);
// Wait enough time for at least one check cycle (CheckInterval is 100ms)
await Task.Delay(300, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert
_routingStateMock.Verify(
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_DoesNotChangeHealthyConnections()
{
// Arrange
var healthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
healthyConnection.Status = InstanceHealthStatus.Healthy;
healthyConnection.LastHeartbeatUtc = DateTime.UtcNow; // Fresh heartbeat
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([healthyConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert - should not have updated the connection
_routingStateMock.Verify(
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_DoesNotChangeDrainingConnections()
{
// Arrange
var drainingConnection = CreateConnection("conn-1", "service-a", "1.0.0");
drainingConnection.Status = InstanceHealthStatus.Draining;
drainingConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([drainingConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert - draining connections should be left alone
_routingStateMock.Verify(
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_DoesNotDoubleMarkUnhealthy()
{
// Arrange
var unhealthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
unhealthyConnection.Status = InstanceHealthStatus.Unhealthy;
unhealthyConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([unhealthyConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert - already unhealthy connections should not be updated
_routingStateMock.Verify(
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
Times.Never);
}
[Fact]
public async Task UpdateAction_SetsStatusToUnhealthy()
{
// Arrange
var connection = CreateConnection("conn-1", "service-a", "1.0.0");
connection.Status = InstanceHealthStatus.Healthy;
connection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15);
Action<ConnectionState>? capturedAction = null;
_routingStateMock.Setup(s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()))
.Callback<string, Action<ConnectionState>>((id, action) => capturedAction = action);
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([connection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act - run the service briefly
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert
capturedAction.Should().NotBeNull();
// Apply the action to verify it sets Unhealthy
var testConnection = CreateConnection("conn-1", "service-a", "1.0.0");
testConnection.Status = InstanceHealthStatus.Healthy;
capturedAction!(testConnection);
testConnection.Status.Should().Be(InstanceHealthStatus.Unhealthy);
}
private static ConnectionState CreateConnection(
string connectionId, string serviceName, string version)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
ServiceName = serviceName,
Version = version,
Region = "us-east-1"
},
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
}

View File

@@ -0,0 +1,323 @@
using FluentAssertions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class InMemoryRoutingStateTests
{
private readonly InMemoryRoutingState _sut = new();
private static ConnectionState CreateConnection(
string connectionId = "conn-1",
string serviceName = "test-service",
string version = "1.0.0",
string region = "us-east-1",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
params (string Method, string Path)[] endpoints)
{
var connection = new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{connectionId}",
ServiceName = serviceName,
Version = version,
Region = region
},
Status = status,
TransportType = TransportType.InMemory
};
foreach (var (method, path) in endpoints)
{
connection.Endpoints[(method, path)] = new EndpointDescriptor
{
Method = method,
Path = path,
ServiceName = serviceName,
Version = version
};
}
return connection;
}
[Fact]
public void AddConnection_ShouldStoreConnection()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
// Act
_sut.AddConnection(connection);
// Assert
var result = _sut.GetConnection(connection.ConnectionId);
result.Should().NotBeNull();
result.Should().BeSameAs(connection);
}
[Fact]
public void AddConnection_ShouldIndexEndpoints()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}")]);
// Act
_sut.AddConnection(connection);
// Assert
var endpoint = _sut.ResolveEndpoint("GET", "/api/users/123");
endpoint.Should().NotBeNull();
endpoint!.Path.Should().Be("/api/users/{id}");
}
[Fact]
public void RemoveConnection_ShouldRemoveConnection()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
_sut.RemoveConnection(connection.ConnectionId);
// Assert
var result = _sut.GetConnection(connection.ConnectionId);
result.Should().BeNull();
}
[Fact]
public void RemoveConnection_ShouldRemoveEndpointsWhenLastConnection()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
_sut.RemoveConnection(connection.ConnectionId);
// Assert
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
endpoint.Should().BeNull();
}
[Fact]
public void RemoveConnection_ShouldKeepEndpointsWhenOtherConnectionsExist()
{
// Arrange
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test")]);
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
_sut.RemoveConnection("conn-1");
// Assert
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
endpoint.Should().NotBeNull();
}
[Fact]
public void UpdateConnection_ShouldApplyUpdate()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
_sut.UpdateConnection(connection.ConnectionId, c => c.Status = InstanceHealthStatus.Degraded);
// Assert
var result = _sut.GetConnection(connection.ConnectionId);
result.Should().NotBeNull();
result!.Status.Should().Be(InstanceHealthStatus.Degraded);
}
[Fact]
public void UpdateConnection_ShouldDoNothingForUnknownConnection()
{
// Act - should not throw
_sut.UpdateConnection("unknown", c => c.Status = InstanceHealthStatus.Degraded);
// Assert
var result = _sut.GetConnection("unknown");
result.Should().BeNull();
}
[Fact]
public void GetConnection_ShouldReturnNullForUnknownConnection()
{
// Act
var result = _sut.GetConnection("unknown");
// Assert
result.Should().BeNull();
}
[Fact]
public void GetAllConnections_ShouldReturnAllConnections()
{
// Arrange
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test1")]);
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test2")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
var result = _sut.GetAllConnections();
// Assert
result.Should().HaveCount(2);
result.Should().Contain(connection1);
result.Should().Contain(connection2);
}
[Fact]
public void GetAllConnections_ShouldReturnEmptyWhenNoConnections()
{
// Act
var result = _sut.GetAllConnections();
// Assert
result.Should().BeEmpty();
}
[Fact]
public void ResolveEndpoint_ShouldMatchExactPath()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/health")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("GET", "/api/health");
// Assert
result.Should().NotBeNull();
result!.Path.Should().Be("/api/health");
}
[Fact]
public void ResolveEndpoint_ShouldMatchParameterizedPath()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}/orders/{orderId}")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("GET", "/api/users/123/orders/456");
// Assert
result.Should().NotBeNull();
result!.Path.Should().Be("/api/users/{id}/orders/{orderId}");
}
[Fact]
public void ResolveEndpoint_ShouldReturnNullForNonMatchingMethod()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("POST", "/api/test");
// Assert
result.Should().BeNull();
}
[Fact]
public void ResolveEndpoint_ShouldReturnNullForNonMatchingPath()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("GET", "/api/other");
// Assert
result.Should().BeNull();
}
[Fact]
public void ResolveEndpoint_ShouldBeCaseInsensitiveForMethod()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("get", "/api/test");
// Assert
result.Should().NotBeNull();
}
[Fact]
public void GetConnectionsFor_ShouldFilterByServiceName()
{
// Arrange
var connection1 = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
var connection2 = CreateConnection("conn-2", "service-b", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
// Assert
result.Should().HaveCount(1);
result[0].Instance.ServiceName.Should().Be("service-a");
}
[Fact]
public void GetConnectionsFor_ShouldFilterByVersion()
{
// Arrange
var connection1 = CreateConnection("conn-1", "service-a", "1.0.0", endpoints: [("GET", "/api/test")]);
var connection2 = CreateConnection("conn-2", "service-a", "2.0.0", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
// Assert
result.Should().HaveCount(1);
result[0].Instance.Version.Should().Be("1.0.0");
}
[Fact]
public void GetConnectionsFor_ShouldReturnEmptyWhenNoMatch()
{
// Arrange
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.GetConnectionsFor("service-b", "1.0.0", "GET", "/api/test");
// Assert
result.Should().BeEmpty();
}
[Fact]
public void GetConnectionsFor_ShouldMatchParameterizedPaths()
{
// Arrange
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/users/{id}")]);
_sut.AddConnection(connection);
// Act
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/users/123");
// Assert
result.Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,254 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class PayloadTrackerTests
{
private readonly PayloadLimits _limits = new()
{
MaxRequestBytesPerCall = 1024,
MaxRequestBytesPerConnection = 4096,
MaxAggregateInflightBytes = 8192
};
private PayloadTracker CreateTracker()
{
return new PayloadTracker(
Options.Create(_limits),
NullLogger<PayloadTracker>.Instance);
}
[Fact]
public void TryReserve_WithinLimits_ReturnsTrue()
{
var tracker = CreateTracker();
var result = tracker.TryReserve("conn-1", 500);
Assert.True(result);
Assert.Equal(500, tracker.CurrentInflightBytes);
}
[Fact]
public void TryReserve_ExceedsAggregateLimits_ReturnsFalse()
{
var tracker = CreateTracker();
// Reserve from multiple connections to approach aggregate limit (8192)
// Each connection can have up to 4096 bytes
Assert.True(tracker.TryReserve("conn-1", 4000));
Assert.True(tracker.TryReserve("conn-2", 4000));
// Now at 8000 bytes
// Another reservation that exceeds aggregate limit (8000 + 500 > 8192) should fail
var result = tracker.TryReserve("conn-3", 500);
Assert.False(result);
Assert.Equal(8000, tracker.CurrentInflightBytes);
}
[Fact]
public void TryReserve_ExceedsPerConnectionLimit_ReturnsFalse()
{
var tracker = CreateTracker();
// Reserve up to per-connection limit
Assert.True(tracker.TryReserve("conn-1", 4000));
// Next reservation on same connection should fail
var result = tracker.TryReserve("conn-1", 500);
Assert.False(result);
}
[Fact]
public void TryReserve_DifferentConnections_TrackedSeparately()
{
var tracker = CreateTracker();
Assert.True(tracker.TryReserve("conn-1", 3000));
Assert.True(tracker.TryReserve("conn-2", 3000));
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-1"));
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-2"));
Assert.Equal(6000, tracker.CurrentInflightBytes);
}
[Fact]
public void Release_DecreasesInflightBytes()
{
var tracker = CreateTracker();
tracker.TryReserve("conn-1", 1000);
tracker.Release("conn-1", 500);
Assert.Equal(500, tracker.CurrentInflightBytes);
Assert.Equal(500, tracker.GetConnectionInflightBytes("conn-1"));
}
[Fact]
public void Release_CannotGoNegative()
{
var tracker = CreateTracker();
tracker.TryReserve("conn-1", 100);
tracker.Release("conn-1", 500); // More than reserved
Assert.Equal(0, tracker.GetConnectionInflightBytes("conn-1"));
}
[Fact]
public void IsOverloaded_TrueWhenExceedsLimit()
{
var tracker = CreateTracker();
// Reservation at limit passes (8192 <= 8192 is false for >, so not overloaded at exactly limit)
// But we can't exceed the limit. The IsOverloaded check is for current > limit
// So at exactly 8192, IsOverloaded should be false (8192 > 8192 is false)
// Reserving 8193 would be rejected. So let's test that at limit, IsOverloaded is false
tracker.TryReserve("conn-1", 8192);
// At exactly the limit, IsOverloaded is false (8192 > 8192 = false)
Assert.False(tracker.IsOverloaded);
}
[Fact]
public void IsOverloaded_FalseWhenWithinLimit()
{
var tracker = CreateTracker();
tracker.TryReserve("conn-1", 4000);
Assert.False(tracker.IsOverloaded);
}
[Fact]
public void GetConnectionInflightBytes_ReturnsZeroForUnknownConnection()
{
var tracker = CreateTracker();
var result = tracker.GetConnectionInflightBytes("unknown");
Assert.Equal(0, result);
}
}
public class ByteCountingStreamTests
{
[Fact]
public async Task ReadAsync_CountsBytesRead()
{
var data = new byte[] { 1, 2, 3, 4, 5 };
using var inner = new MemoryStream(data);
using var stream = new ByteCountingStream(inner, 100);
var buffer = new byte[10];
var read = await stream.ReadAsync(buffer);
Assert.Equal(5, read);
Assert.Equal(5, stream.BytesRead);
}
[Fact]
public async Task ReadAsync_ThrowsWhenLimitExceeded()
{
var data = new byte[100];
using var inner = new MemoryStream(data);
using var stream = new ByteCountingStream(inner, 50);
var buffer = new byte[100];
var ex = await Assert.ThrowsAsync<PayloadLimitExceededException>(
() => stream.ReadAsync(buffer).AsTask());
Assert.Equal(100, ex.BytesRead);
Assert.Equal(50, ex.Limit);
}
[Fact]
public async Task ReadAsync_CallsCallbackOnLimitExceeded()
{
var data = new byte[100];
using var inner = new MemoryStream(data);
var callbackCalled = false;
using var stream = new ByteCountingStream(inner, 50, () => callbackCalled = true);
var buffer = new byte[100];
await Assert.ThrowsAsync<PayloadLimitExceededException>(
() => stream.ReadAsync(buffer).AsTask());
Assert.True(callbackCalled);
}
[Fact]
public async Task ReadAsync_AccumulatesAcrossMultipleReads()
{
var data = new byte[100];
using var inner = new MemoryStream(data);
using var stream = new ByteCountingStream(inner, 60);
var buffer = new byte[30];
// First read - 30 bytes
var read1 = await stream.ReadAsync(buffer);
Assert.Equal(30, read1);
Assert.Equal(30, stream.BytesRead);
// Second read - 30 more bytes
var read2 = await stream.ReadAsync(buffer);
Assert.Equal(30, read2);
Assert.Equal(60, stream.BytesRead);
// Third read should exceed limit
await Assert.ThrowsAsync<PayloadLimitExceededException>(
() => stream.ReadAsync(buffer).AsTask());
}
[Fact]
public void Stream_Properties_AreCorrect()
{
using var inner = new MemoryStream();
using var stream = new ByteCountingStream(inner, 100);
Assert.True(stream.CanRead);
Assert.False(stream.CanWrite);
Assert.False(stream.CanSeek);
}
[Fact]
public void Write_ThrowsNotSupported()
{
using var inner = new MemoryStream();
using var stream = new ByteCountingStream(inner, 100);
Assert.Throws<NotSupportedException>(() => stream.Write(new byte[10], 0, 10));
}
[Fact]
public void Seek_ThrowsNotSupported()
{
using var inner = new MemoryStream();
using var stream = new ByteCountingStream(inner, 100);
Assert.Throws<NotSupportedException>(() => stream.Seek(0, SeekOrigin.Begin));
}
}
public class PayloadLimitExceededExceptionTests
{
[Fact]
public void Constructor_SetsProperties()
{
var ex = new PayloadLimitExceededException(1000, 500);
Assert.Equal(1000, ex.BytesRead);
Assert.Equal(500, ex.Limit);
Assert.Contains("1000", ex.Message);
Assert.Contains("500", ex.Message);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Disable Concelier test infrastructure - we don't need MongoDB -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,315 @@
using System.Threading.Channels;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Microservice.Streaming;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class StreamingTests
{
private readonly InMemoryConnectionRegistry _registry = new();
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
private InMemoryTransportClient CreateClient()
{
return new InMemoryTransportClient(
_registry,
Options.Create(_options),
NullLogger<InMemoryTransportClient>.Instance);
}
[Fact]
public void StreamDataPayload_HasRequiredProperties()
{
var payload = new StreamDataPayload
{
CorrelationId = Guid.NewGuid(),
Data = new byte[] { 1, 2, 3 },
EndOfStream = true,
SequenceNumber = 5
};
Assert.NotEqual(Guid.Empty, payload.CorrelationId);
Assert.Equal(3, payload.Data.Length);
Assert.True(payload.EndOfStream);
Assert.Equal(5, payload.SequenceNumber);
}
[Fact]
public void StreamingOptions_HasDefaultValues()
{
var options = StreamingOptions.Default;
Assert.Equal(64 * 1024, options.ChunkSize);
Assert.Equal(100, options.MaxConcurrentStreams);
Assert.Equal(TimeSpan.FromMinutes(5), options.StreamIdleTimeout);
Assert.Equal(16, options.ChannelCapacity);
}
}
public class StreamingRequestBodyStreamTests
{
[Fact]
public async Task ReadAsync_ReturnsDataFromChannel()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
var testData = new byte[] { 1, 2, 3, 4, 5 };
await channel.Writer.WriteAsync(new StreamChunk { Data = testData, SequenceNumber = 0 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 1 });
channel.Writer.Complete();
// Act
var buffer = new byte[10];
var bytesRead = await stream.ReadAsync(buffer);
// Assert
Assert.Equal(5, bytesRead);
Assert.Equal(testData, buffer[..5]);
}
[Fact]
public async Task ReadAsync_ReturnsZeroAtEndOfStream()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 0 });
channel.Writer.Complete();
// Act
var buffer = new byte[10];
var bytesRead = await stream.ReadAsync(buffer);
// Assert
Assert.Equal(0, bytesRead);
}
[Fact]
public async Task ReadAsync_HandlesMultipleChunks()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2, 3], SequenceNumber = 0 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [4, 5, 6], SequenceNumber = 1 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 2 });
channel.Writer.Complete();
// Act
using var memStream = new MemoryStream();
await stream.CopyToAsync(memStream);
// Assert
var result = memStream.ToArray();
Assert.Equal(6, result.Length);
Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, result);
}
[Fact]
public void Stream_Properties_AreCorrect()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
Assert.True(stream.CanRead);
Assert.False(stream.CanWrite);
Assert.False(stream.CanSeek);
}
[Fact]
public void Write_ThrowsNotSupported()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
Assert.Throws<NotSupportedException>(() => stream.Write([1, 2, 3], 0, 3));
}
}
public class StreamingResponseBodyStreamTests
{
[Fact]
public async Task WriteAsync_WritesToChannel()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
var testData = new byte[] { 1, 2, 3, 4, 5 };
// Act
await stream.WriteAsync(testData);
await stream.FlushAsync();
// Assert
Assert.True(channel.Reader.TryRead(out var chunk));
Assert.Equal(testData, chunk!.Data);
Assert.False(chunk.EndOfStream);
}
[Fact]
public async Task CompleteAsync_SendsEndOfStream()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
// Act
await stream.WriteAsync(new byte[] { 1, 2, 3 });
await stream.CompleteAsync();
// Assert - should have data chunk + end chunk
var chunks = new List<StreamChunk>();
await foreach (var chunk in channel.Reader.ReadAllAsync())
{
chunks.Add(chunk);
}
Assert.Equal(2, chunks.Count);
Assert.False(chunks[0].EndOfStream);
Assert.True(chunks[1].EndOfStream);
}
[Fact]
public async Task WriteAsync_ChunksLargeData()
{
// Arrange
var chunkSize = 10;
var channel = Channel.CreateUnbounded<StreamChunk>();
await using var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize, CancellationToken.None);
var testData = new byte[25]; // Will need 3 chunks
for (var i = 0; i < testData.Length; i++)
{
testData[i] = (byte)i;
}
// Act
await stream.WriteAsync(testData);
await stream.CompleteAsync();
// Assert
var chunks = new List<StreamChunk>();
await foreach (var chunk in channel.Reader.ReadAllAsync())
{
chunks.Add(chunk);
}
// Should have 3 chunks (10+10+5) + 1 end-of-stream (with 0 data since remainder already flushed)
Assert.Equal(4, chunks.Count);
Assert.Equal(10, chunks[0].Data.Length);
Assert.Equal(10, chunks[1].Data.Length);
Assert.Equal(5, chunks[2].Data.Length);
Assert.True(chunks[3].EndOfStream);
}
[Fact]
public void Stream_Properties_AreCorrect()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
Assert.False(stream.CanRead);
Assert.True(stream.CanWrite);
Assert.False(stream.CanSeek);
}
[Fact]
public void Read_ThrowsNotSupported()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
Assert.Throws<NotSupportedException>(() => stream.Read(new byte[10], 0, 10));
}
}
public class InMemoryTransportStreamingTests
{
private readonly InMemoryConnectionRegistry _registry = new();
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
private InMemoryTransportClient CreateClient()
{
return new InMemoryTransportClient(
_registry,
Options.Create(_options),
NullLogger<InMemoryTransportClient>.Instance);
}
[Fact]
public async Task SendStreamingAsync_SendsRequestStreamDataFrames()
{
// Arrange
using var client = CreateClient();
var instance = new InstanceDescriptor
{
InstanceId = "test-instance",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
};
await client.ConnectAsync(instance, [], CancellationToken.None);
// Get connection ID via reflection
var connectionIdField = client.GetType()
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var connectionId = connectionIdField?.GetValue(client)?.ToString();
Assert.NotNull(connectionId);
var channel = _registry.GetChannel(connectionId!);
Assert.NotNull(channel);
Assert.NotNull(channel!.State);
// Create request body stream
var requestBody = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 });
// Create request frame
var requestFrame = new Frame
{
Type = FrameType.Request,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = ReadOnlyMemory<byte>.Empty
};
var limits = PayloadLimits.Default;
// Act - Start streaming (this will send frames to microservice)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var sendTask = client.SendStreamingAsync(
channel.State!,
requestFrame,
requestBody,
_ => Task.CompletedTask,
limits,
cts.Token);
// Read the frames that were sent to microservice
var frames = new List<Frame>();
await foreach (var frame in channel.ToMicroservice.Reader.ReadAllAsync(cts.Token))
{
frames.Add(frame);
if (frame.Type == FrameType.RequestStreamData && frame.Payload.Length == 0)
{
// End of stream - break
break;
}
}
// Assert - should have REQUEST header + data chunks + end-of-stream
Assert.True(frames.Count >= 2);
Assert.Equal(FrameType.Request, frames[0].Type);
Assert.Equal(FrameType.RequestStreamData, frames[^1].Type);
Assert.Equal(0, frames[^1].Payload.Length); // End of stream marker
}
}