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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user