From 78d0eaa739ad5256acdd83f853a52e6c0f6504f6 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 12 Sep 2026 15:59:37 -0400 Subject: [PATCH 1/3] Move Infrastructure.Configuration into Configuration (#291) --- docs/web/diagrams/vertical-slice.md | 2 +- web/backend/API/API.Core/API.Core.csproj | 2 +- .../API/API.Core/Authentication/JwtAuthenticationHandler.cs | 2 +- web/backend/API/API.Core/Dockerfile | 2 +- web/backend/API/API.Specs/API.Specs.csproj | 2 +- web/backend/API/API.Specs/Dockerfile | 2 +- web/backend/API/API.Specs/Steps/AuthSteps.cs | 2 +- web/backend/Biergarten.API.slnx | 4 ++-- .../Configuration.csproj} | 2 +- .../ConfigurationHelpers.cs | 2 +- .../ConfigurationKeys.cs | 2 +- .../Database/Database.Connection/Database.Connection.csproj | 2 +- .../Database.Connection/DefaultSqlConnectionFactory.cs | 2 +- .../Database/Database.Connection/SqlConnectionStringHelper.cs | 2 +- .../Database/Database.Migrations/Database.Migrations.csproj | 2 +- web/backend/Database/Database.Migrations/Program.cs | 2 +- web/backend/Features/Features.Emails/Features.Emails.csproj | 2 +- .../Features/Features.Emails/Services/EmailDispatcher.cs | 2 +- .../Features/Features.Emails/Services/SmtpEmailProvider.cs | 2 +- .../Features/Features.PhotoUpload/Features.PhotoUpload.csproj | 2 +- .../Features.PhotoUpload/Services/S3FileStorageProvider.cs | 2 +- .../Features/Features.Users.Tests/Features.Users.Tests.csproj | 2 +- .../Features.Users.Tests/Services/TokenServiceRefreshTests.cs | 2 +- .../Services/TokenServiceValidationTests.cs | 2 +- web/backend/Features/Features.Users/Features.Users.csproj | 2 +- web/backend/Features/Features.Users/Services/TokenService.cs | 2 +- 26 files changed, 27 insertions(+), 27 deletions(-) rename web/backend/{Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj => Configuration/Configuration.csproj} (85%) rename web/backend/{Infrastructure/Infrastructure.Configuration => Configuration}/ConfigurationHelpers.cs (96%) rename web/backend/{Infrastructure/Infrastructure.Configuration => Configuration}/ConfigurationKeys.cs (97%) diff --git a/docs/web/diagrams/vertical-slice.md b/docs/web/diagrams/vertical-slice.md index 2963d7c8..6bf4e6c3 100644 --- a/docs/web/diagrams/vertical-slice.md +++ b/docs/web/diagrams/vertical-slice.md @@ -98,7 +98,7 @@ see the notes for what else each of those foundation projects pulls in. - **Omitted from the diagram**: - `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts` and each feature slice) - - `Infrastructure.Configuration` (the shared options-binding project + - `Configuration` (the shared options-binding project referenced by `Database.Connection`, `Features.PhotoUpload`, and `Features.Emails` directly) - both are plumbing-level dependencies of nearly everything above, and drawing diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index de6ff688..f81e1a84 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -22,7 +22,7 @@ - + diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index b51e808f..49f62a41 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; using Features.Users.Services; -using Infrastructure.Configuration; +using Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index 3b88389f..f1af52e9 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -33,7 +33,7 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] -COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] +COPY ["Configuration/Configuration.csproj", "Configuration/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] RUN dotnet restore "Biergarten.API.slnx" diff --git a/web/backend/API/API.Specs/API.Specs.csproj b/web/backend/API/API.Specs/API.Specs.csproj index af456d56..6f777279 100644 --- a/web/backend/API/API.Specs/API.Specs.csproj +++ b/web/backend/API/API.Specs/API.Specs.csproj @@ -38,7 +38,7 @@ - + diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index bb708481..d303ea1d 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -23,7 +23,7 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] -COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] +COPY ["Configuration/Configuration.csproj", "Configuration/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] RUN dotnet restore "Biergarten.API.slnx" diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 90d6e7c4..45624eca 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -2,7 +2,7 @@ using System.Text; using System.Text.Json; using Features.Users.Services; using FluentAssertions; -using Infrastructure.Configuration; +using Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Reqnroll; diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx index 674edbb0..b2734ffb 100644 --- a/web/backend/Biergarten.API.slnx +++ b/web/backend/Biergarten.API.slnx @@ -19,8 +19,8 @@ - - + + diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj b/web/backend/Configuration/Configuration.csproj similarity index 85% rename from web/backend/Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj rename to web/backend/Configuration/Configuration.csproj index a4fbf1bb..4884c448 100644 --- a/web/backend/Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj +++ b/web/backend/Configuration/Configuration.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - Infrastructure.Configuration + Configuration diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationHelpers.cs b/web/backend/Configuration/ConfigurationHelpers.cs similarity index 96% rename from web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationHelpers.cs rename to web/backend/Configuration/ConfigurationHelpers.cs index 4238565f..9fe726a0 100644 --- a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationHelpers.cs +++ b/web/backend/Configuration/ConfigurationHelpers.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Configuration; -namespace Infrastructure.Configuration; +namespace Configuration; public static class ConfigurationHelpers { diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs b/web/backend/Configuration/ConfigurationKeys.cs similarity index 97% rename from web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs rename to web/backend/Configuration/ConfigurationKeys.cs index 400a9742..06763a01 100644 --- a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs +++ b/web/backend/Configuration/ConfigurationKeys.cs @@ -1,4 +1,4 @@ -namespace Infrastructure.Configuration; +namespace Configuration; public static class ConfigurationKeys { diff --git a/web/backend/Database/Database.Connection/Database.Connection.csproj b/web/backend/Database/Database.Connection/Database.Connection.csproj index e8957e92..d1b6d654 100644 --- a/web/backend/Database/Database.Connection/Database.Connection.csproj +++ b/web/backend/Database/Database.Connection/Database.Connection.csproj @@ -21,6 +21,6 @@ /> - + diff --git a/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs b/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs index 97e7f588..4f7d480d 100644 --- a/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs +++ b/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs @@ -1,4 +1,4 @@ -using Infrastructure.Configuration; +using Configuration; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; diff --git a/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs b/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs index 3c88788d..1dcacb68 100644 --- a/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs +++ b/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs @@ -1,4 +1,4 @@ -using Infrastructure.Configuration; +using Configuration; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; diff --git a/web/backend/Database/Database.Migrations/Database.Migrations.csproj b/web/backend/Database/Database.Migrations/Database.Migrations.csproj index 1e26fcd7..a5262b97 100644 --- a/web/backend/Database/Database.Migrations/Database.Migrations.csproj +++ b/web/backend/Database/Database.Migrations/Database.Migrations.csproj @@ -19,7 +19,7 @@ - + diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index cbded8f0..121705bb 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -1,5 +1,5 @@ using Database.Connection; -using Infrastructure.Configuration; +using Configuration; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; diff --git a/web/backend/Features/Features.Emails/Features.Emails.csproj b/web/backend/Features/Features.Emails/Features.Emails.csproj index 3f7ceaf6..cc04eca8 100644 --- a/web/backend/Features/Features.Emails/Features.Emails.csproj +++ b/web/backend/Features/Features.Emails/Features.Emails.csproj @@ -15,7 +15,7 @@ - + diff --git a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs index 10a631ca..8e394c78 100644 --- a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs +++ b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs @@ -1,5 +1,5 @@ using Features.Emails.Templates.Rendering; -using Infrastructure.Configuration; +using Configuration; using Microsoft.Extensions.Configuration; namespace Features.Emails.Services; diff --git a/web/backend/Features/Features.Emails/Services/SmtpEmailProvider.cs b/web/backend/Features/Features.Emails/Services/SmtpEmailProvider.cs index 0626a2d7..232e5d58 100644 --- a/web/backend/Features/Features.Emails/Services/SmtpEmailProvider.cs +++ b/web/backend/Features/Features.Emails/Services/SmtpEmailProvider.cs @@ -1,4 +1,4 @@ -using Infrastructure.Configuration; +using Configuration; using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Configuration; diff --git a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj index 31659a75..a63c6a0a 100644 --- a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj +++ b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj @@ -22,7 +22,7 @@ - + diff --git a/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs b/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs index ccbe0b7f..236d851a 100644 --- a/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs +++ b/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs @@ -1,6 +1,6 @@ using Amazon.S3; using Amazon.S3.Model; -using Infrastructure.Configuration; +using Configuration; using Microsoft.Extensions.Configuration; namespace Features.ImageUploads.Services; diff --git a/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj b/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj index b7e9339b..8ad14ef5 100644 --- a/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj +++ b/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj @@ -22,6 +22,6 @@ - + diff --git a/web/backend/Features/Features.Users.Tests/Services/TokenServiceRefreshTests.cs b/web/backend/Features/Features.Users.Tests/Services/TokenServiceRefreshTests.cs index 04c9b242..d1a5bf63 100644 --- a/web/backend/Features/Features.Users.Tests/Services/TokenServiceRefreshTests.cs +++ b/web/backend/Features/Features.Users.Tests/Services/TokenServiceRefreshTests.cs @@ -5,7 +5,7 @@ using Features.Users.Identity; using Features.Users.Services; using Features.Users.Tests.TestSupport; using FluentAssertions; -using Infrastructure.Configuration; +using Configuration; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Moq; diff --git a/web/backend/Features/Features.Users.Tests/Services/TokenServiceValidationTests.cs b/web/backend/Features/Features.Users.Tests/Services/TokenServiceValidationTests.cs index e4782b1a..9c066391 100644 --- a/web/backend/Features/Features.Users.Tests/Services/TokenServiceValidationTests.cs +++ b/web/backend/Features/Features.Users.Tests/Services/TokenServiceValidationTests.cs @@ -4,7 +4,7 @@ using Domain.Exceptions; using Features.Users.Services; using Features.Users.Tests.TestSupport; using FluentAssertions; -using Infrastructure.Configuration; +using Configuration; using Microsoft.Extensions.Configuration; using Moq; diff --git a/web/backend/Features/Features.Users/Features.Users.csproj b/web/backend/Features/Features.Users/Features.Users.csproj index 6bbd0552..e18009e5 100644 --- a/web/backend/Features/Features.Users/Features.Users.csproj +++ b/web/backend/Features/Features.Users/Features.Users.csproj @@ -14,7 +14,7 @@ - + diff --git a/web/backend/Features/Features.Users/Services/TokenService.cs b/web/backend/Features/Features.Users/Services/TokenService.cs index 2566c8b3..16b110b3 100644 --- a/web/backend/Features/Features.Users/Services/TokenService.cs +++ b/web/backend/Features/Features.Users/Services/TokenService.cs @@ -2,7 +2,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Domain.Exceptions; using Features.Users.Identity; -using Infrastructure.Configuration; +using Configuration; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; From 3793fca65e398be5e12ced9c07d95e385b8791e8 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 12 Sep 2026 16:24:14 -0400 Subject: [PATCH 2/3] Features/exceptions pipeline integration (#308) --- docs/web/docker.md | 2 +- docs/web/testing.md | 8 +- web/backend/API/API.Core/Dockerfile | 1 + .../API.Core/ServiceCollectionExtensions.cs | 1 + web/backend/API/API.Specs/Dockerfile | 1 + web/backend/Biergarten.API.slnx | 1 + web/backend/Dockerfile.tests | 8 +- .../UnhandledExceptionLoggingBehaviorTests.cs | 111 ++++++++++++++++++ .../Shared.Application.Tests.csproj | 26 ++++ .../UnhandledExceptionLoggingBehavior.cs | 86 ++++++++++++++ .../Shared.Application.csproj | 1 + 11 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 web/backend/Shared/Shared.Application.Tests/Behaviors/UnhandledExceptionLoggingBehaviorTests.cs create mode 100644 web/backend/Shared/Shared.Application.Tests/Shared.Application.Tests.csproj create mode 100644 web/backend/Shared/Shared.Application/Behaviors/UnhandledExceptionLoggingBehavior.cs diff --git a/docs/web/docker.md b/docs/web/docker.md index a097d10f..ef05717b 100644 --- a/docs/web/docker.md +++ b/docs/web/docker.md @@ -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) ``` diff --git a/docs/web/testing.md b/docs/web/testing.md index 18a299ab..9f50e28d 100644 --- a/docs/web/testing.md +++ b/docs/web/testing.md @@ -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**: diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index f1af52e9..3973aa04 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -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 . . diff --git a/web/backend/API/API.Core/ServiceCollectionExtensions.cs b/web/backend/API/API.Core/ServiceCollectionExtensions.cs index 3f168f89..f6fdcb7f 100644 --- a/web/backend/API/API.Core/ServiceCollectionExtensions.cs +++ b/web/backend/API/API.Core/ServiceCollectionExtensions.cs @@ -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<,>)); }); diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index d303ea1d..cb962d08 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -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 . . diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx index b2734ffb..6d5e7d17 100644 --- a/web/backend/Biergarten.API.slnx +++ b/web/backend/Biergarten.API.slnx @@ -47,5 +47,6 @@ + diff --git a/web/backend/Dockerfile.tests b/web/backend/Dockerfile.tests index bab9c101..55c661b5 100644 --- a/web/backend/Dockerfile.tests +++ b/web/backend/Dockerfile.tests @@ -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"] diff --git a/web/backend/Shared/Shared.Application.Tests/Behaviors/UnhandledExceptionLoggingBehaviorTests.cs b/web/backend/Shared/Shared.Application.Tests/Behaviors/UnhandledExceptionLoggingBehaviorTests.cs new file mode 100644 index 00000000..5ccbb47a --- /dev/null +++ b/web/backend/Shared/Shared.Application.Tests/Behaviors/UnhandledExceptionLoggingBehaviorTests.cs @@ -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> + > _loggerMock = new(); + + private UnhandledExceptionLoggingBehavior CreateBehavior() => + new(_loggerMock.Object); + + [Fact] + public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows() + { + UnhandledExceptionLoggingBehavior behavior = CreateBehavior(); + TestRequest request = new("aaron", "hunter2"); + InvalidOperationException thrown = new("boom"); + RequestHandlerDelegate next = () => throw thrown; + + Func act = async () => await behavior.Handle(request, next, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Should() + .BeSameAs(thrown); + VerifyLogError(Times.Once()); + } + + [Fact] + public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows() + { + UnhandledExceptionLoggingBehavior behavior = CreateBehavior(); + TestRequest request = new("aaron", "hunter2"); + ValidationException thrown = new([new ValidationFailure("Username", "is required")]); + RequestHandlerDelegate next = () => throw thrown; + + Func act = async () => await behavior.Handle(request, next, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Should().BeSameAs(thrown); + VerifyLogError(Times.Never()); + } + + [Fact] + public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse() + { + UnhandledExceptionLoggingBehavior behavior = CreateBehavior(); + TestRequest request = new("aaron", "hunter2"); + TestResponse response = new("ok"); + RequestHandlerDelegate 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 behavior = CreateBehavior(); + TestRequest request = new("aaron", "hunter2"); + RequestHandlerDelegate next = () => + throw new InvalidOperationException("boom"); + + Func act = async () => await behavior.Handle(request, next, CancellationToken.None); + await act.Should().ThrowAsync(); + + string loggedMessage = CaptureLoggedMessage(); + loggedMessage.Should().Contain("aaron").And.NotContain("hunter2"); + } + + private void VerifyLogError(Times times) => + _loggerMock.Verify( + l => + l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + ), + 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; + +public record TestResponse(string Value); diff --git a/web/backend/Shared/Shared.Application.Tests/Shared.Application.Tests.csproj b/web/backend/Shared/Shared.Application.Tests/Shared.Application.Tests.csproj new file mode 100644 index 00000000..35a05cfc --- /dev/null +++ b/web/backend/Shared/Shared.Application.Tests/Shared.Application.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + false + Shared.Application.Tests + + + + + + + + + + + + + + + + + + + diff --git a/web/backend/Shared/Shared.Application/Behaviors/UnhandledExceptionLoggingBehavior.cs b/web/backend/Shared/Shared.Application/Behaviors/UnhandledExceptionLoggingBehavior.cs new file mode 100644 index 00000000..08721878 --- /dev/null +++ b/web/backend/Shared/Shared.Application/Behaviors/UnhandledExceptionLoggingBehavior.cs @@ -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; + +/// +/// MediatR pipeline behavior that logs unhandled exceptions together with the request type and +/// payload, then rethrows them unchanged for GlobalExceptionFilter to map to a status code. +/// +public class UnhandledExceptionLoggingBehavior( + ILogger> logger +) : IPipelineBehavior + where TRequest : IRequest +{ + private static readonly string[] SensitiveFieldNames = ["password", "token"]; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = { RedactSensitiveProperties }, + }, + }; + + /// + public async Task Handle( + TRequest request, + RequestHandlerDelegate 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 ""; + } + } + + 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***"; + } + } +} diff --git a/web/backend/Shared/Shared.Application/Shared.Application.csproj b/web/backend/Shared/Shared.Application/Shared.Application.csproj index be50cf0f..305d1df0 100644 --- a/web/backend/Shared/Shared.Application/Shared.Application.csproj +++ b/web/backend/Shared/Shared.Application/Shared.Application.csproj @@ -9,5 +9,6 @@ + From fbede956e943c95861c2ee5f3ce15a3917dbcad6 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 12 Sep 2026 17:11:02 -0400 Subject: [PATCH 3/3] Fix seed pipeline crash and make seeding failures fail CI (#309) --- .github/workflows/tests.yml | 2 +- web/backend/Database/Database.Seed/Program.cs | 13 ++++--- .../Sqlite/PipelineSeedDataReader.cs | 38 +++++++++---------- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aaba03c0..d24adbc0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,7 @@ jobs: if: always() run: | status=0 - for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do + for name in test-env-database-migrations test-env-database-seed test-env-api-specs test-env-unit-tests test-env-frontend-tests; do code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)" echo "$name exited with $code" if [ "$code" != "0" ]; then diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index dbfad0cb..7c127b41 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -19,7 +19,7 @@ namespace Database.Seed; internal class Program { - private static IMediator CreateMediator() + private static ServiceProvider BuildServiceProvider() { IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); @@ -38,11 +38,10 @@ internal class Program .RegisterServicesFromAssemblyContaining() ); - using ServiceProvider provider = services.BuildServiceProvider(); - return provider.GetRequiredService(); + return services.BuildServiceProvider(); } - public static async Task Main() + public static async Task Main() { using CancellationTokenSource cts = new(); Console.CancelKeyPress += (_, e) => @@ -54,20 +53,24 @@ internal class Program try { - IMediator mediator = CreateMediator(); + await using ServiceProvider provider = BuildServiceProvider(); + IMediator mediator = provider.GetRequiredService(); SeedRepository reader = new SeedRepository( connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" ); await new BiergartenDataSeeder(mediator, reader).Run(cts.Token); + return 0; } catch (OperationCanceledException) { AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]"); + return 1; } catch (Exception ex) { AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); AnsiConsole.WriteException(ex); + return 1; } } } diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index 12e715fb..b18580ec 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -58,13 +58,13 @@ public sealed class SeedRepository : IDisposable } private sealed record BreweryRow( - int Id, + long Id, string NameEn, string DescriptionEn, string NameLocal, string DescriptionLocal, - int AddressId, - int CityId, + long AddressId, + long CityId, double Longitude, double Latitude, string AddressLine1, @@ -101,27 +101,27 @@ public sealed class SeedRepository : IDisposable return [.. rows.Select(row => new BreweryResult { - Id = row.Id, + Id = (int)row.Id, NameEn = row.NameEn, DescriptionEn = row.DescriptionEn, NameLocal = row.NameLocal, DescriptionLocal = row.DescriptionLocal, Address = new BreweryAddress { - Id = row.AddressId, - CityId = row.CityId, - BreweryId = row.Id, + Id = (int)row.AddressId, + CityId = (int)row.CityId, + BreweryId = (int)row.Id, Longitude = row.Longitude, Latitude = row.Latitude, AddressLine1 = row.AddressLine1, PostalCode = row.PostalCode, - City = cities[row.CityId], + City = cities[(int)row.CityId], }, })]; } private sealed record UserRow( - int Id, + long Id, string FirstName, string LastName, string Gender, @@ -130,8 +130,8 @@ public sealed class SeedRepository : IDisposable double ActivityWeight, string Email, string DateOfBirth, - int AddressId, - int CityId, + long AddressId, + long CityId, double Longitude, double Latitude ); @@ -172,16 +172,16 @@ public sealed class SeedRepository : IDisposable DateOfBirth = row.DateOfBirth, Address = new UserAddress { - Id = row.AddressId, - CityId = row.CityId, - UserId = row.Id, + Id = (int)row.AddressId, + CityId = (int)row.CityId, + UserId = (int)row.Id, Longitude = row.Longitude, Latitude = row.Latitude, - City = cities[row.CityId], + City = cities[(int)row.CityId], }, User = new UserResult { - Id = row.Id, + Id = (int)row.Id, FirstName = row.FirstName, LastName = row.LastName, Gender = row.Gender, @@ -193,7 +193,7 @@ public sealed class SeedRepository : IDisposable } private sealed record CityRow( - int Id, + long Id, string CityName, string StateProvince, string Iso31662, @@ -231,10 +231,10 @@ public sealed class SeedRepository : IDisposable IEnumerable rows = await connection.QueryAsync(command); return rows.ToDictionary( - row => row.Id, + row => (int)row.Id, row => new City { - Id = row.Id, + Id = (int)row.Id, CityName = row.CityName, StateProvinceName = row.StateProvince, ISO_3166_2 = row.Iso31662,