using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Nexus.Api.Controllers; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public class GatewayConnectorTests { // ── Options / Configuration tests ── [Fact] public void Options_DefaultWebSocketPath_IsWs() { var options = new GatewayConnectorOptions(); Assert.Equal("/ws", options.WebSocketPath); } [Fact] public void Options_DefaultReconnectInitialDelay_Is1000ms() { var options = new GatewayConnectorOptions(); Assert.Equal(1000, options.ReconnectInitialDelayMs); } [Fact] public void Options_DefaultReconnectMaxDelay_Is5Minutes() { var options = new GatewayConnectorOptions(); Assert.Equal(300_000, options.ReconnectMaxDelayMs); } [Fact] public void Options_FailFastOnVersionMismatch_IsTrueByDefault() { var options = new GatewayConnectorOptions(); Assert.True(options.FailFastOnVersionMismatch); } [Fact] public void Options_FailFastOnMissingVersion_IsFalseByDefault() { var options = new GatewayConnectorOptions(); Assert.False(options.FailFastOnMissingVersion); } // ── Configuration binding tests ── [Fact] public void Options_BindFromConfiguration() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["GatewayConnector:WebSocketPath"] = "/events", ["GatewayConnector:ReconnectInitialDelayMs"] = "2000", ["GatewayConnector:ReconnectMaxDelayMs"] = "60000", ["GatewayConnector:FailFastOnVersionMismatch"] = "false", ["GatewayConnector:FailFastOnMissingVersion"] = "true" }) .Build(); var services = new ServiceCollection(); services.AddSingleton(config); services.AddOptions() .BindConfiguration(GatewayConnectorOptions.SectionName); var sp = services.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; Assert.Equal("/events", options.WebSocketPath); Assert.Equal(2000, options.ReconnectInitialDelayMs); Assert.Equal(60000, options.ReconnectMaxDelayMs); Assert.False(options.FailFastOnVersionMismatch); Assert.True(options.FailFastOnMissingVersion); } // ── Health endpoint tests ── [Fact] public async Task HealthEndpoint_ReturnsCorrectStructure() { var controller = new GatewayHealthController(new FakeGatewayConnector( GatewayConnectionState.Connected, "2.103.0", "2.103.0", DateTimeOffset.UtcNow, 0, "Gateway verbunden")); var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth()); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.Equal("connected", root.GetProperty("status").GetString()); Assert.True(root.GetProperty("connected").GetBoolean()); Assert.Equal("2.103.0", root.GetProperty("gatewayVersion").GetString()); Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString()); Assert.True(root.GetProperty("versionPinned").GetBoolean()); Assert.Equal(0, root.GetProperty("reconnectAttempts").GetInt32()); } [Fact] public async Task HealthEndpoint_Disconnected_ShowsDisconnected() { var controller = new GatewayHealthController(new FakeGatewayConnector( GatewayConnectionState.Disconnected, null, "2.103.0", null, 5, "Connection refused")); var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth()); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.Equal("disconnected", root.GetProperty("status").GetString()); Assert.False(root.GetProperty("connected").GetBoolean()); Assert.Equal("unknown", root.GetProperty("gatewayVersion").GetString()); Assert.Equal(5, root.GetProperty("reconnectAttempts").GetInt32()); Assert.Equal("Connection refused", root.GetProperty("message").GetString()); } [Fact] public async Task HealthEndpoint_Reconnecting_ShowsReconnecting() { var controller = new GatewayHealthController(new FakeGatewayConnector( GatewayConnectionState.Reconnecting, null, null, null, 3, "Reconnecting...")); var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth()); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.Equal("reconnecting", root.GetProperty("status").GetString()); Assert.False(root.GetProperty("connected").GetBoolean()); Assert.False(root.GetProperty("versionPinned").GetBoolean()); } [Fact] public async Task HealthEndpoint_Failed_ShowsFailed() { var controller = new GatewayHealthController(new FakeGatewayConnector( GatewayConnectionState.Failed, "2.100.0", "2.103.0", null, 10, "Version mismatch — fail-fast")); var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth()); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.Equal("failed", root.GetProperty("status").GetString()); Assert.False(root.GetProperty("connected").GetBoolean()); Assert.Equal("2.100.0", root.GetProperty("gatewayVersion").GetString()); Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString()); Assert.Equal(10, root.GetProperty("reconnectAttempts").GetInt32()); Assert.True(root.TryGetProperty("timestamp", out _)); } [Fact] public async Task HealthEndpoint_Unpinned_ShowsVersionPinnedFalse() { var controller = new GatewayHealthController(new FakeGatewayConnector( GatewayConnectionState.Connected, "2.103.0", null, DateTimeOffset.UtcNow, 0, null)); var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth()); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.False(root.GetProperty("versionPinned").GetBoolean()); Assert.Equal(JsonValueKind.Null, root.GetProperty("requiredVersion").ValueKind); } // ── Connection state transition tests ── [Fact] public void Connector_InitialState_IsInitializing() { var connector = new FakeGatewayConnector( GatewayConnectionState.Initializing, null, null, null, 0, null); Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState); Assert.Null(connector.GatewayVersion); Assert.Null(connector.LastConnectedAt); Assert.Equal(0, connector.ReconnectAttempts); } [Fact] public void Connector_AfterConnection_ReportsConnected() { var now = DateTimeOffset.UtcNow; var connector = new FakeGatewayConnector( GatewayConnectionState.Connected, "2.103.0", "2.103.0", now, 0, null); Assert.Equal(GatewayConnectionState.Connected, connector.ConnectionState); Assert.Equal("2.103.0", connector.GatewayVersion); Assert.Equal("2.103.0", connector.RequiredVersion); Assert.NotNull(connector.LastConnectedAt); Assert.Equal(0, connector.ReconnectAttempts); } // ── DI registration tests ── [Fact] public void Connector_CanBeRegisteredInDi() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Integrations:OpenClaw:BaseUrl"] = "http://localhost:18789", ["Integrations:OpenClaw:RequiredVersion"] = "2.103.0" }) .Build(); var services = new ServiceCollection(); services.AddSingleton(config); services.AddLogging(); services.AddHttpClient("gateway"); services.AddOptions() .BindConfiguration(GatewayConnectorOptions.SectionName); services.AddSingleton(); var sp = services.BuildServiceProvider(); var connector = sp.GetRequiredService(); Assert.NotNull(connector); Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState); } // ── HTTP endpoint routing test ── [Fact] public void HealthEndpoint_HasAllowAnonymous() { var type = typeof(GatewayHealthController); var attr = type.GetCustomAttributes(true) .OfType() .FirstOrDefault(); Assert.NotNull(attr); } [Fact] public void HealthEndpoint_HasCorrectRoute() { var method = typeof(GatewayHealthController).GetMethod("GetGatewayHealth"); Assert.NotNull(method); var routeAttr = method!.GetCustomAttributes(true) .OfType() .FirstOrDefault(); Assert.NotNull(routeAttr); Assert.Contains("/api/health/gateway", routeAttr!.Template!); } // ── Helpers ── private static async Task ExecuteAndReadJsonAsync(IResult result) { var services = new ServiceCollection(); services.AddLogging(); services.ConfigureHttpJsonOptions(options => options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase); var sp = services.BuildServiceProvider(); var httpContext = new DefaultHttpContext { RequestServices = sp }; // HttpResults.Ok needs a response body to write to httpContext.Response.Body = new MemoryStream(); await result.ExecuteAsync(httpContext); httpContext.Response.Body.Position = 0; using var reader = new StreamReader(httpContext.Response.Body); return reader.ReadToEnd(); } } /// /// Test double implementing all members of IGatewayConnector. /// public sealed class FakeGatewayConnector : IGatewayConnector { private readonly GatewayConnectionState _state; private readonly string? _gatewayVersion; private readonly string? _requiredVersion; private readonly DateTimeOffset? _lastConnectedAt; private readonly int _reconnectAttempts; private readonly string? _statusMessage; public FakeGatewayConnector( GatewayConnectionState state, string? gatewayVersion, string? requiredVersion, DateTimeOffset? lastConnectedAt, int reconnectAttempts, string? statusMessage) { _state = state; _gatewayVersion = gatewayVersion; _requiredVersion = requiredVersion; _lastConnectedAt = lastConnectedAt; _reconnectAttempts = reconnectAttempts; _statusMessage = statusMessage; } public GatewayConnectionState ConnectionState => _state; public string? GatewayVersion => _gatewayVersion; public string? RequiredVersion => _requiredVersion; public DateTimeOffset? LastConnectedAt => _lastConnectedAt; public int ReconnectAttempts => _reconnectAttempts; public string? StatusMessage => _statusMessage; }