Compare commits

..

3 Commits

Author SHA1 Message Date
Aaron Po
fbede956e9 Fix seed pipeline crash and make seeding failures fail CI (#309) 2026-09-12 17:11:02 -04:00
Aaron Po
3793fca65e Features/exceptions pipeline integration (#308) 2026-09-12 16:24:14 -04:00
Aaron Po
78d0eaa739 Move Infrastructure.Configuration into Configuration (#291) 2026-09-12 15:59:37 -04:00
37 changed files with 293 additions and 60 deletions

View File

@@ -33,7 +33,7 @@ jobs:
if: always() if: always()
run: | run: |
status=0 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)" code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
echo "$name exited with $code" echo "$name exited with $code"
if [ "$code" != "0" ]; then if [ "$code" != "0" ]; then

View File

@@ -98,7 +98,7 @@ see the notes for what else each of those foundation projects pulls in.
- **Omitted from the diagram**: - **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including - `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts` and each feature slice) 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`, referenced by `Database.Connection`, `Features.PhotoUpload`,
and `Features.Emails` directly) and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing - both are plumbing-level dependencies of nearly everything above, and drawing

View File

@@ -98,7 +98,7 @@ sqlserver # Test database
database.migrations # Fresh schema database.migrations # Fresh schema
database.seed # Test data database.seed # Test data
api.specs # Reqnroll BDD tests 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) frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
``` ```

View File

@@ -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: 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 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 ```bash
cd web/backend 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.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.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: # 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**: **Requirements**:

View File

@@ -22,7 +22,7 @@
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />

View File

@@ -1,7 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Features.Users.Services; using Features.Users.Services;
using Infrastructure.Configuration; using Configuration;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;

View File

@@ -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.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] 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/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/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -46,6 +46,7 @@ internal static class ServiceCollectionExtensions
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly); cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
}); });

View File

@@ -38,7 +38,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" /> <ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />

View File

@@ -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.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] 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/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/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -2,7 +2,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Features.Users.Services; using Features.Users.Services;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Reqnroll; using Reqnroll;

View File

@@ -19,8 +19,8 @@
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" /> <Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" /> <Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder> </Folder>
<Folder Name="/Infrastructure/"> <Folder Name="/Configuration/">
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" /> <Project Path="Configuration/Configuration.csproj" />
</Folder> </Folder>
<Folder Name="/Features/" /> <Folder Name="/Features/" />
<Folder Name="/Features/Users/"> <Folder Name="/Features/Users/">
@@ -47,5 +47,6 @@
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
<Project Path="Shared/Shared.Application/Shared.Application.csproj" /> <Project Path="Shared/Shared.Application/Shared.Application.csproj" />
<Project Path="Shared/Shared.Application.Tests/Shared.Application.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Configuration</RootNamespace> <RootNamespace>Configuration</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Infrastructure.Configuration; namespace Configuration;
public static class ConfigurationHelpers public static class ConfigurationHelpers
{ {

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Configuration; namespace Configuration;
public static class ConfigurationKeys public static class ConfigurationKeys
{ {

View File

@@ -21,6 +21,6 @@
/> />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -19,7 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" /> <EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -1,5 +1,5 @@
using Database.Connection; using Database.Connection;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -19,7 +19,7 @@ namespace Database.Seed;
internal class Program internal class Program
{ {
private static IMediator CreateMediator() private static ServiceProvider BuildServiceProvider()
{ {
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
@@ -38,11 +38,10 @@ internal class Program
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>() .RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
); );
using ServiceProvider provider = services.BuildServiceProvider(); return services.BuildServiceProvider();
return provider.GetRequiredService<IMediator>();
} }
public static async Task Main() public static async Task<int> Main()
{ {
using CancellationTokenSource cts = new(); using CancellationTokenSource cts = new();
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
@@ -54,20 +53,24 @@ internal class Program
try try
{ {
IMediator mediator = CreateMediator(); await using ServiceProvider provider = BuildServiceProvider();
IMediator mediator = provider.GetRequiredService<IMediator>();
SeedRepository reader = new SeedRepository( SeedRepository reader = new SeedRepository(
connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
); );
await new BiergartenDataSeeder(mediator, reader).Run(cts.Token); await new BiergartenDataSeeder(mediator, reader).Run(cts.Token);
return 0;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]"); AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]");
return 1;
} }
catch (Exception ex) catch (Exception ex)
{ {
AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
AnsiConsole.WriteException(ex); AnsiConsole.WriteException(ex);
return 1;
} }
} }
} }

View File

@@ -58,13 +58,13 @@ public sealed class SeedRepository : IDisposable
} }
private sealed record BreweryRow( private sealed record BreweryRow(
int Id, long Id,
string NameEn, string NameEn,
string DescriptionEn, string DescriptionEn,
string NameLocal, string NameLocal,
string DescriptionLocal, string DescriptionLocal,
int AddressId, long AddressId,
int CityId, long CityId,
double Longitude, double Longitude,
double Latitude, double Latitude,
string AddressLine1, string AddressLine1,
@@ -101,27 +101,27 @@ public sealed class SeedRepository : IDisposable
return [.. rows.Select(row => new BreweryResult return [.. rows.Select(row => new BreweryResult
{ {
Id = row.Id, Id = (int)row.Id,
NameEn = row.NameEn, NameEn = row.NameEn,
DescriptionEn = row.DescriptionEn, DescriptionEn = row.DescriptionEn,
NameLocal = row.NameLocal, NameLocal = row.NameLocal,
DescriptionLocal = row.DescriptionLocal, DescriptionLocal = row.DescriptionLocal,
Address = new BreweryAddress Address = new BreweryAddress
{ {
Id = row.AddressId, Id = (int)row.AddressId,
CityId = row.CityId, CityId = (int)row.CityId,
BreweryId = row.Id, BreweryId = (int)row.Id,
Longitude = row.Longitude, Longitude = row.Longitude,
Latitude = row.Latitude, Latitude = row.Latitude,
AddressLine1 = row.AddressLine1, AddressLine1 = row.AddressLine1,
PostalCode = row.PostalCode, PostalCode = row.PostalCode,
City = cities[row.CityId], City = cities[(int)row.CityId],
}, },
})]; })];
} }
private sealed record UserRow( private sealed record UserRow(
int Id, long Id,
string FirstName, string FirstName,
string LastName, string LastName,
string Gender, string Gender,
@@ -130,8 +130,8 @@ public sealed class SeedRepository : IDisposable
double ActivityWeight, double ActivityWeight,
string Email, string Email,
string DateOfBirth, string DateOfBirth,
int AddressId, long AddressId,
int CityId, long CityId,
double Longitude, double Longitude,
double Latitude double Latitude
); );
@@ -172,16 +172,16 @@ public sealed class SeedRepository : IDisposable
DateOfBirth = row.DateOfBirth, DateOfBirth = row.DateOfBirth,
Address = new UserAddress Address = new UserAddress
{ {
Id = row.AddressId, Id = (int)row.AddressId,
CityId = row.CityId, CityId = (int)row.CityId,
UserId = row.Id, UserId = (int)row.Id,
Longitude = row.Longitude, Longitude = row.Longitude,
Latitude = row.Latitude, Latitude = row.Latitude,
City = cities[row.CityId], City = cities[(int)row.CityId],
}, },
User = new UserResult User = new UserResult
{ {
Id = row.Id, Id = (int)row.Id,
FirstName = row.FirstName, FirstName = row.FirstName,
LastName = row.LastName, LastName = row.LastName,
Gender = row.Gender, Gender = row.Gender,
@@ -193,7 +193,7 @@ public sealed class SeedRepository : IDisposable
} }
private sealed record CityRow( private sealed record CityRow(
int Id, long Id,
string CityName, string CityName,
string StateProvince, string StateProvince,
string Iso31662, string Iso31662,
@@ -231,10 +231,10 @@ public sealed class SeedRepository : IDisposable
IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command); IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command);
return rows.ToDictionary( return rows.ToDictionary(
row => row.Id, row => (int)row.Id,
row => new City row => new City
{ {
Id = row.Id, Id = (int)row.Id,
CityName = row.CityName, CityName = row.CityName,
StateProvinceName = row.StateProvince, StateProvinceName = row.StateProvince,
ISO_3166_2 = row.Iso31662, ISO_3166_2 = row.Iso31662,

View File

@@ -5,11 +5,11 @@ COPY . .
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is # Runs every Features.*.Tests and Shared.*.Tests project (unit + DbMocker-backed repository
# excluded here since it's a separate BDD/integration suite, run by its own compose service # tests). API.Specs is excluded here since it's a separate BDD/integration suite, run by its own
# against the live, seeded database. # compose service against the live, seeded database.
FROM build AS final FROM build AS final
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
RUN mkdir -p /app/test-results 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"]

View File

@@ -15,7 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
<ProjectReference Include="..\Features.Emails.Templates\Features.Emails.Templates.csproj" /> <ProjectReference Include="..\Features.Emails.Templates\Features.Emails.Templates.csproj" />
<ProjectReference Include="..\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\Features.Users\Features.Users.csproj" />

View File

@@ -1,5 +1,5 @@
using Features.Emails.Templates.Rendering; using Features.Emails.Templates.Rendering;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Features.Emails.Services; namespace Features.Emails.Services;

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -22,7 +22,7 @@
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,6 +1,6 @@
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Features.ImageUploads.Services; namespace Features.ImageUploads.Services;

View File

@@ -22,6 +22,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,7 +5,7 @@ using Features.Users.Identity;
using Features.Users.Services; using Features.Users.Services;
using Features.Users.Tests.TestSupport; using Features.Users.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Moq; using Moq;

View File

@@ -4,7 +4,7 @@ using Domain.Exceptions;
using Features.Users.Services; using Features.Users.Services;
using Features.Users.Tests.TestSupport; using Features.Users.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Moq; using Moq;

View File

@@ -14,7 +14,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />

View File

@@ -2,7 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Users.Identity; using Features.Users.Identity;
using Infrastructure.Configuration; using Configuration;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -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);

View File

@@ -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>

View File

@@ -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***";
}
}
}

View File

@@ -9,5 +9,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="FluentValidation" Version="11.3.0" /> <PackageReference Include="FluentValidation" Version="11.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>