Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverReconnectE2eTests.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

87 lines
4.1 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;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>ReconnectDriver</c> command path through
/// <see cref="IAdminOperationsClient"/>.
///
/// <para><b>Scope note:</b> wiring a live <c>DriverInstanceActor</c> for the full
/// Healthy → Reconnecting → Healthy health-transition assertion requires a deployed
/// driver row in the config DB, a real fixture endpoint, and the
/// <c>DriverHostActor</c> to have registered the instance — substantially more
/// harness complexity than the two-node cluster setup alone provides. That deeper
/// fixture is tracked as a follow-up. This suite instead verifies the message
/// round-trip through the <c>AdminOperationsActor</c> singleton: the command is
/// accepted, persisted as a <c>ConfigEdit</c> audit row, and the reply carries
/// <c>Ok = true</c> with the matching <c>CorrelationId</c>. The DPS broadcast
/// that triggers the actor-side reconnect is exercised by the control-plane unit
/// tests that mock <c>IActorRef</c>.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class DriverReconnectE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="ReconnectDriver"/> message dispatched through
/// <see cref="IAdminOperationsClient.AskAsync{T}"/> returns a
/// <see cref="ReconnectDriverResult"/> with <c>Ok = true</c> and the matching
/// correlation ID, confirming the cluster-singleton round-trip works end-to-end.
///
/// <para>The instance ID used here ("reconnect-e2e-nonexistent") does not correspond
/// to a deployed driver, so no <c>DriverInstanceActor</c> will act on the DPS
/// broadcast — the test is validating the command ingestion and reply path only.</para>
/// </summary>
[Fact]
public async Task Reconnect_RoundTrip_ReturnsOk()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var correlationId = Guid.NewGuid();
var msg = new ReconnectDriver(
ClusterId: "cluster-e2e-test",
DriverInstanceId: "reconnect-e2e-nonexistent",
ActorByUserName: "e2e-test-runner",
CorrelationId: correlationId);
var result = await client.AskAsync<ReconnectDriverResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeTrue($"ReconnectDriver round-trip failed: {result.Message}");
result.Message.ShouldBeNull();
}
/// <summary>
/// Verifies that a second <see cref="ReconnectDriver"/> for the same instance ID
/// is also accepted (idempotent at the actor layer — the actor simply re-broadcasts
/// to DPS and writes another <c>ConfigEdit</c> row).
/// </summary>
[Fact]
public async Task Reconnect_IsIdempotent_SecondCallAlsoReturnsOk()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
const string instanceId = "reconnect-idempotency-test";
var first = new ReconnectDriver("cluster-1", instanceId, "runner", Guid.NewGuid());
var second = new ReconnectDriver("cluster-1", instanceId, "runner", Guid.NewGuid());
var r1 = await client.AskAsync<ReconnectDriverResult>(first, Ct);
var r2 = await client.AskAsync<ReconnectDriverResult>(second, Ct);
r1.Ok.ShouldBeTrue($"First call failed: {r1.Message}");
r2.Ok.ShouldBeTrue($"Second call failed: {r2.Message}");
r1.CorrelationId.ShouldBe(first.CorrelationId);
r2.CorrelationId.ShouldBe(second.CorrelationId);
}
}