b5748288df
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).
118 lines
5.7 KiB
C#
118 lines
5.7 KiB
C#
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}");
|
|
}
|
|
}
|