fix(dotnet-client): no ack retry, caller-owned channels, safe session dispose, typed diagnostics, AttachSession
This commit is contained in:
@@ -81,24 +81,22 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
[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.
|
||||
// Acks bypass the retry pipeline (non-idempotent), so the caller's token
|
||||
// reaches the transport unchanged.
|
||||
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));
|
||||
await client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = string.Empty,
|
||||
OperatorUser = "alice",
|
||||
},
|
||||
cancellation.Token);
|
||||
|
||||
Assert.Equal(cancellation.Token, Assert.Single(transport.AcknowledgeAlarmCalls).CallOptions.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>Tests for ack no-retry, caller-owned channels, safe dispose, typed diagnostics, and AttachSession.</summary>
|
||||
public sealed class MxGatewayClientRemediationTests
|
||||
{
|
||||
/// <summary>An acknowledge is a non-idempotent operator write and is never retried.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_DoesNotRetryTransientFailure()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(new RpcException(new Status(StatusCode.Unavailable, "down")));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
|
||||
await Assert.ThrowsAsync<RpcException>(() => client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest { OperatorUser = "op", OperatorPassword = "pw" }));
|
||||
|
||||
Assert.Single(transport.AcknowledgeAlarmCalls);
|
||||
}
|
||||
|
||||
/// <summary>A client created over a caller-owned channel does not dispose it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Create_WithExistingChannel_DoesNotDisposeChannel()
|
||||
{
|
||||
using GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:1");
|
||||
MxGatewayClient gateway = MxGatewayClient.Create(channel, Options());
|
||||
GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.Create(channel, Options());
|
||||
|
||||
await gateway.DisposeAsync();
|
||||
await galaxy.DisposeAsync();
|
||||
|
||||
// A disposed channel throws ObjectDisposedException when a call is created.
|
||||
var invoker = channel.CreateCallInvoker();
|
||||
Exception? ex = Record.Exception(() => invoker.AsyncUnaryCall(
|
||||
new Method<byte[], byte[]>(
|
||||
MethodType.Unary,
|
||||
"s",
|
||||
"m",
|
||||
Marshallers.Create(b => b, b => b),
|
||||
Marshallers.Create(b => b, b => b)),
|
||||
null,
|
||||
new CallOptions(deadline: DateTime.UtcNow.AddMilliseconds(1)),
|
||||
[]).Dispose());
|
||||
Assert.IsNotType<ObjectDisposedException>(ex);
|
||||
}
|
||||
|
||||
/// <summary>Create over an existing channel rejects a null channel.</summary>
|
||||
[Fact]
|
||||
public void Create_WithNullChannel_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => MxGatewayClient.Create(null!, Options()));
|
||||
Assert.Throws<ArgumentNullException>(() => GalaxyRepositoryClient.Create(null!, Options()));
|
||||
}
|
||||
|
||||
/// <summary>DisposeAsync swallows a gateway failure during close and still disposes.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Session_DisposeAsync_SwallowsCloseFailure()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.CloseSessionExceptions.Enqueue(new MxGatewayException("unreachable"));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.DisposeAsync();
|
||||
|
||||
Assert.Single(transport.CloseSessionCalls);
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.CloseAsync());
|
||||
}
|
||||
|
||||
/// <summary>DisposeAsync after the client is disposed does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Session_DisposeAsync_AfterClientDisposed_DoesNotThrow()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
await client.DisposeAsync();
|
||||
|
||||
await session.DisposeAsync();
|
||||
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.CloseAsync());
|
||||
}
|
||||
|
||||
/// <summary>PingAsync sends a Ping command carrying the message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PingAsync_SendsPingCommand()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(OkReply(MxCommandKind.Ping));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.PingAsync("hello");
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Ping, request.Command.Kind);
|
||||
Assert.Equal("hello", request.Command.Ping.Message);
|
||||
Assert.Equal("session-fixture", request.SessionId);
|
||||
}
|
||||
|
||||
/// <summary>GetSessionStateAsync returns the typed state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetSessionStateAsync_ReturnsState()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxCommandReply reply = OkReply(MxCommandKind.GetSessionState);
|
||||
reply.SessionState = new SessionStateReply { State = SessionState.Ready };
|
||||
transport.AddInvokeReply(reply);
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
SessionState state = await session.GetSessionStateAsync();
|
||||
|
||||
Assert.Equal(SessionState.Ready, state);
|
||||
Assert.Equal(MxCommandKind.GetSessionState, Assert.Single(transport.InvokeCalls).Request.Command.Kind);
|
||||
}
|
||||
|
||||
/// <summary>GetSessionStateAsync throws on a reply without payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetSessionStateAsync_MissingPayload_ThrowsMalformed()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(OkReply(MxCommandKind.GetSessionState));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await Assert.ThrowsAsync<MxGatewayMalformedReplyException>(() => session.GetSessionStateAsync());
|
||||
}
|
||||
|
||||
/// <summary>GetWorkerInfoAsync returns the worker info payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetWorkerInfoAsync_ReturnsWorkerInfo()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxCommandReply reply = OkReply(MxCommandKind.GetWorkerInfo);
|
||||
reply.WorkerInfo = new WorkerInfoReply { WorkerProcessId = 42, WorkerVersion = "1.2.3" };
|
||||
transport.AddInvokeReply(reply);
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
WorkerInfoReply info = await session.GetWorkerInfoAsync();
|
||||
|
||||
Assert.Equal(42, info.WorkerProcessId);
|
||||
Assert.Equal("1.2.3", info.WorkerVersion);
|
||||
}
|
||||
|
||||
/// <summary>DrainEventsAsync sends max_events and returns the events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainEventsAsync_ReturnsEventsAndSendsMax()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxCommandReply reply = OkReply(MxCommandKind.DrainEvents);
|
||||
reply.DrainEvents = new DrainEventsReply();
|
||||
reply.DrainEvents.Events.Add(new MxEvent { SessionId = "session-fixture", WorkerSequence = 5 });
|
||||
transport.AddInvokeReply(reply);
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
IReadOnlyList<MxEvent> events = await session.DrainEventsAsync(10);
|
||||
|
||||
Assert.Equal(5UL, Assert.Single(events).WorkerSequence);
|
||||
Assert.Equal(10U, Assert.Single(transport.InvokeCalls).Request.Command.DrainEvents.MaxEvents);
|
||||
}
|
||||
|
||||
/// <summary>DrainEventsAsync treats a missing payload as an empty drain.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainEventsAsync_MissingPayload_ReturnsEmpty()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(OkReply(MxCommandKind.DrainEvents));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
Assert.Empty(await session.DrainEventsAsync());
|
||||
}
|
||||
|
||||
/// <summary>AttachSession binds to an existing id without opening a session and resumes the stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachSession_BindsExistingIdWithoutOpening_AndResumesStream()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddEvent(new MxEvent { SessionId = "existing", WorkerSequence = 8 });
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
|
||||
MxGatewaySession session = client.AttachSession("existing");
|
||||
List<MxEvent> received = [];
|
||||
await foreach (MxEvent e in session.StreamEventsAsync(afterWorkerSequence: 7))
|
||||
{
|
||||
received.Add(e);
|
||||
}
|
||||
|
||||
Assert.Empty(transport.OpenSessionCalls);
|
||||
Assert.Equal("existing", session.SessionId);
|
||||
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
|
||||
Assert.Equal("existing", request.SessionId);
|
||||
Assert.Equal(7UL, request.AfterWorkerSequence);
|
||||
Assert.Single(received);
|
||||
}
|
||||
|
||||
/// <summary>AttachSession rejects blank ids and a disposed client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachSession_InvalidArguments_Throw()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxGatewayClient client = new(transport.Options, transport);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => client.AttachSession(" "));
|
||||
await client.DisposeAsync();
|
||||
Assert.Throws<ObjectDisposedException>(() => client.AttachSession("x"));
|
||||
}
|
||||
|
||||
private static MxCommandReply OkReply(MxCommandKind kind) => new()
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = kind,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
};
|
||||
|
||||
private static MxGatewayClientOptions Options() => new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
};
|
||||
|
||||
private static FakeGatewayTransport CreateTransport() => new(Options());
|
||||
}
|
||||
Reference in New Issue
Block a user