using Google.Protobuf.WellKnownTypes; using Grpc.Core; using MxGateway.Contracts.Proto; namespace MxGateway.Client.Tests; /// /// Pins the .NET SDK surface for the alarm RPCs: /// and /// . /// public sealed class MxGatewayClientAlarmsTests { [Fact] public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply() { FakeGatewayTransport transport = CreateTransport(); transport.AddAcknowledgeReply(new AcknowledgeAlarmReply { CorrelationId = "corr-1", ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok, DetectedBy = MxStatusSource.RespondingLmx, }, }); await using MxGatewayClient client = CreateClient(transport); AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest { ClientCorrelationId = "corr-1", AlarmFullReference = "Tank01.Level.HiHi", Comment = "investigating", OperatorUser = "alice", }); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxStatusCategory.Ok, reply.Status.Category); var call = Assert.Single(transport.AcknowledgeAlarmCalls); Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference); Assert.Equal("investigating", call.Request.Comment); Assert.Equal("alice", call.Request.OperatorUser); Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization")); } [Fact] public async Task AcknowledgeAlarmAsync_HonorsCancellation() { // Acks are routed through the safe-unary retry pipeline (idempotent at the // MxAccess level), so the transport-side cancellation token is a linked one // rather than the caller's original. Verify cancellation by tripping the source // and asserting the call observes it. using CancellationTokenSource cancellation = new(); cancellation.Cancel(); FakeGatewayTransport transport = CreateTransport(); await using MxGatewayClient client = CreateClient(transport); await Assert.ThrowsAnyAsync(() => client.AcknowledgeAlarmAsync( new AcknowledgeAlarmRequest { AlarmFullReference = "Tank01.Level.HiHi", Comment = string.Empty, OperatorUser = "alice", }, cancellation.Token)); } [Fact] public async Task AcknowledgeAlarmAsync_SurfacesRpcExceptionFromFakeTransportVerbatim_WhenMappingDisabled() { // Default FakeGatewayTransport.MapTransportExceptions is false, matching the // historical pass-through shape: a thrown RpcException reaches the caller as // RpcException rather than being mapped to a typed MxGatewayException. This // test pins that shape so a future change can't silently flip it. FakeGatewayTransport transport = CreateTransport(); transport.AcknowledgeAlarmExceptions.Enqueue( new RpcException(new Status(StatusCode.Unauthenticated, "expired key"))); await using MxGatewayClient client = CreateClient(transport); var ex = await Assert.ThrowsAsync( () => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest { AlarmFullReference = "Tank01.Level.HiHi", Comment = string.Empty, OperatorUser = "alice", })); Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode); } [Fact] public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException() { // Production parity: GrpcMxGatewayClientTransport.AcknowledgeAlarmAsync runs // every thrown RpcException through RpcExceptionMapper.Map, so callers see // MxGatewayAuthenticationException (for Unauthenticated) rather than the raw // RpcException. The fake transport reproduces that mapping when // MapTransportExceptions is set, letting this SDK-level test cover the same // observable behaviour without standing up a real gRPC channel. FakeGatewayTransport transport = CreateTransport(); transport.MapTransportExceptions = true; transport.AcknowledgeAlarmExceptions.Enqueue( new RpcException(new Status(StatusCode.Unauthenticated, "expired key"))); await using MxGatewayClient client = CreateClient(transport); var ex = await Assert.ThrowsAsync( () => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest { AlarmFullReference = "Tank01.Level.HiHi", Comment = string.Empty, OperatorUser = "alice", })); Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode); } [Fact] public async Task StreamAlarmsAsync_StreamsSnapshotThenSnapshotComplete() { FakeGatewayTransport transport = CreateTransport(); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active)); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked)); await using MxGatewayClient client = CreateClient(transport); List messages = []; await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest())) { messages.Add(message); } Assert.Equal(3, messages.Count); Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference); Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState); Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState); Assert.True(messages[2].SnapshotComplete); Assert.Single(transport.StreamAlarmsCalls); } [Fact] public async Task StreamAlarmsAsync_PassesFilterPrefix() { FakeGatewayTransport transport = CreateTransport(); await using MxGatewayClient client = CreateClient(transport); await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest { AlarmFilterPrefix = "Tank01.", })) { // only the snapshot-complete sentinel; verifying the request passes through } var call = Assert.Single(transport.StreamAlarmsCalls); Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix); } [Fact] public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration() { FakeGatewayTransport transport = CreateTransport(); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active)); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.Active)); await using MxGatewayClient client = CreateClient(transport); using CancellationTokenSource cancellation = new(); await Assert.ThrowsAsync(async () => { await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync( new StreamAlarmsRequest(), cancellation.Token)) { cancellation.Cancel(); } }); } private static ActiveAlarmSnapshot MakeSnapshot(string fullReference, AlarmConditionState state) { return new ActiveAlarmSnapshot { AlarmFullReference = fullReference, SourceObjectReference = fullReference.Split('.')[0], AlarmTypeName = "AnalogLimitAlarm.HiHi", Severity = 750, CurrentState = state, Category = "Process", Description = "Tank high-high level", OriginalRaiseTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)), LastTransitionTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)), }; } private static MxGatewayClient CreateClient(FakeGatewayTransport transport) { return new MxGatewayClient(transport.Options, transport); } private static FakeGatewayTransport CreateTransport() { return new FakeGatewayTransport(new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = "test-api-key", }); } }