From b5748288df4e278b2ced253ed22c7037dcb31ea0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 13:53:34 -0400 Subject: [PATCH] =?UTF-8?q?test(scriptlog):=20prove=20bridge=E2=86=92broad?= =?UTF-8?q?caster=20delivery=20off=20the=20script-logs=20DPS=20topic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- .../ScriptLogHubE2eTests.cs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ScriptLogHubE2eTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ScriptLogHubE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ScriptLogHubE2eTests.cs new file mode 100644 index 00000000..188975da --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ScriptLogHubE2eTests.cs @@ -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; + +/// +/// E2E integration coverage for the ScriptLogSignalRBridge actor → in-process +/// broadcaster → Blazor /script-log page pipeline (Layer 0 of the script-log / +/// scripted-alarm runtime work). +/// +/// Scope note: mirrors . 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 +/// in the harness actor system, publishes a +/// to the script-logs DPS topic, and asserts that the entry +/// reaches the singleton resolved from the SAME DI +/// container the /script-log page injects from (plus the SignalR hub push). This is the +/// link the existing (publisher → topic) and +/// (broadcaster → subscriber) tests do not cover +/// together: bridge subscription → broadcaster fan-out off the live topic. +/// +[Trait("Category", "Integration")] +public sealed class ScriptLogHubE2eTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// + /// Verifies that a published to the script-logs DPS + /// topic is forwarded by to (a) the + /// singleton the Blazor page reads and (b) the mock + /// via a SendAsync on the expected method. + /// + [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>(); + var received = new ConcurrentQueue(); + void Handler(ScriptLogEntry e) => received.Enqueue(e); + broadcaster.Received += Handler; + + // Mock IHubContext: 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(); + var mockClientProxy = new Mock(); + mockClients.Setup(c => c.All).Returns(mockClientProxy.Object); + mockClientProxy + .Setup(p => p.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((method, args, _) => + hubCalls.Add((method, args.FirstOrDefault()))) + .Returns(Task.CompletedTask); + var mockHub = new Mock>(); + 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> 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}"); + } +}