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");
}
}