using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// E2E integration coverage for the TestDriverConnect / /// round-trip through the /// AdminOperationsActor cluster singleton. /// /// All three tests target the Modbus Docker fixture that ships with the /// lmxopcua-fix up modbus profile (default endpoint /// 10.100.0.35:5020 from MODBUS_SIM_ENDPOINT). They are skipped /// automatically when the fixture is unreachable so dotnet test on a dev /// machine without Docker access still exits clean. /// [Trait("Category", "Integration")] [Trait("Driver", "Modbus")] public sealed class DriverTestConnectE2eTests { private const string DefaultEndpoint = "10.100.0.35:5020"; private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT"; private static CancellationToken Ct => TestContext.Current.CancellationToken; /// /// Resolves the Modbus sim endpoint from the environment (or falls back to the shared /// Docker host default) and returns host + port separately. /// private static (string host, int port) ResolveSimEndpoint() { var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint; var parts = raw.Split(':', 2); var host = parts[0]; var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 5020; return (host, port); } /// /// Happy-path probe: connects to the running Modbus pymodbus simulator and asserts /// the reports Ok = true with a /// sub-5 s latency. Skipped when the Docker fixture host is unreachable. /// [Fact] public async Task TestConnect_Modbus_AgainstFixture_ReportsOk() { var (host, port) = ResolveSimEndpoint(); if (!DockerFixtureAvailability.IsReachable(host, port)) Assert.Skip($"Modbus fixture at {host}:{port} unreachable — start with `lmxopcua-fix up modbus standard`."); await using var harness = await TwoNodeClusterHarness.StartAsync(); await using var scope = harness.NodeA.Services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var configJson = $"{{\"Host\":\"{host}\",\"Port\":{port}}}"; var correlationId = Guid.NewGuid(); var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 10, correlationId); var result = await client.AskAsync(msg, Ct); result.CorrelationId.ShouldBe(correlationId); result.Ok.ShouldBeTrue($"Probe reported failure: {result.Message}"); result.LatencyMs.ShouldNotBeNull(); result.LatencyMs!.Value.ShouldBeLessThan(5_000); } /// /// Wrong-port probe: connects to the Docker fixture host on port 9999 (nothing /// listens there) and asserts the result is Ok = false with a message /// containing a connection-refused indicator. Skipped when the host is unreachable /// (even a refused connection requires the IP to be routable). /// [Fact] public async Task TestConnect_Modbus_AgainstWrongPort_ReportsFailure() { var (host, _) = ResolveSimEndpoint(); // Reachability check on the *correct* port to confirm the host is routable. var (_, goodPort) = ResolveSimEndpoint(); if (!DockerFixtureAvailability.IsReachable(host, goodPort)) Assert.Skip($"Modbus fixture host {host} not routable — cannot exercise refused-connection scenario."); await using var harness = await TwoNodeClusterHarness.StartAsync(); await using var scope = harness.NodeA.Services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var configJson = $"{{\"Host\":\"{host}\",\"Port\":9999}}"; var correlationId = Guid.NewGuid(); var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 5, correlationId); var result = await client.AskAsync(msg, Ct); result.CorrelationId.ShouldBe(correlationId); result.Ok.ShouldBeFalse("Port 9999 should not be open."); result.Message.ShouldNotBeNull(); // SocketErrorCode is ConnectionRefused on Windows/Linux; on macOS it may appear as // "Connection refused" in the message text rather than the enum name. var failureMessage = result.Message!; (failureMessage.Contains("ConnectionRefused", StringComparison.OrdinalIgnoreCase) || failureMessage.Contains("refused", StringComparison.OrdinalIgnoreCase) || failureMessage.Contains("Connect failed", StringComparison.OrdinalIgnoreCase)) .ShouldBeTrue($"Expected a refused-connection message but got: {failureMessage}"); } /// /// Black-hole probe: targets 1.2.3.4:502 (TEST-NET-1, packets are dropped). /// Uses a 3-second timeout to keep the wall-clock cost low. Asserts the probe /// reports Ok = false with a message containing "timed out". /// /// Scope: the does not register /// IDriverProbe implementations (those are wired by /// DriverFactoryBootstrap in Program.cs). This test therefore exercises /// directly rather than going through /// the AdminOperationsActor. The cluster round-trip path is covered by /// (which skips in dev). /// This test does NOT require any Docker fixture and always runs. /// [Fact] public async Task TestConnect_Modbus_AgainstBlackHole_ReportsTimeout() { var probe = new ModbusDriverProbe(); // 1.2.3.4 is TEST-NET-1 (RFC 5737) — routable but black-holed; packets never return. const string configJson = "{\"Host\":\"1.2.3.4\",\"Port\":502}"; var timeout = TimeSpan.FromSeconds(3); using var cts = new CancellationTokenSource(timeout); var result = await probe.ProbeAsync(configJson, timeout, cts.Token); result.Ok.ShouldBeFalse("TEST-NET-1 connection should time out."); result.Message.ShouldNotBeNull(); result.Message!.ToLowerInvariant().ShouldContain("timed out"); } }