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 = [];