Fix CA1873 warning and consolidate test fixtures (#313)

This commit is contained in:
Aaron Po
2026-09-12 22:14:55 -04:00
committed by GitHub
parent 40ae75808c
commit a6a9046457
6 changed files with 150 additions and 121 deletions

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace Shared.Application.Tests.Behaviors;
// ReSharper disable NotAccessedPositionalProperty.Global
public sealed record TestRequest(string Username, string Password) : IRequest<TestResponse>;
public sealed record TestResponse(string Value);
// ReSharper restore NotAccessedPositionalProperty.Global

View File

@@ -14,15 +14,18 @@ public class UnhandledExceptionLoggingBehaviorTests
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>> ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
> _loggerMock = new(); > _loggerMock = new();
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior() => private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior()
new(_loggerMock.Object); {
_loggerMock.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
return new(_loggerMock.Object);
}
[Fact] [Fact]
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows() public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
{ {
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior(); UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "hunter2"); TestRequest request = new("aaron", "pa$$word123");
InvalidOperationException thrown = new("boom"); InvalidOperationException thrown = new("Something went wrong.");
RequestHandlerDelegate<TestResponse> next = () => throw thrown; RequestHandlerDelegate<TestResponse> next = () => throw thrown;
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None); Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
@@ -37,7 +40,7 @@ public class UnhandledExceptionLoggingBehaviorTests
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows() public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
{ {
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior(); UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "hunter2"); TestRequest request = new("aaron", "pa$$word123");
ValidationException thrown = new([new ValidationFailure("Username", "is required")]); ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
RequestHandlerDelegate<TestResponse> next = () => throw thrown; RequestHandlerDelegate<TestResponse> next = () => throw thrown;
@@ -51,7 +54,7 @@ public class UnhandledExceptionLoggingBehaviorTests
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse() public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
{ {
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior(); UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "hunter2"); TestRequest request = new("aaron", "pa$$word123");
TestResponse response = new("ok"); TestResponse response = new("ok");
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response); RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
@@ -65,15 +68,15 @@ public class UnhandledExceptionLoggingBehaviorTests
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload() public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
{ {
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior(); UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "hunter2"); TestRequest request = new("aaron", "pa$$word123");
RequestHandlerDelegate<TestResponse> next = () => RequestHandlerDelegate<TestResponse> next = () =>
throw new InvalidOperationException("boom"); throw new InvalidOperationException("Something went wrong.");
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None); Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>(); await act.Should().ThrowAsync<InvalidOperationException>();
string loggedMessage = CaptureLoggedMessage(); string loggedMessage = CaptureLoggedMessage();
loggedMessage.Should().Contain("aaron").And.NotContain("hunter2"); loggedMessage.Should().Contain("aaron").And.NotContain("pa$$word123");
} }
private void VerifyLogError(Times times) => private void VerifyLogError(Times times) =>
@@ -91,21 +94,17 @@ public class UnhandledExceptionLoggingBehaviorTests
private string CaptureLoggedMessage() private string CaptureLoggedMessage()
{ {
var invocation = _loggerMock IInvocation? invocation = _loggerMock
.Invocations.Where(i => i.Method.Name == nameof(ILogger.Log)) .Invocations.Where(i => i.Method.Name == nameof(ILogger.Log))
.Should() .Should()
.ContainSingle() .ContainSingle()
.Subject; .Subject;
object state = invocation.Arguments[2]; object state = invocation.Arguments[2];
object? exception = invocation.Arguments[3]; object exception = invocation.Arguments[3];
object formatter = invocation.Arguments[4]; object formatter = invocation.Arguments[4];
System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!; System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!;
return (string)invokeMethod.Invoke(formatter, [state, exception])!; return (string)invokeMethod.Invoke(formatter, [state, exception])!;
} }
} }
public record TestRequest(string Username, string Password) : IRequest<TestResponse>;
public record TestResponse(string Value);

View File

@@ -9,35 +9,35 @@ namespace Shared.Application.Tests.Behaviors;
public class ValidationBehaviorTests public class ValidationBehaviorTests
{ {
private static ValidationBehavior<ValidationTestRequest, ValidationTestResponse> CreateBehavior( private static ValidationBehavior<TestRequest, TestResponse> CreateBehavior(
params IValidator<ValidationTestRequest>[] validators params IValidator<TestRequest>[] validators
) => new(validators); ) => new(validators);
[Fact] [Fact]
public async Task Handle_WhenRequestPassesAllValidators_InvokesNextUnchanged() public async Task Handle_WhenRequestPassesAllValidators_InvokesNextUnchanged()
{ {
Mock<IValidator<ValidationTestRequest>> validatorMock = new(); Mock<IValidator<TestRequest>> validatorMock = new();
validatorMock validatorMock
.Setup(v => .Setup(v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
) )
) )
.ReturnsAsync(new ValidationResult()); .ReturnsAsync(new ValidationResult());
ValidationBehavior<ValidationTestRequest, ValidationTestResponse> behavior = CreateBehavior( ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
validatorMock.Object validatorMock.Object
); );
ValidationTestRequest request = new("aaron"); TestRequest request = new("aaron", "pa$$word123");
ValidationTestResponse response = new("ok"); TestResponse response = new("ok");
bool nextInvoked = false; bool nextInvoked = false;
RequestHandlerDelegate<ValidationTestResponse> next = () => RequestHandlerDelegate<TestResponse> next = () =>
{ {
nextInvoked = true; nextInvoked = true;
return Task.FromResult(response); return Task.FromResult(response);
}; };
ValidationTestResponse result = await behavior.Handle( TestResponse result = await behavior.Handle(
request, request,
next, next,
CancellationToken.None CancellationToken.None
@@ -50,30 +50,31 @@ public class ValidationBehaviorTests
[Fact] [Fact]
public async Task Handle_WhenValidatorReportsFailures_ThrowsValidationExceptionAndNeverInvokesNext() public async Task Handle_WhenValidatorReportsFailures_ThrowsValidationExceptionAndNeverInvokesNext()
{ {
ValidationFailure failure = new("Name", "is required"); ValidationFailure failure = new("Username", "is required");
Mock<IValidator<ValidationTestRequest>> validatorMock = new(); Mock<IValidator<TestRequest>> validatorMock = new();
validatorMock validatorMock
.Setup(v => .Setup(v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
) )
) )
.ReturnsAsync(new ValidationResult([failure])); .ReturnsAsync(new ValidationResult([failure]));
ValidationBehavior<ValidationTestRequest, ValidationTestResponse> behavior = CreateBehavior( ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
validatorMock.Object validatorMock.Object
); );
ValidationTestRequest request = new(""); TestRequest request = new("", "pa$$word123");
bool nextInvoked = false; bool nextInvoked = false;
RequestHandlerDelegate<ValidationTestResponse> next = () => RequestHandlerDelegate<TestResponse> next = () =>
{ {
nextInvoked = true; nextInvoked = true;
return Task.FromResult(new ValidationTestResponse("ok")); return Task.FromResult(new TestResponse("ok"));
}; };
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None); Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>()).Which.Errors.Should() (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should()
.ContainSingle() .ContainSingle()
.Which.Should() .Which.Should()
.BeSameAs(failure); .BeSameAs(failure);
@@ -83,38 +84,38 @@ public class ValidationBehaviorTests
[Fact] [Fact]
public async Task Handle_WhenMultipleValidatorsAreRegistered_RunsAllOfThem() public async Task Handle_WhenMultipleValidatorsAreRegistered_RunsAllOfThem()
{ {
Mock<IValidator<ValidationTestRequest>> firstValidatorMock = new(); Mock<IValidator<TestRequest>> firstValidatorMock = new();
firstValidatorMock firstValidatorMock
.Setup(v => .Setup(v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
) )
) )
.ReturnsAsync(new ValidationResult()); .ReturnsAsync(new ValidationResult());
Mock<IValidator<ValidationTestRequest>> secondValidatorMock = new(); Mock<IValidator<TestRequest>> secondValidatorMock = new();
secondValidatorMock secondValidatorMock
.Setup(v => .Setup(v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
) )
) )
.ReturnsAsync(new ValidationResult()); .ReturnsAsync(new ValidationResult());
ValidationBehavior<ValidationTestRequest, ValidationTestResponse> behavior = CreateBehavior( ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
firstValidatorMock.Object, firstValidatorMock.Object,
secondValidatorMock.Object secondValidatorMock.Object
); );
ValidationTestRequest request = new("aaron"); TestRequest request = new("aaron", "pa$$word123");
RequestHandlerDelegate<ValidationTestResponse> next = () => RequestHandlerDelegate<TestResponse> next = () =>
Task.FromResult(new ValidationTestResponse("ok")); Task.FromResult(new TestResponse("ok"));
await behavior.Handle(request, next, CancellationToken.None); await behavior.Handle(request, next, CancellationToken.None);
firstValidatorMock.Verify( firstValidatorMock.Verify(
v => v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
), ),
Times.Once() Times.Once()
@@ -122,7 +123,7 @@ public class ValidationBehaviorTests
secondValidatorMock.Verify( secondValidatorMock.Verify(
v => v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
), ),
Times.Once() Times.Once()
@@ -132,49 +133,51 @@ public class ValidationBehaviorTests
[Fact] [Fact]
public async Task Handle_WhenMultipleValidatorsReportFailures_AggregatesErrorsFromAll() public async Task Handle_WhenMultipleValidatorsReportFailures_AggregatesErrorsFromAll()
{ {
ValidationFailure firstFailure = new("Name", "is required"); ValidationFailure firstFailure = new("Username", "is required");
ValidationFailure secondFailure = new("Name", "must be at least 3 characters"); ValidationFailure secondFailure = new("Username", "must be at least 3 characters");
Mock<IValidator<ValidationTestRequest>> firstValidatorMock = new(); Mock<IValidator<TestRequest>> firstValidatorMock = new();
firstValidatorMock firstValidatorMock
.Setup(v => .Setup(v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
) )
) )
.ReturnsAsync(new ValidationResult([firstFailure])); .ReturnsAsync(new ValidationResult([firstFailure]));
Mock<IValidator<ValidationTestRequest>> secondValidatorMock = new(); Mock<IValidator<TestRequest>> secondValidatorMock = new();
secondValidatorMock secondValidatorMock
.Setup(v => .Setup(v =>
v.ValidateAsync( v.ValidateAsync(
It.IsAny<ValidationContext<ValidationTestRequest>>(), It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
) )
) )
.ReturnsAsync(new ValidationResult([secondFailure])); .ReturnsAsync(new ValidationResult([secondFailure]));
ValidationBehavior<ValidationTestRequest, ValidationTestResponse> behavior = CreateBehavior( ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
firstValidatorMock.Object, firstValidatorMock.Object,
secondValidatorMock.Object secondValidatorMock.Object
); );
ValidationTestRequest request = new(""); TestRequest request = new("", "pa$$word123");
RequestHandlerDelegate<ValidationTestResponse> next = () => RequestHandlerDelegate<TestResponse> next = () =>
Task.FromResult(new ValidationTestResponse("ok")); Task.FromResult(new TestResponse("ok"));
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None); Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>()).Which.Errors.Should() (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should()
.BeEquivalentTo([firstFailure, secondFailure]); .BeEquivalentTo([firstFailure, secondFailure]);
} }
[Fact] [Fact]
public async Task Handle_WhenNoValidatorsAreRegistered_InvokesNext() public async Task Handle_WhenNoValidatorsAreRegistered_InvokesNext()
{ {
ValidationBehavior<ValidationTestRequest, ValidationTestResponse> behavior = CreateBehavior(); ValidationBehavior<TestRequest, TestResponse> behavior =
ValidationTestRequest request = new("aaron"); CreateBehavior();
ValidationTestResponse response = new("ok"); TestRequest request = new("aaron", "pa$$word123");
RequestHandlerDelegate<ValidationTestResponse> next = () => Task.FromResult(response); TestResponse response = new("ok");
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
ValidationTestResponse result = await behavior.Handle( TestResponse result = await behavior.Handle(
request, request,
next, next,
CancellationToken.None CancellationToken.None
@@ -183,7 +186,3 @@ public class ValidationBehaviorTests
result.Should().BeSameAs(response); result.Should().BeSameAs(response);
} }
} }
public record ValidationTestRequest(string Name) : IRequest<ValidationTestResponse>;
public record ValidationTestResponse(string Value);

View File

@@ -8,19 +8,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2"/> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0"/> <PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="FluentValidation" Version="11.3.0"/> <PackageReference Include="FluentValidation" Version="11.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit"/> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Shared.Application\Shared.Application.csproj"/> <ProjectReference Include="..\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -6,18 +6,11 @@ using Microsoft.Extensions.Logging;
namespace Shared.Application.Behaviors; namespace Shared.Application.Behaviors;
/// <summary> public abstract class JsonLogger
/// MediatR pipeline behavior that logs unhandled exceptions together with the request type and
/// payload, then rethrows them unchanged for <c>GlobalExceptionFilter</c> to map to a status code.
/// </summary>
public class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{ {
private static readonly string[] SensitiveFieldNames = ["password", "token"]; private static readonly string[] SensitiveFieldNames = ["password", "token"];
private static readonly JsonSerializerOptions SerializerOptions = new() protected static readonly JsonSerializerOptions SerializerOptions = new()
{ {
TypeInfoResolver = new DefaultJsonTypeInfoResolver TypeInfoResolver = new DefaultJsonTypeInfoResolver
{ {
@@ -25,46 +18,6 @@ public class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
}, },
}; };
/// <inheritdoc/>
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
try
{
return await next();
}
catch (ValidationException)
{
// Expected: ValidationBehavior already reports these as request errors, not incidents.
throw;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Unhandled exception while processing {RequestName}: {RequestPayload}",
typeof(TRequest).Name,
Serialize(request)
);
throw;
}
}
private static string Serialize(TRequest request)
{
try
{
return JsonSerializer.Serialize(request, SerializerOptions);
}
catch (Exception)
{
return "<unable to serialize request payload>";
}
}
private static void RedactSensitiveProperties(JsonTypeInfo typeInfo) private static void RedactSensitiveProperties(JsonTypeInfo typeInfo)
{ {
if (typeInfo.Kind != JsonTypeInfoKind.Object) if (typeInfo.Kind != JsonTypeInfoKind.Object)
@@ -84,3 +37,71 @@ public class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
} }
} }
} }
/// <summary>
/// MediatR pipeline behavior that logs unhandled exceptions together with the
/// request type and
/// payload, then rethrows them unchanged for <c>GlobalExceptionFilter</c> to
/// map to a status code.
/// </summary>
public partial class UnhandledExceptionLoggingBehavior<TRequest, TResponse>
: JsonLogger,
IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> _logger;
public UnhandledExceptionLoggingBehavior(
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
)
{
_logger = logger;
}
/// <inheritdoc />
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
try
{
return await next();
}
catch (ValidationException)
{
// Expected: ValidationBehavior already reports these as request errors
throw;
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
LogUnhandledException(ex, typeof(TRequest).Name, Serialize(request));
throw;
}
}
private static string Serialize(TRequest request)
{
try
{
return JsonSerializer.Serialize(request, SerializerOptions);
}
catch (Exception)
{
return "<unable to serialize request payload>";
}
}
[LoggerMessage(
Level = LogLevel.Error,
Message = "Unhandled exception while processing {RequestName}: {RequestPayload}"
)]
private partial void LogUnhandledException(
Exception ex,
string requestName,
string requestPayload
);
}

View File

@@ -5,14 +5,15 @@ using MediatR;
namespace Shared.Application.Behaviors; namespace Shared.Application.Behaviors;
/// <summary> /// <summary>
/// MediatR pipeline behavior that runs registered FluentValidation validators before the handler, /// MediatR pipeline behavior that runs registered FluentValidation validators
/// before the handler,
/// throwing <see cref="ValidationException" /> if any report failures. /// throwing <see cref="ValidationException" /> if any report failures.
/// </summary> /// </summary>
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse> where TRequest : IRequest<TResponse>
{ {
/// <inheritdoc/> /// <inheritdoc />
public async Task<TResponse> Handle( public async Task<TResponse> Handle(
TRequest request, TRequest request,
RequestHandlerDelegate<TResponse> next, RequestHandlerDelegate<TResponse> next,