using Google.Protobuf.WellKnownTypes; using Grpc.Core; using MxGateway.Contracts.Proto; namespace MxGateway.Client.Tests; /// /// PR E.2 — pins the .NET SDK surface for the new alarm RPCs: /// and /// . /// public sealed class MxGatewayClientAlarmsTests { [Fact] public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply() { FakeGatewayTransport transport = CreateTransport(); transport.AddAcknowledgeReply(new AcknowledgeAlarmReply { SessionId = "session-fixture", 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 { SessionId = "session-fixture", 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 { SessionId = "session-fixture", 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 { SessionId = "session-fixture", 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 { SessionId = "session-fixture", AlarmFullReference = "Tank01.Level.HiHi", Comment = string.Empty, OperatorUser = "alice", })); Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode); } [Fact] public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots() { 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 snapshots = []; await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest { SessionId = "session-fixture", })) { snapshots.Add(snapshot); } Assert.Equal(2, snapshots.Count); Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference); Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState); Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState); Assert.Single(transport.QueryActiveAlarmsCalls); } [Fact] public async Task QueryActiveAlarmsAsync_PassesFilterPrefix() { FakeGatewayTransport transport = CreateTransport(); await using MxGatewayClient client = CreateClient(transport); await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest { SessionId = "session-fixture", AlarmFilterPrefix = "Tank01.", })) { // no snapshots enqueued; just verifying the request passes through } var call = Assert.Single(transport.QueryActiveAlarmsCalls); Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix); } [Fact] public async Task QueryActiveAlarmsAsync_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 (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync( new QueryActiveAlarmsRequest { SessionId = "session-fixture" }, 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", }); } }