diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index e87a143..b3654a1 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -229,6 +229,124 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } + // ===== PR A.4 — AcknowledgeAlarm + QueryActiveAlarms handler contract ===== + // + // Worker-side dispatch (translating AcknowledgeAlarm to MxAccess Acknowledge, + // walking the active-alarm collection for QueryActiveAlarms) is gated on PR + // A.2's dev-rig validation. These tests pin the public surface so the worker + // wiring lands without changing observable behaviour for clients. + + /// Verifies AcknowledgeAlarm rejects empty session_id. + [Fact] + public async Task AcknowledgeAlarm_WithMissingSessionId_ThrowsInvalidArgument() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.AcknowledgeAlarm( + new AcknowledgeAlarmRequest + { + AlarmFullReference = "Tank01.Level.HiHi", + OperatorUser = "alice", + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + } + + /// Verifies AcknowledgeAlarm rejects empty alarm_full_reference. + [Fact] + public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.AcknowledgeAlarm( + new AcknowledgeAlarmRequest + { + SessionId = "session-1", + OperatorUser = "alice", + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + } + + /// Verifies AcknowledgeAlarm returns OK with a worker-pending diagnostic for valid input. + [Fact] + public async Task AcknowledgeAlarm_WithValidRequest_ReturnsOkWithWorkerPendingDiagnostic() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + + AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm( + new AcknowledgeAlarmRequest + { + SessionId = "session-1", + ClientCorrelationId = "corr-1", + AlarmFullReference = "Tank01.Level.HiHi", + Comment = "investigating", + OperatorUser = "alice", + }, + new TestServerCallContext()); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal("session-1", reply.SessionId); + Assert.Equal("corr-1", reply.CorrelationId); + Assert.Contains("worker", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); + } + + /// Verifies QueryActiveAlarms rejects empty session_id. + [Fact] + public async Task QueryActiveAlarms_WithMissingSessionId_ThrowsInvalidArgument() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.QueryActiveAlarms( + new QueryActiveAlarmsRequest(), + new RecordingStreamWriter(), + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + } + + /// Verifies QueryActiveAlarms streams zero snapshots until PR A.2 wires the worker walk. + [Fact] + public async Task QueryActiveAlarms_WithValidRequest_StreamsZeroSnapshots() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + RecordingStreamWriter sink = new(); + + await service.QueryActiveAlarms( + new QueryActiveAlarmsRequest + { + SessionId = "session-1", + AlarmFilterPrefix = "Tank01.", + }, + sink, + new TestServerCallContext()); + + Assert.Empty(sink.Items); + } + + /// Verifies OpenSession advertises the alarm RPC capability strings. + [Fact] + public async Task OpenSession_AdvertisesAlarmRpcCapabilities() + { + FakeSessionManager sessionManager = new(); + GatewayRequestIdentityAccessor identityAccessor = new(); + MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); + + using IDisposable identityScope = identityAccessor.Push(CreateIdentity()); + + OpenSessionReply reply = await service.OpenSession( + new OpenSessionRequest(), + new TestServerCallContext()); + + Assert.Contains("unary-acknowledge-alarm", reply.Capabilities); + Assert.Contains("server-stream-active-alarms", reply.Capabilities); + } + private static MxAccessGatewayService CreateService( FakeSessionManager sessionManager, IGatewayRequestIdentityAccessor? identityAccessor = null, @@ -549,6 +667,18 @@ public sealed class MxAccessGatewayServiceTests } } + private sealed class RecordingStreamWriter : IServerStreamWriter + { + public List Items { get; } = new(); + public WriteOptions? WriteOptions { get; set; } + + public Task WriteAsync(T message) + { + Items.Add(message); + return Task.CompletedTask; + } + } + private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext { private readonly Metadata requestHeaders = [];