test(scriptlog): prove bridge→broadcaster delivery off the script-logs DPS topic

Composes the one Layer-0 hop existing tests left uncovered together:
ScriptLogSignalRBridge subscribing to the script-logs DPS topic and
fanning a ScriptLogEntry out to the IInProcessBroadcaster<ScriptLogEntry>
singleton resolved from the SAME DI container the /script-log page injects.
Mirrors DriverStatusHubE2eTests. Confirms the server-side topic→page chain
delivers end-to-end (only the live Blazor circuit remains manual).
This commit is contained in:
Joseph Doherty
2026-06-10 13:53:34 -04:00
parent c42a056537
commit b5748288df
@@ -0,0 +1,117 @@
using System.Collections.Concurrent;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
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.Logging;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>ScriptLogSignalRBridge</c> actor → in-process
/// broadcaster → Blazor <c>/script-log</c> page pipeline (Layer 0 of the script-log /
/// scripted-alarm runtime work).
///
/// <para><b>Scope note:</b> mirrors <see cref="DriverStatusHubE2eTests"/>. The Blazor circuit
/// itself can't be exercised from an integration test (it needs an HTTP listener, JWT auth, and
/// a real WebSocket upgrade — covered by the manual runbook). This suite instead exercises the
/// exact server-side delivery the page depends on: it spawns a
/// <see cref="ScriptLogSignalRBridge"/> in the harness actor system, publishes a
/// <see cref="ScriptLogEntry"/> to the <c>script-logs</c> DPS topic, and asserts that the entry
/// reaches the <see cref="IInProcessBroadcaster{T}"/> singleton resolved from the SAME DI
/// container the <c>/script-log</c> page injects from (plus the SignalR hub push). This is the
/// link the existing <see cref="DpsScriptLogPublisherTests"/> (publisher → topic) and
/// <see cref="InProcessBroadcasterTests"/> (broadcaster → subscriber) tests do not cover
/// together: bridge subscription → broadcaster fan-out off the live topic.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class ScriptLogHubE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="ScriptLogEntry"/> published to the <c>script-logs</c> DPS
/// topic is forwarded by <see cref="ScriptLogSignalRBridge"/> to (a) the
/// <see cref="IInProcessBroadcaster{T}"/> singleton the Blazor page reads and (b) the mock
/// <see cref="IHubContext{ScriptLogHub}"/> via a <c>SendAsync</c> on the expected method.
/// </summary>
[Fact]
public async Task ScriptLogBridge_ForwardsEntry_ToInProcessBroadcaster_FromDpsTopic()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
// The SAME singleton the Blazor /script-log page injects: AddAdminUI() →
// AddOtOpcUaDriverStatusServices() registers IInProcessBroadcaster<> as an open-generic
// singleton, so resolving the closed type here yields the page's instance.
var broadcaster = harness.NodeA.Services.GetRequiredService<IInProcessBroadcaster<ScriptLogEntry>>();
var received = new ConcurrentQueue<ScriptLogEntry>();
void Handler(ScriptLogEntry e) => received.Enqueue(e);
broadcaster.Received += Handler;
// Mock IHubContext<ScriptLogHub>: the bridge pushes to Clients.All in addition to the
// broadcaster (for any out-of-process SignalR client).
var hubCalls = new List<(string Method, object? Arg)>();
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((method, args, _) =>
hubCalls.Add((method, args.FirstOrDefault())))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<ScriptLogHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
var bridge = harness.NodeASystem.ActorOf(
ScriptLogSignalRBridge.Props(mockHub.Object, broadcaster),
$"test-script-log-bridge-{Guid.NewGuid():N}");
// Let the DPS subscription register + gossip before publishing.
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
var entry = new ScriptLogEntry(
ScriptId: "script-log-e2e",
Level: "Information",
Message: "hello from e2e",
TimestampUtc: DateTime.UtcNow,
VirtualTagId: "vtag-e2e",
AlarmId: null,
EquipmentId: "EQ-e2e");
// DPS topic membership is eventually consistent — publish in a retry loop until delivered
// (mirrors DpsScriptLogPublisherTests / DriverStatusHubE2eTests round-trip pattern).
await WaitForAsync(() =>
{
DistributedPubSub.Get(harness.NodeASystem).Mediator.Tell(
new Publish(ScriptLogSignalRBridge.TopicName, entry));
return Task.FromResult(!received.IsEmpty);
}, TimeSpan.FromSeconds(5));
received.ShouldNotBeEmpty(
"The /script-log page's in-process broadcaster must receive the entry the bridge forwards off the DPS topic.");
received.TryDequeue(out var got).ShouldBeTrue();
got.ShouldBe(entry);
// The hub push also fired on the expected method name.
hubCalls.ShouldNotBeEmpty("Hub mock should have received a SendAsync call.");
hubCalls[0].Method.ShouldBe(ScriptLogHub.MethodName);
broadcaster.Received -= Handler;
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}");
}
}