Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverTestConnectE2eTests.cs
T
Joseph Doherty 494da22cd1 test(adminui): E2E scaffolding for Test Connect + Reconnect + Status hub
- DriverTestConnectE2eTests: 3 scenarios (sim/wrong-port/black-hole)
  against the Modbus Docker fixture. Sim + wrong-port skip if fixture
  unreachable; black-hole uses ModbusDriverProbe directly (no fixture).
- DriverReconnectE2eTests: message round-trip through AdminOperationsActor
  cluster singleton — Ok=true + audit write, without live driver side effect.
- DriverStatusHubE2eTests: bridge-mocked fallback — spawns
  DriverStatusSignalRBridge in the harness ActorSystem with a mock
  IHubContext, publishes DriverHealthChanged to the driver-health DPS
  topic, asserts store upsert + hub SendAsync call.
- DockerFixtureAvailability helper: TCP-connect probe for skip guards.
- Moq 4.20.72 added to central package management for hub mocking.
- Design doc §8.3 replaced with concrete pre-ship operator runbook.
2026-05-28 11:31:12 -04:00

138 lines
6.7 KiB
C#

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;
/// <summary>
/// E2E integration coverage for the <c>TestDriverConnect</c> /
/// <see cref="TestDriverConnectResult"/> round-trip through the
/// <c>AdminOperationsActor</c> cluster singleton.
///
/// <para>All three tests target the Modbus Docker fixture that ships with the
/// <c>lmxopcua-fix up modbus</c> profile (default endpoint
/// <c>10.100.0.35:5020</c> from <c>MODBUS_SIM_ENDPOINT</c>). They are skipped
/// automatically when the fixture is unreachable so <c>dotnet test</c> on a dev
/// machine without Docker access still exits clean.</para>
/// </summary>
[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;
/// <summary>
/// Resolves the Modbus sim endpoint from the environment (or falls back to the shared
/// Docker host default) and returns host + port separately.
/// </summary>
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);
}
/// <summary>
/// Happy-path probe: connects to the running Modbus pymodbus simulator and asserts
/// the <see cref="TestDriverConnectResult"/> reports <c>Ok = true</c> with a
/// sub-5 s latency. Skipped when the Docker fixture host is unreachable.
/// </summary>
[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<IAdminOperationsClient>();
var configJson = $"{{\"Host\":\"{host}\",\"Port\":{port}}}";
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 10, correlationId);
var result = await client.AskAsync<TestDriverConnectResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeTrue($"Probe reported failure: {result.Message}");
result.LatencyMs.ShouldNotBeNull();
result.LatencyMs!.Value.ShouldBeLessThan(5_000);
}
/// <summary>
/// Wrong-port probe: connects to the Docker fixture host on port 9999 (nothing
/// listens there) and asserts the result is <c>Ok = false</c> with a message
/// containing a connection-refused indicator. Skipped when the host is unreachable
/// (even a refused connection requires the IP to be routable).
/// </summary>
[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<IAdminOperationsClient>();
var configJson = $"{{\"Host\":\"{host}\",\"Port\":9999}}";
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 5, correlationId);
var result = await client.AskAsync<TestDriverConnectResult>(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}");
}
/// <summary>
/// Black-hole probe: targets <c>1.2.3.4:502</c> (TEST-NET-1, packets are dropped).
/// Uses a 3-second timeout to keep the wall-clock cost low. Asserts the probe
/// reports <c>Ok = false</c> with a message containing "timed out".
///
/// <para><b>Scope:</b> the <see cref="TwoNodeClusterHarness"/> does not register
/// <c>IDriverProbe</c> implementations (those are wired by
/// <c>DriverFactoryBootstrap</c> in Program.cs). This test therefore exercises
/// <see cref="ModbusDriverProbe.ProbeAsync"/> directly rather than going through
/// the <c>AdminOperationsActor</c>. The cluster round-trip path is covered by
/// <see cref="TestConnect_Modbus_AgainstFixture_ReportsOk"/> (which skips in dev).
/// This test does NOT require any Docker fixture and always runs.</para>
/// </summary>
[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");
}
}