mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Features/exceptions pipeline integration (#308)
This commit is contained in:
@@ -98,7 +98,7 @@ sqlserver # Test database
|
||||
database.migrations # Fresh schema
|
||||
database.seed # Test data
|
||||
api.specs # Reqnroll BDD tests
|
||||
unit.tests # All Features.*.Tests unit test projects
|
||||
unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects
|
||||
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
||||
```
|
||||
|
||||
|
||||
@@ -76,10 +76,11 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
|
||||
You can run individual test projects locally without Docker:
|
||||
|
||||
### Feature slice unit tests
|
||||
### Feature slice and shared unit tests
|
||||
|
||||
Each feature slice has its own test project, covering its command/query
|
||||
handlers:
|
||||
handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has
|
||||
its own test project under `Shared/`:
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
@@ -87,9 +88,10 @@ dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
|
||||
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
|
||||
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
|
||||
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
|
||||
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj
|
||||
|
||||
# Or run all of them at once via the solution:
|
||||
for proj in Features/*.Tests; do dotnet test "$proj"; done
|
||||
for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
@@ -35,6 +35,7 @@ COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/
|
||||
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
|
||||
COPY ["Configuration/Configuration.csproj", "Configuration/"]
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
COPY . .
|
||||
|
||||
@@ -46,6 +46,7 @@ internal static class ServiceCollectionExtensions
|
||||
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
||||
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
||||
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
|
||||
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/
|
||||
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
|
||||
COPY ["Configuration/Configuration.csproj", "Configuration/"]
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
COPY . .
|
||||
|
||||
@@ -47,5 +47,6 @@
|
||||
<Folder Name="/Shared/">
|
||||
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
||||
<Project Path="Shared/Shared.Application/Shared.Application.csproj" />
|
||||
<Project Path="Shared/Shared.Application.Tests/Shared.Application.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -5,11 +5,11 @@ COPY . .
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
|
||||
|
||||
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is
|
||||
# excluded here since it's a separate BDD/integration suite, run by its own compose service
|
||||
# against the live, seeded database.
|
||||
# Runs every Features.*.Tests and Shared.*.Tests project (unit + DbMocker-backed repository
|
||||
# tests). API.Specs is excluded here since it's a separate BDD/integration suite, run by its own
|
||||
# compose service against the live, seeded database.
|
||||
FROM build AS final
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
RUN mkdir -p /app/test-results
|
||||
ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]
|
||||
ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests Shared/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Shared.Application.Behaviors;
|
||||
|
||||
namespace Shared.Application.Tests.Behaviors;
|
||||
|
||||
public class UnhandledExceptionLoggingBehaviorTests
|
||||
{
|
||||
private readonly Mock<
|
||||
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
|
||||
> _loggerMock = new();
|
||||
|
||||
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior() =>
|
||||
new(_loggerMock.Object);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
InvalidOperationException thrown = new("boom");
|
||||
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<InvalidOperationException>())
|
||||
.Which.Should()
|
||||
.BeSameAs(thrown);
|
||||
VerifyLogError(Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
|
||||
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>()).Which.Should().BeSameAs(thrown);
|
||||
VerifyLogError(Times.Never());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
TestResponse response = new("ok");
|
||||
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
|
||||
|
||||
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
result.Should().BeSameAs(response);
|
||||
VerifyLogError(Times.Never());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "hunter2");
|
||||
RequestHandlerDelegate<TestResponse> next = () =>
|
||||
throw new InvalidOperationException("boom");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
private void VerifyLogError(Times times) =>
|
||||
_loggerMock.Verify(
|
||||
l =>
|
||||
l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
times
|
||||
);
|
||||
|
||||
private string CaptureLoggedMessage()
|
||||
{
|
||||
var 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 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,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Shared.Application.Tests</RootNamespace>
|
||||
</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"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared.Application\Shared.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
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>
|
||||
{
|
||||
private static readonly string[] SensitiveFieldNames = ["password", "token"];
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
||||
{
|
||||
Modifiers = { RedactSensitiveProperties },
|
||||
},
|
||||
};
|
||||
|
||||
/// <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)
|
||||
return;
|
||||
|
||||
foreach (JsonPropertyInfo property in typeInfo.Properties)
|
||||
{
|
||||
if (property.PropertyType != typeof(string))
|
||||
continue;
|
||||
|
||||
if (
|
||||
SensitiveFieldNames.Any(name =>
|
||||
property.Name.Contains(name, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
)
|
||||
property.Get = static _ => "***REDACTED***";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="FluentValidation" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user