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/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/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/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..3973aa04 100644
--- a/web/backend/API/API.Core/Dockerfile
+++ b/web/backend/API/API.Core/Dockerfile
@@ -33,8 +33,9 @@ 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.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/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..cb962d08 100644
--- a/web/backend/API/API.Specs/Dockerfile
+++ b/web/backend/API/API.Specs/Dockerfile
@@ -23,8 +23,9 @@ 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.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.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..6d5e7d17 100644
--- a/web/backend/Biergarten.API.slnx
+++ b/web/backend/Biergarten.API.slnx
@@ -19,8 +19,8 @@
-
-
+
+
@@ -47,5 +47,6 @@
+
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/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,
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/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;
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 @@
+