using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
///
/// Verifies that honours
/// MxGateway:Dashboard:ShowTagValues (SEC-25): tag values are stripped
/// from the mirrored copy when the flag is off, present when it is on, and the
/// shared source event is never mutated. Also verifies the viewer gate — the
/// mirror does no work at all for a session nobody is watching.
///
public sealed class DashboardEventBroadcasterTests
{
/// An unwatched session costs neither a redaction clone nor a send.
[Fact]
public void Publish_WithNoRegisteredViewers_DoesNotCloneOrSend()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
MxEvent source = BuildEventWithValue();
broadcaster.Publish("session-1", source);
Assert.Equal(0, hubContext.SendCount);
Assert.Null(hubContext.LastArgument);
}
/// A viewer on a different session does not open the gate for this one.
[Fact]
public void Publish_WithViewersOnAnotherSessionOnly_DoesNotSend()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
viewers.AddViewer("conn-1", "session-2");
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(0, hubContext.SendCount);
}
/// Once the last viewer leaves, the mirror stops sending again.
[Fact]
public void Publish_AfterLastViewerLeaves_StopsSending()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
viewers.AddViewer("conn-1", "session-1");
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
viewers.RemoveViewer("conn-1", "session-1");
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
}
/// Values are stripped from the mirror when ShowTagValues is off; metadata survives.
[Fact]
public void Publish_WhenShowTagValuesFalse_RedactsValuesButKeepsMetadata()
{
CapturingHubContext hubContext = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1());
MxEvent source = BuildEventWithValue();
broadcaster.Publish("session-1", source);
MxEvent sent = Assert.IsType(hubContext.LastArgument);
Assert.Null(sent.Value);
Assert.Null(sent.OnAlarmTransition.CurrentValue);
Assert.Null(sent.OnAlarmTransition.LimitValue);
// Metadata unrelated to the value survives redaction.
Assert.Equal("session-1", sent.SessionId);
Assert.Equal(7, sent.ServerHandle);
Assert.Equal(11, sent.ItemHandle);
Assert.Equal(192, sent.Quality);
Assert.Equal("Tank01.Level.HiHi", sent.OnAlarmTransition.AlarmFullReference);
}
/// Redaction applies to a clone, so the shared source event keeps its values.
[Fact]
public void Publish_WhenShowTagValuesFalse_DoesNotMutateSourceEvent()
{
CapturingHubContext hubContext = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1());
MxEvent source = BuildEventWithValue();
broadcaster.Publish("session-1", source);
// The redaction must apply to a clone; the shared source keeps its values.
Assert.NotNull(source.Value);
Assert.Equal(42.5, source.Value.DoubleValue);
Assert.NotNull(source.OnAlarmTransition.CurrentValue);
Assert.NotNull(source.OnAlarmTransition.LimitValue);
Assert.NotSame(source, hubContext.LastArgument);
}
/// Values pass through unredacted when ShowTagValues is on.
[Fact]
public void Publish_WhenShowTagValuesTrue_KeepsValues()
{
CapturingHubContext hubContext = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: true, WatchedSession1());
MxEvent source = BuildEventWithValue();
broadcaster.Publish("session-1", source);
MxEvent sent = Assert.IsType(hubContext.LastArgument);
Assert.NotNull(sent.Value);
Assert.Equal(42.5, sent.Value.DoubleValue);
Assert.NotNull(sent.OnAlarmTransition.CurrentValue);
Assert.NotNull(sent.OnAlarmTransition.LimitValue);
}
///
/// An in-process subscriber gets the same redacted clone the hub group gets,
/// and the shared source event is still left untouched.
///
[Fact]
public void Subscribe_WhenShowTagValuesFalse_DeliversRedactedCloneWithoutMutatingSource()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
MxEvent source = BuildEventWithValue();
broadcaster.Publish("session-1", source);
MxEvent received = ReadOne(subscription);
Assert.Null(received.Value);
Assert.Null(received.OnAlarmTransition.CurrentValue);
Assert.Null(received.OnAlarmTransition.LimitValue);
Assert.Equal("Tank01.Level.HiHi", received.OnAlarmTransition.AlarmFullReference);
// One clone feeds both audiences — the hub group and the in-process feed.
Assert.Same(hubContext.LastArgument, received);
// The source is shared with the gRPC stream and the replay ring.
Assert.NotSame(source, received);
Assert.NotNull(source.Value);
Assert.Equal(42.5, source.Value.DoubleValue);
Assert.NotNull(source.OnAlarmTransition.CurrentValue);
Assert.NotNull(source.OnAlarmTransition.LimitValue);
}
/// An in-process subscription opens the viewer gate the same way a hub client does.
[Fact]
public void Subscribe_OpensTheViewerGate()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(0, hubContext.SendCount);
Assert.Null(hubContext.LastArgument);
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
Assert.True(viewers.HasViewers("session-1"));
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
Assert.NotNull(hubContext.LastArgument);
}
/// Disposing the last in-process subscription restores the no-viewers short-circuit.
[Fact]
public void Dispose_OfLastInProcessSubscription_RestoresTheShortCircuit()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
Assert.Equal("session-1", ReadOne(subscription).SessionId);
subscription.Dispose();
Assert.False(viewers.HasViewers("session-1"));
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
Assert.False(subscription.Reader.TryRead(out _));
}
/// Hub viewers and in-process subscribers are audiences of their own session only.
[Fact]
public void Publish_DeliversOnlyToTheSubscribedSessionsAudience()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
viewers.AddViewer("conn-1", "session-1");
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-2");
// The hub viewer's session must not spill into the in-process feed.
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
Assert.False(subscription.Reader.TryRead(out _));
broadcaster.Publish("session-2", BuildEventWithValue("session-2"));
Assert.Equal("session-2", ReadOne(subscription).SessionId);
// A session with no audience at all still short-circuits.
broadcaster.Publish("session-3", BuildEventWithValue("session-3"));
Assert.Equal(2, hubContext.SendCount);
}
/// Disposing twice is a no-op and cannot release a sibling subscription's registration.
[Fact]
public void Dispose_CalledTwice_IsSafeAndLeavesSiblingSubscriptionsAlone()
{
CapturingHubContext hubContext = new();
EventsHubViewerRegistry viewers = new();
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
IDashboardEventSubscription first = broadcaster.Subscribe("session-1");
using IDashboardEventSubscription second = broadcaster.Subscribe("session-1");
first.Dispose();
first.Dispose();
Assert.True(viewers.HasViewers("session-1"));
broadcaster.Publish("session-1", BuildEventWithValue());
Assert.Equal(1, hubContext.SendCount);
Assert.False(first.Reader.TryRead(out _));
Assert.Equal("session-1", ReadOne(second).SessionId);
}
/// Reads exactly one event from a subscription, failing the test if none is queued.
/// The subscription to read from.
/// The event that was read.
private static MxEvent ReadOne(IDashboardEventSubscription subscription)
{
Assert.True(subscription.Reader.TryRead(out MxEvent? received));
return Assert.IsType(received);
}
private static DashboardEventBroadcaster Create(
CapturingHubContext hubContext,
bool showTagValues,
EventsHubViewerRegistry viewers)
{
GatewayOptions gatewayOptions = new()
{
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
};
return new DashboardEventBroadcaster(
hubContext,
viewers,
Options.Create(gatewayOptions),
NullLogger.Instance);
}
/// A registry with one hub connection watching session-1.
/// The populated registry.
private static EventsHubViewerRegistry WatchedSession1()
{
EventsHubViewerRegistry viewers = new();
viewers.AddViewer("conn-1", "session-1");
return viewers;
}
/// Builds a value-bearing alarm-transition event for the given session.
/// Session id stamped on the event.
/// The event.
private static MxEvent BuildEventWithValue(string sessionId = "session-1")
{
return new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
SessionId = sessionId,
ServerHandle = 7,
ItemHandle = 11,
Quality = 192,
Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 },
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 },
},
};
}
private sealed class CapturingHubContext : IHubContext
{
private readonly CapturingHubClients _clients = new();
/// Gets the hub clients.
public IHubClients Clients => _clients;
/// Gets the group manager.
public IGroupManager Groups { get; } = new NoopGroupManager();
/// Gets the first argument of the most recent send call.
public object? LastArgument => _clients.GroupProxy.LastArgument;
/// Gets the number of send calls this fake has observed.
public int SendCount => _clients.GroupProxy.SendCount;
}
private sealed class CapturingHubClients : IHubClients
{
/// Gets the capturing client proxy shared by this fake.
public CapturingClientProxy GroupProxy { get; } = new();
public IClientProxy All => GroupProxy;
public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => GroupProxy;
public IClientProxy Client(string connectionId) => GroupProxy;
public IClientProxy Clients(IReadOnlyList connectionIds) => GroupProxy;
public IClientProxy Group(string groupName) => GroupProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => GroupProxy;
public IClientProxy Groups(IReadOnlyList groupNames) => GroupProxy;
public IClientProxy User(string userId) => GroupProxy;
public IClientProxy Users(IReadOnlyList userIds) => GroupProxy;
}
private sealed class CapturingClientProxy : IClientProxy
{
/// Gets the first argument of the most recent send call.
public object? LastArgument { get; private set; }
/// Gets the number of send calls made through this proxy.
public int SendCount { get; private set; }
/// Records the send call arguments and completes synchronously.
/// The SignalR method name.
/// The method arguments.
/// Token to observe for cancellation.
/// A completed task.
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
SendCount++;
LastArgument = args.Length > 0 ? args[0] : null;
return Task.CompletedTask;
}
}
private sealed class NoopGroupManager : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}