494da22cd1
- 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.
138 lines
6.7 KiB
C#
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");
|
|
}
|
|
}
|