mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Compare commits
2 Commits
fbede956e9
...
a6a9046457
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a9046457 | ||
|
|
40ae75808c |
@@ -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
|
||||
@@ -14,15 +14,18 @@ public class UnhandledExceptionLoggingBehaviorTests
|
||||
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
|
||||
> _loggerMock = new();
|
||||
|
||||
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior() =>
|
||||
new(_loggerMock.Object);
|
||||
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior()
|
||||
{
|
||||
_loggerMock.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
|
||||
return new(_loggerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
InvalidOperationException thrown = new("boom");
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
InvalidOperationException thrown = new("Something went wrong.");
|
||||
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
@@ -37,7 +40,7 @@ public class UnhandledExceptionLoggingBehaviorTests
|
||||
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
|
||||
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
||||
|
||||
@@ -51,7 +54,7 @@ public class UnhandledExceptionLoggingBehaviorTests
|
||||
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
TestResponse response = new("ok");
|
||||
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
|
||||
|
||||
@@ -65,15 +68,15 @@ public class UnhandledExceptionLoggingBehaviorTests
|
||||
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
RequestHandlerDelegate<TestResponse> next = () =>
|
||||
throw new InvalidOperationException("boom");
|
||||
throw new InvalidOperationException("Something went wrong.");
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
string loggedMessage = CaptureLoggedMessage();
|
||||
loggedMessage.Should().Contain("aaron").And.NotContain("hunter2");
|
||||
loggedMessage.Should().Contain("aaron").And.NotContain("pa$$word123");
|
||||
}
|
||||
|
||||
private void VerifyLogError(Times times) =>
|
||||
@@ -91,21 +94,17 @@ public class UnhandledExceptionLoggingBehaviorTests
|
||||
|
||||
private string CaptureLoggedMessage()
|
||||
{
|
||||
var invocation = _loggerMock
|
||||
IInvocation? invocation = _loggerMock
|
||||
.Invocations.Where(i => i.Method.Name == nameof(ILogger.Log))
|
||||
.Should()
|
||||
.ContainSingle()
|
||||
.Subject;
|
||||
|
||||
object state = invocation.Arguments[2];
|
||||
object? exception = invocation.Arguments[3];
|
||||
object exception = invocation.Arguments[3];
|
||||
object formatter = invocation.Arguments[4];
|
||||
System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!;
|
||||
|
||||
return (string)invokeMethod.Invoke(formatter, [state, exception])!;
|
||||
}
|
||||
}
|
||||
|
||||
public record TestRequest(string Username, string Password) : IRequest<TestResponse>;
|
||||
|
||||
public record TestResponse(string Value);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using MediatR;
|
||||
using Moq;
|
||||
using Shared.Application.Behaviors;
|
||||
|
||||
namespace Shared.Application.Tests.Behaviors;
|
||||
|
||||
public class ValidationBehaviorTests
|
||||
{
|
||||
private static ValidationBehavior<TestRequest, TestResponse> CreateBehavior(
|
||||
params IValidator<TestRequest>[] validators
|
||||
) => new(validators);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestPassesAllValidators_InvokesNextUnchanged()
|
||||
{
|
||||
Mock<IValidator<TestRequest>> validatorMock = new();
|
||||
validatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
validatorMock.Object
|
||||
);
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
TestResponse response = new("ok");
|
||||
bool nextInvoked = false;
|
||||
RequestHandlerDelegate<TestResponse> next = () =>
|
||||
{
|
||||
nextInvoked = true;
|
||||
return Task.FromResult(response);
|
||||
};
|
||||
|
||||
TestResponse result = await behavior.Handle(
|
||||
request,
|
||||
next,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
nextInvoked.Should().BeTrue();
|
||||
result.Should().BeSameAs(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenValidatorReportsFailures_ThrowsValidationExceptionAndNeverInvokesNext()
|
||||
{
|
||||
ValidationFailure failure = new("Username", "is required");
|
||||
Mock<IValidator<TestRequest>> validatorMock = new();
|
||||
validatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult([failure]));
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
validatorMock.Object
|
||||
);
|
||||
TestRequest request = new("", "pa$$word123");
|
||||
bool nextInvoked = false;
|
||||
RequestHandlerDelegate<TestResponse> next = () =>
|
||||
{
|
||||
nextInvoked = true;
|
||||
return Task.FromResult(new TestResponse("ok"));
|
||||
};
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should()
|
||||
.ContainSingle()
|
||||
.Which.Should()
|
||||
.BeSameAs(failure);
|
||||
nextInvoked.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenMultipleValidatorsAreRegistered_RunsAllOfThem()
|
||||
{
|
||||
Mock<IValidator<TestRequest>> firstValidatorMock = new();
|
||||
firstValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
Mock<IValidator<TestRequest>> secondValidatorMock = new();
|
||||
secondValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
firstValidatorMock.Object,
|
||||
secondValidatorMock.Object
|
||||
);
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
RequestHandlerDelegate<TestResponse> next = () =>
|
||||
Task.FromResult(new TestResponse("ok"));
|
||||
|
||||
await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
firstValidatorMock.Verify(
|
||||
v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once()
|
||||
);
|
||||
secondValidatorMock.Verify(
|
||||
v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenMultipleValidatorsReportFailures_AggregatesErrorsFromAll()
|
||||
{
|
||||
ValidationFailure firstFailure = new("Username", "is required");
|
||||
ValidationFailure secondFailure = new("Username", "must be at least 3 characters");
|
||||
Mock<IValidator<TestRequest>> firstValidatorMock = new();
|
||||
firstValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult([firstFailure]));
|
||||
Mock<IValidator<TestRequest>> secondValidatorMock = new();
|
||||
secondValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult([secondFailure]));
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
firstValidatorMock.Object,
|
||||
secondValidatorMock.Object
|
||||
);
|
||||
TestRequest request = new("", "pa$$word123");
|
||||
RequestHandlerDelegate<TestResponse> next = () =>
|
||||
Task.FromResult(new TestResponse("ok"));
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should()
|
||||
.BeEquivalentTo([firstFailure, secondFailure]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNoValidatorsAreRegistered_InvokesNext()
|
||||
{
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior =
|
||||
CreateBehavior();
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
TestResponse response = new("ok");
|
||||
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
|
||||
|
||||
TestResponse result = await behavior.Handle(
|
||||
request,
|
||||
next,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeSameAs(response);
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0"/>
|
||||
<PackageReference Include="FluentValidation" Version="11.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared.Application\Shared.Application.csproj"/>
|
||||
<ProjectReference Include="..\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,18 +6,11 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Shared.Application.Behaviors;
|
||||
|
||||
/// <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 class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
|
||||
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
public abstract class JsonLogger
|
||||
{
|
||||
private static readonly string[] SensitiveFieldNames = ["password", "token"];
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
protected static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ using MediatR;
|
||||
namespace Shared.Application.Behaviors;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
/// <inheritdoc />
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
|
||||
Reference in New Issue
Block a user