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_MapsUnauthenticated_RpcException_ToTypedException() { FakeGatewayTransport transport = CreateTransport(); transport.AcknowledgeAlarmExceptions.Enqueue( new RpcException(new Status(StatusCode.Unauthenticated, "expired key"))); await using MxGatewayClient client = CreateClient(transport); // Note: the FakeGatewayTransport surfaces RpcException directly (it does not run // through GrpcMxGatewayClientTransport's mapping); the fake's contract here is to // pass the exception verbatim. RpcException → typed exception mapping is covered // in the GrpcMxGatewayClientTransport-level tests; the SDK-level test pins the // pass-through shape so a future migration to direct mapping won't silently // change observable behaviour. 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", }); } }