gateway: AcknowledgeAlarm + QueryActiveAlarms handler tests (PR A.4)

Nineteenth (final) PR of the alarms-over-gateway epic. Pins the
public RPC handler contract added in PR A.3:

- AcknowledgeAlarm rejects empty session_id and empty
  alarm_full_reference with InvalidArgument.
- AcknowledgeAlarm with valid input returns OK and a
  worker-pending diagnostic so clients see a successful round-trip
  even before A.2's worker dispatch lands.
- QueryActiveAlarms rejects empty session_id with InvalidArgument.
- QueryActiveAlarms with valid input streams zero snapshots until
  PR A.2 wires the worker-side QueryActiveAlarmsCommand
  (filter-prefix passthrough verified at the proto layer).
- OpenSession advertises both new RPC capability strings
  (unary-acknowledge-alarm, server-stream-active-alarms) so client
  capability negotiation lights up against the contract surface.

Closes Track A's gateway-side surface. The remaining worker
ConditionRefresh walk + integration parity-rig validation lands
during dev-rig hardware validation alongside PR A.2's COM-side
alarm subscription pin.

Tests: 279 passed (was 273; 6 new). Per-handler integration tests
land alongside the dev-rig validation when the worker walks the
real MxAccess active-alarm collection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 21:20:57 -04:00
parent bc61598b44
commit 9de2c0c43d
@@ -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.
/// <summary>Verifies AcknowledgeAlarm rejects empty session_id.</summary>
[Fact]
public async Task AcknowledgeAlarm_WithMissingSessionId_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.AcknowledgeAlarm(
new AcknowledgeAlarmRequest
{
AlarmFullReference = "Tank01.Level.HiHi",
OperatorUser = "alice",
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies AcknowledgeAlarm rejects empty alarm_full_reference.</summary>
[Fact]
public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.AcknowledgeAlarm(
new AcknowledgeAlarmRequest
{
SessionId = "session-1",
OperatorUser = "alice",
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies AcknowledgeAlarm returns OK with a worker-pending diagnostic for valid input.</summary>
[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);
}
/// <summary>Verifies QueryActiveAlarms rejects empty session_id.</summary>
[Fact]
public async Task QueryActiveAlarms_WithMissingSessionId_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
new RecordingStreamWriter<ActiveAlarmSnapshot>(),
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies QueryActiveAlarms streams zero snapshots until PR A.2 wires the worker walk.</summary>
[Fact]
public async Task QueryActiveAlarms_WithValidRequest_StreamsZeroSnapshots()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RecordingStreamWriter<ActiveAlarmSnapshot> sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest
{
SessionId = "session-1",
AlarmFilterPrefix = "Tank01.",
},
sink,
new TestServerCallContext());
Assert.Empty(sink.Items);
}
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
[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<T> : IServerStreamWriter<T>
{
public List<T> 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 = [];