0765eb4de3
Seventh PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md). Depends on PR A.1 (proto, merged) and E.1 (regen, merged). Hand-written .NET SDK methods on top of the regenerated proto types: - MxGatewayClient.AcknowledgeAlarmAsync — routes through the existing safe-unary retry pipeline (Acks are idempotent at MxAccess), maps Unauthenticated/PermissionDenied RpcExceptions to typed MxGatewayAuthenticationException / MxGatewayAuthorizationException via GrpcMxGatewayClientTransport.MapRpcException. - MxGatewayClient.QueryActiveAlarmsAsync — server-streaming IAsyncEnumerable<ActiveAlarmSnapshot> mirroring the StreamEvents pattern. - IMxGatewayClientTransport extended; GrpcMxGatewayClientTransport implements both methods using the regenerated grpc client. - FakeGatewayTransport extended with capture lists, exception queue, and reply / snapshot enqueue helpers. CLI version-string assertions updated for the GatewayProtocolVersion 2 → 3 bump from A.1. The CLI alarms verb (subscribe / acknowledge / query-active) is deferred to a follow-up — keeping this PR focused on the SDK surface that lmxopcua's GalaxyDriver consumes in PR B.2. The other-language SDKs (E.3-E.6) layer the same shape on the regen. Tests: - 6 new MxGatewayClientAlarmsTests — request shape, cancellation honor (linked-token via retry pipeline), Unauthenticated mapping, streaming snapshot enumeration, filter prefix passthrough, cancellation during enumeration. - Full client test suite: 57 passed (was 51; 6 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
7.8 KiB
C#
193 lines
7.8 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using MxGateway.Contracts.Proto;
|
|
|
|
namespace MxGateway.Client.Tests;
|
|
|
|
/// <summary>
|
|
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
|
|
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
|
|
/// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
|
|
/// </summary>
|
|
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<OperationCanceledException>(() =>
|
|
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<RpcException>(
|
|
() => 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<ActiveAlarmSnapshot> 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<OperationCanceledException>(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",
|
|
});
|
|
}
|
|
}
|