using Grpc.Core; using Grpc.Net.Client; using ZB.MOM.WW.MxGateway.Contracts.Proto; namespace ZB.MOM.WW.MxGateway.Client.Tests; /// Tests for ack no-retry, caller-owned channels, safe dispose, typed diagnostics, and AttachSession. public sealed class MxGatewayClientRemediationTests { /// An acknowledge is a non-idempotent operator write and is never retried. /// A task that represents the asynchronous operation. [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(() => client.AcknowledgeAlarmAsync( new AcknowledgeAlarmRequest { OperatorUser = "op", OperatorPassword = "pw" })); Assert.Single(transport.AcknowledgeAlarmCalls); } /// A client created over a caller-owned channel does not dispose it. /// A task that represents the asynchronous operation. [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( 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(ex); } /// Create over an existing channel rejects a null channel. [Fact] public void Create_WithNullChannel_Throws() { Assert.Throws(() => MxGatewayClient.Create(null!, Options())); Assert.Throws(() => GalaxyRepositoryClient.Create(null!, Options())); } /// DisposeAsync swallows a gateway failure during close and still disposes. /// A task that represents the asynchronous operation. [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(() => session.CloseAsync()); } /// DisposeAsync after the client is disposed does not throw. /// A task that represents the asynchronous operation. [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(() => session.CloseAsync()); } /// PingAsync sends a Ping command carrying the message. /// A task that represents the asynchronous operation. [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); } /// GetSessionStateAsync returns the typed state. /// A task that represents the asynchronous operation. [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); } /// GetSessionStateAsync throws on a reply without payload. /// A task that represents the asynchronous operation. [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(() => session.GetSessionStateAsync()); } /// GetWorkerInfoAsync returns the worker info payload. /// A task that represents the asynchronous operation. [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); } /// DrainEventsAsync sends max_events and returns the events. /// A task that represents the asynchronous operation. [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 events = await session.DrainEventsAsync(10); Assert.Equal(5UL, Assert.Single(events).WorkerSequence); Assert.Equal(10U, Assert.Single(transport.InvokeCalls).Request.Command.DrainEvents.MaxEvents); } /// DrainEventsAsync treats a missing payload as an empty drain. /// A task that represents the asynchronous operation. [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()); } /// AttachSession binds to an existing id without opening a session and resumes the stream. /// A task that represents the asynchronous operation. [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 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); } /// AttachSession rejects blank ids and a disposed client. /// A task that represents the asynchronous operation. [Fact] public async Task AttachSession_InvalidArguments_Throw() { FakeGatewayTransport transport = CreateTransport(); MxGatewayClient client = new(transport.Options, transport); Assert.Throws(() => client.AttachSession(" ")); await client.DisposeAsync(); Assert.Throws(() => 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()); }