ac12c150c3
Replace the client's QueryActiveAlarmsAsync with StreamAlarmsAsync — a session-less subscription to the gateway's central alarm feed that yields the active-alarm snapshot followed by live transitions. AcknowledgeAlarm is session-less (AcknowledgeAlarmRequest no longer carries a session id). Updates the transport interface, the gRPC transport, the test fake, and the alarm tests; the .NET client solution builds and its alarm tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
8.7 KiB
C#
209 lines
8.7 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using MxGateway.Contracts.Proto;
|
|
|
|
namespace MxGateway.Client.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the .NET SDK surface for the alarm RPCs:
|
|
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
|
|
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>.
|
|
/// </summary>
|
|
public sealed class MxGatewayClientAlarmsTests
|
|
{
|
|
[Fact]
|
|
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
|
{
|
|
FakeGatewayTransport transport = CreateTransport();
|
|
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
|
{
|
|
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
|
|
{
|
|
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<OperationCanceledException>(() =>
|
|
client.AcknowledgeAlarmAsync(
|
|
new AcknowledgeAlarmRequest
|
|
{
|
|
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<RpcException>(
|
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
|
{
|
|
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<MxGatewayAuthenticationException>(
|
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
|
{
|
|
AlarmFullReference = "Tank01.Level.HiHi",
|
|
Comment = string.Empty,
|
|
OperatorUser = "alice",
|
|
}));
|
|
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StreamAlarmsAsync_StreamsSnapshotThenSnapshotComplete()
|
|
{
|
|
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<AlarmFeedMessage> messages = [];
|
|
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest()))
|
|
{
|
|
messages.Add(message);
|
|
}
|
|
|
|
Assert.Equal(3, messages.Count);
|
|
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference);
|
|
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState);
|
|
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState);
|
|
Assert.True(messages[2].SnapshotComplete);
|
|
Assert.Single(transport.StreamAlarmsCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StreamAlarmsAsync_PassesFilterPrefix()
|
|
{
|
|
FakeGatewayTransport transport = CreateTransport();
|
|
await using MxGatewayClient client = CreateClient(transport);
|
|
|
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest
|
|
{
|
|
AlarmFilterPrefix = "Tank01.",
|
|
}))
|
|
{
|
|
// only the snapshot-complete sentinel; verifying the request passes through
|
|
}
|
|
|
|
var call = Assert.Single(transport.StreamAlarmsCalls);
|
|
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StreamAlarmsAsync_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<OperationCanceledException>(async () =>
|
|
{
|
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(
|
|
new StreamAlarmsRequest(),
|
|
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",
|
|
});
|
|
}
|
|
}
|