using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading.Channels; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Grpc; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Tests.Alarms; /// /// Carries the worker's truncated-fetch verdict across the gateway: worker /// reply payload → → the public /// QueryActiveAlarms stream. /// /// /// /// The truncation guard itself (the worker merging rather than replacing /// a capped snapshot) is already covered in the worker suite. What was /// missing is that the guard is silent: a capped fetch suppresses /// absence-implies-Clear inference and says so only in a rate-limited /// stderr warning, so a consumer of the alarm surface could not tell a /// complete active set from a capped one. These tests pin the structural /// signal that replaces the guesswork. /// /// /// The load-bearing assertion is the false one /// (). /// "Truncated reply sets the flag" would also pass against a field /// hard-wired to true; only the complete-reply case proves the flag is /// actually derived from the worker's verdict. /// /// public sealed class AlarmTruncationSignalTests { private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15); /// /// A capped worker reply sets the monitor's completeness caveat and /// stamps every cached snapshot, so both the dashboard (which reads the /// service flag) and the RPC (which reads the records) can surface it. /// /// A task that represents the asynchronous operation. [Fact] public async Task Reconcile_WithTruncatedWorkerReply_SurfacesTheFlagOnMonitorAndSnapshots() { using GatewayMetrics metrics = new(); StubSessionManager sessions = new() { SnapshotTruncated = true, Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)], }; using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); using CancellationTokenSource cts = new(); await monitor.StartAsync(cts.Token); await sessions.WaitForReconcileAsync(WaitTimeout); await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout); Assert.True(monitor.SnapshotTruncated); ActiveAlarmSnapshot cached = Assert.Single(monitor.CurrentAlarms); Assert.True(cached.FromTruncatedSnapshot); // Truncation is about the completeness of the SET, not the fidelity of // the record — the subtag-fallback flag must stay independent of it. Assert.False(cached.Degraded); await cts.CancelAsync(); await monitor.StopAsync(CancellationToken.None); } /// /// The public QueryActiveAlarms stream carries the per-record flag /// through untouched. That RPC returns a bare /// stream ActiveAlarmSnapshot with no envelope message, so the /// per-record boolean is the only place set-level degraded status can /// ride — if the service ever starts re-projecting records instead of /// forwarding them, this is what catches the dropped field. /// /// A task that represents the asynchronous operation. [Fact] public async Task QueryActiveAlarms_WithTruncatedSnapshot_StreamsTheFlagToTheClient() { FakeGatewayAlarmService alarms = new() { SnapshotTruncated = true, CurrentAlarms = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)], }; MxAccessGatewayService service = CreateService(alarms); RecordingServerStreamWriter sink = new(); await service.QueryActiveAlarms( new QueryActiveAlarmsRequest(), sink, new TestServerCallContext()); ActiveAlarmSnapshot streamed = Assert.Single(sink.Messages); Assert.True(streamed.FromTruncatedSnapshot); Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", streamed.AlarmFullReference); } /// /// The control. A complete worker reply must leave both the monitor flag /// and the streamed records unset — otherwise every snapshot would read /// as possibly-incomplete and the signal would carry no information. /// /// A task that represents the asynchronous operation. [Fact] public async Task QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset() { using GatewayMetrics metrics = new(); StubSessionManager sessions = new() { SnapshotTruncated = false, Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: false)], }; using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); using CancellationTokenSource cts = new(); await monitor.StartAsync(cts.Token); await sessions.WaitForReconcileAsync(WaitTimeout); await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout); Assert.False(monitor.SnapshotTruncated); MxAccessGatewayService service = CreateService(new FakeGatewayAlarmService { SnapshotTruncated = monitor.SnapshotTruncated, CurrentAlarms = monitor.CurrentAlarms, }); RecordingServerStreamWriter sink = new(); await service.QueryActiveAlarms( new QueryActiveAlarmsRequest(), sink, new TestServerCallContext()); Assert.False(Assert.Single(sink.Messages).FromTruncatedSnapshot); await cts.CancelAsync(); await monitor.StopAsync(CancellationToken.None); } private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot) { return new ActiveAlarmSnapshot { AlarmFullReference = reference, SourceObjectReference = "Tank01.Level", AlarmTypeName = "HiHi", Category = "Area", Severity = 500, CurrentState = AlarmConditionState.Active, SourceProvider = AlarmProviderMode.Alarmmgr, FromTruncatedSnapshot = fromTruncatedSnapshot, }; } private static GatewayAlarmMonitor CreateMonitor(StubSessionManager sessions, GatewayMetrics metrics) { AlarmsOptions options = new() { Enabled = true, SubscriptionExpression = @"\\NODE\Galaxy!Area", }; return new GatewayAlarmMonitor( sessions, new StubWatchListResolver(), metrics, Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }), NullLogger.Instance); } private static MxAccessGatewayService CreateService(FakeGatewayAlarmService alarms) { StubSessionManager sessions = new(); return new MxAccessGatewayService( sessions, new GatewayRequestIdentityAccessor(), new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), new MxAccessGrpcMapper(), new StubEventStreamService(), new GatewayMetrics(), NullLogger.Instance, alarms); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { DateTime deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (condition()) { return; } await Task.Delay(25); } throw new TimeoutException("Condition was not met in time."); } /// that resolves an empty watch-list. private sealed class StubWatchListResolver : IAlarmWatchListResolver { /// public Task> ResolveAsync( AlarmsOptions options, CancellationToken cancellationToken = default) => Task.FromResult>([]); } /// /// Minimal that answers the monitor's /// QueryActiveAlarms with a scripted reply payload — the seam this suite /// drives the truncation verdict through. /// private sealed class StubSessionManager : ISessionManager { private readonly Channel _events = Channel.CreateUnbounded(); private readonly TaskCompletionSource _reconciled = new(TaskCreationOptions.RunContinuationsAsynchronously); /// Gets or sets the truncation verdict the scripted reply carries. public bool SnapshotTruncated { get; init; } /// Gets or sets the snapshots the scripted reply carries. public IReadOnlyList Snapshots { get; init; } = []; /// Completes once the monitor has issued its first QueryActiveAlarms. /// The maximum time to wait. /// A task that represents the asynchronous operation. public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout); /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, string? ownerKeyId, CancellationToken cancellationToken) { GatewaySession session = new( Guid.NewGuid().ToString("N"), "Galaxy", "pipe-test", "nonce-test", clientIdentity, null, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow); session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader)); session.MarkReady(); return Task.FromResult(session); } /// public Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken) { MxCommandReply reply = new() { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }; if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms) { QueryActiveAlarmsReplyPayload payload = new() { SnapshotTruncated = SnapshotTruncated }; payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone())); reply.QueryActiveAlarms = payload; _reconciled.TrySetResult(); } return Task.FromResult(new WorkerCommandReply { Reply = reply }); } /// public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { session = null; return false; } /// public Task CloseSessionAsync(string sessionId, CancellationToken cancellationToken) { _events.Writer.TryComplete(); return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } /// public Task KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); /// public Task CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(0); /// public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; } /// /// stub — QueryActiveAlarms never /// touches the event path, but the service constructor requires one. /// private sealed class StubEventStreamService : IEventStreamService { /// public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, string? callerKeyId, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask.ConfigureAwait(false); yield break; } } }