8023eccfa6
Client.Dotnet-001: MapRpcException typed only Unauthenticated and PermissionDenied; every other gRPC status collapsed to an untyped exception with the status code discarded. Added a nullable StatusCode to MxGatewayException, extracted the duplicated mappers into a shared RpcExceptionMapper that records the code for every status, and documented it. Client.Dotnet-002: the production retry branch (MxGatewayException wrapping RpcException) was never exercised. FakeGatewayTransport gained a MapTransportExceptions mode that runs thrown RpcExceptions through RpcExceptionMapper exactly as the production transport does. Client.Dotnet-003: MxGatewaySession.DisposeAsync disposed _closeLock while a concurrent CloseAsync could be parked in WaitAsync. DisposeAsync now drains in-flight CloseAsync callers before disposing the semaphore; the client's _disposed flag is accessed via Interlocked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
3.0 KiB
C#
77 lines
3.0 KiB
C#
using Grpc.Core;
|
|
|
|
namespace MxGateway.Client.Tests;
|
|
|
|
/// <summary>Tests for the shared gRPC-to-native exception mapping used by the transports.</summary>
|
|
public sealed class RpcExceptionMapperTests
|
|
{
|
|
/// <summary>Verifies that an unauthenticated status maps to the authentication exception.</summary>
|
|
[Fact]
|
|
public void Map_UnauthenticatedStatus_ProducesAuthenticationException()
|
|
{
|
|
RpcException rpc = new(new Status(StatusCode.Unauthenticated, "no key"));
|
|
|
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
|
|
MxGatewayAuthenticationException authentication =
|
|
Assert.IsType<MxGatewayAuthenticationException>(mapped);
|
|
Assert.Equal(StatusCode.Unauthenticated, authentication.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that a permission-denied status maps to the authorization exception.</summary>
|
|
[Fact]
|
|
public void Map_PermissionDeniedStatus_ProducesAuthorizationException()
|
|
{
|
|
RpcException rpc = new(new Status(StatusCode.PermissionDenied, "missing scope"));
|
|
|
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
|
|
MxGatewayAuthorizationException authorization =
|
|
Assert.IsType<MxGatewayAuthorizationException>(mapped);
|
|
Assert.Equal(StatusCode.PermissionDenied, authorization.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that a cancelled status maps to OperationCanceledException.</summary>
|
|
[Fact]
|
|
public void Map_CancelledStatus_ProducesOperationCanceledException()
|
|
{
|
|
RpcException rpc = new(new Status(StatusCode.Cancelled, "cancelled"));
|
|
|
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
|
|
Assert.IsType<OperationCanceledException>(mapped);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that non-auth statuses surface the originating gRPC status code on the
|
|
/// mapped exception so callers can distinguish transient from permanent failures
|
|
/// without reflecting into InnerException.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(StatusCode.NotFound)]
|
|
[InlineData(StatusCode.InvalidArgument)]
|
|
[InlineData(StatusCode.ResourceExhausted)]
|
|
[InlineData(StatusCode.FailedPrecondition)]
|
|
[InlineData(StatusCode.Unavailable)]
|
|
[InlineData(StatusCode.Internal)]
|
|
public void Map_NonAuthStatus_CarriesStatusCodeOnMxGatewayException(StatusCode statusCode)
|
|
{
|
|
RpcException rpc = new(new Status(statusCode, "boom"));
|
|
|
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
|
|
MxGatewayException gatewayException = Assert.IsType<MxGatewayException>(mapped);
|
|
Assert.Equal(statusCode, gatewayException.StatusCode);
|
|
Assert.Same(rpc, gatewayException.InnerException);
|
|
}
|
|
|
|
/// <summary>Verifies that an MxGatewayException built without a gRPC status reports a null StatusCode.</summary>
|
|
[Fact]
|
|
public void StatusCode_IsNull_WhenNoGrpcStatusProvided()
|
|
{
|
|
MxGatewayException gatewayException = new("plain failure");
|
|
|
|
Assert.Null(gatewayException.StatusCode);
|
|
}
|
|
}
|