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

165 lines
7.6 KiB
C#

using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Hosting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>DriverStatusSignalRBridge</c> actor → snapshot
/// store → SignalR hub push pipeline.
///
/// <para><b>Scope note:</b> wiring a full SignalR hub connection from inside an
/// integration test requires an HTTP listener, JWT authentication (the hub has
/// <c>[Authorize]</c>), and a real WebSocket upgrade — significantly more plumbing
/// than the two-node harness provides out of the box. Full-stack hub connectivity is
/// covered by the Playwright smoke tests in the manual runbook (§8.3). This suite
/// instead exercises the bridge actor directly: it spawns a
/// <see cref="DriverStatusSignalRBridge"/> inside the harness actor system, publishes
/// a <see cref="DriverHealthChanged"/> to the <c>driver-health</c> DPS topic, and
/// asserts that (a) the snapshot store is updated and (b) the mock
/// <see cref="IHubContext{DriverStatusHub}"/> receives a <c>SendAsync</c> call with
/// the matching <c>DriverInstanceId</c>. This validates the bridge actor's DPS
/// subscription, store write, and hub-push code paths without a live HTTP client.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class DriverStatusHubE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="DriverHealthChanged"/> published to the
/// <c>driver-health</c> DPS topic is forwarded by <see cref="DriverStatusSignalRBridge"/>
/// to both the <see cref="IDriverStatusSnapshotStore"/> (via <c>Upsert</c>) and the
/// mock <see cref="IHubContext{DriverStatusHub}"/> (via <c>SendAsync</c>).
/// </summary>
[Fact]
public async Task StatusHub_BridgeActor_ForwardsHealthChanged_ToStoreAndHub()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
// Resolve the snapshot store that AddAdminUI() wired into DI.
var store = harness.NodeA.Services.GetRequiredService<IDriverStatusSnapshotStore>();
// Build a mock IHubContext<DriverStatusHub> that captures SendAsync calls.
var sentMessages = new List<(string method, object? arg)>();
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.Group(It.IsAny<string>())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((method, args, _) =>
sentMessages.Add((method, args.FirstOrDefault())))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<DriverStatusHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
// Spawn the bridge actor directly in the harness ActorSystem.
var bridge = harness.NodeASystem.ActorOf(
DriverStatusSignalRBridge.Props(mockHub.Object, store),
$"test-driver-status-bridge-{Guid.NewGuid():N}");
// Wait for the DPS subscription to be acknowledged.
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
// Publish a DriverHealthChanged snapshot via DPS.
const string testInstanceId = "driver-hub-e2e-test-instance";
var snapshot = new DriverHealthChanged(
ClusterId: "cluster-e2e",
DriverInstanceId: testInstanceId,
State: "Healthy",
LastSuccessfulReadUtc: DateTime.UtcNow,
LastError: null,
ErrorCount5Min: 0,
PublishedUtc: DateTime.UtcNow);
DistributedPubSub.Get(harness.NodeASystem).Mediator.Tell(
new Publish(DriverStatusSignalRBridge.TopicName, snapshot));
// Wait up to 3s for the bridge actor to process the message and invoke the hub mock.
await WaitForAsync(
() => Task.FromResult(sentMessages.Count > 0),
TimeSpan.FromSeconds(3));
// Assert snapshot store was updated.
store.TryGet(testInstanceId, out var stored).ShouldBeTrue("Snapshot store should contain the published snapshot.");
stored.DriverInstanceId.ShouldBe(testInstanceId);
stored.State.ShouldBe("Healthy");
// Assert hub mock received the push on the expected method name.
sentMessages.ShouldNotBeEmpty("Hub mock should have received a SendAsync call.");
sentMessages[0].method.ShouldBe(DriverStatusHub.MethodName);
// Clean up actor to avoid lingering DPS subscription.
harness.NodeASystem.Stop(bridge);
}
/// <summary>
/// Verifies that publishing two consecutive <see cref="DriverHealthChanged"/> snapshots
/// for the same instance ID results in the store holding only the most recent state
/// (last-write-wins) and both hub push calls being made.
/// </summary>
[Fact]
public async Task StatusHub_BridgeActor_LastSnapshotWins_InStore()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
var store = harness.NodeA.Services.GetRequiredService<IDriverStatusSnapshotStore>();
var hubCallCount = 0;
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.Group(It.IsAny<string>())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((_, _, _) => Interlocked.Increment(ref hubCallCount))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<DriverStatusHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
var bridge = harness.NodeASystem.ActorOf(
DriverStatusSignalRBridge.Props(mockHub.Object, store),
$"test-driver-status-bridge-2-{Guid.NewGuid():N}");
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
const string instanceId = "driver-hub-last-write-wins";
var mediator = DistributedPubSub.Get(harness.NodeASystem).Mediator;
mediator.Tell(new Publish(DriverStatusSignalRBridge.TopicName,
new DriverHealthChanged("c1", instanceId, "Reconnecting", null, "lost connection", 1, DateTime.UtcNow)));
mediator.Tell(new Publish(DriverStatusSignalRBridge.TopicName,
new DriverHealthChanged("c1", instanceId, "Healthy", DateTime.UtcNow, null, 0, DateTime.UtcNow)));
await WaitForAsync(
() => Task.FromResult(hubCallCount >= 2),
TimeSpan.FromSeconds(3));
// Store should reflect the most recent (Healthy) state.
store.TryGet(instanceId, out var stored).ShouldBeTrue();
stored.State.ShouldBe("Healthy");
hubCallCount.ShouldBeGreaterThanOrEqualTo(2);
harness.NodeASystem.Stop(bridge);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await condition()) return;
await Task.Delay(100);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
}