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());
}