From 6651e946f2b8d178a37f066bbb56ea19b17cc2ea Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Mon, 20 Jul 2026 00:23:52 +0200 Subject: [PATCH 1/5] Enable latest analyzers and treat warnings as errors Enable latest-All analysis, promote code analysis warnings to errors, and resolve the resulting analyzer findings with targeted suppressions where framework and test conventions require them. --- Directory.Build.props | 7 +- .../Handlers/ContributorDeletedHandler.cs | 29 +- ...utorNameUpdatedEmailNotificationHandler.cs | 30 +- .../Interfaces/IEmailSender.cs | 2 +- .../Services/DeleteContributorService.cs | 46 ++- .../Data/AppDbContext.cs | 2 + .../Data/Config/ContributorConfiguration.cs | 8 +- .../Data/Config/DataSchemaConstants.cs | 2 + .../Data/Config/VogenEfCoreConverters.cs | 5 +- .../Data/EventDispatcherInterceptor.cs | 39 ++- .../Migrations/20231218143922_PhoneNumber.cs | 4 + .../20251113164108_UpdateForNet10.cs | 4 + .../20260424000000_UseDbGeneratedIds.cs | 6 +- .../Queries/ListContributorsQueryService.cs | 4 +- .../Data/SeedData.cs | 48 +++- .../Email/FakeEmailSender.cs | 26 +- .../Email/MimeKitEmailSender.cs | 62 +++- .../InfrastructureServiceExtensions.cs | 19 +- .../Extensions.cs | 17 +- src/Clean.Architecture.UseCases/Constants.cs | 4 + .../Create/CreateContributorHandler.cs | 22 +- .../Delete/DeleteContributorHandler.cs | 17 +- .../Contributors/Get/GetContributorHandler.cs | 29 +- .../Contributors/Get/GetContributorQuery.cs | 2 + .../List/ListContributorsHandler.cs | 15 +- .../Update/UpdateContributorHandler.cs | 29 +- src/Clean.Architecture.Web/.editorconfig | 8 + .../Configurations/LoggerConfigs.cs | 28 +- .../Configurations/MediatorConfig.cs | 40 +-- .../Configurations/MiddlewareConfig.cs | 151 +++++++--- .../Configurations/OptionConfigs.cs | 46 +-- .../Configurations/ServiceConfigs.cs | 33 ++- .../Contributors/Create.cs | 16 +- .../Delete.DeleteContributorRequest.cs | 11 +- .../Contributors/Delete.cs | 10 +- .../GetById.GetContributorByIdRequest.cs | 11 +- .../Contributors/GetById.cs | 15 +- .../Contributors/List.cs | 20 +- .../Update.UpdateContributorRequest.cs | 11 +- .../Contributors/Update.cs | 18 +- .../Extensions/ResultExtensions.cs | 15 + src/Clean.Architecture.Web/Program.cs | 74 +++-- tests/.editorconfig | 7 + .../CustomWebApplicationFactory.cs | 267 +++++++++--------- .../DockerAvailabilityTests.cs | 23 +- .../Data/BaseEfRepoTestFixture.cs | 69 +++-- .../Data/EfRepositoryUpdate.cs | 2 +- .../NoOpMediator.cs | 49 ++-- 48 files changed, 964 insertions(+), 438 deletions(-) create mode 100644 src/Clean.Architecture.Web/.editorconfig create mode 100644 tests/.editorconfig diff --git a/Directory.Build.props b/Directory.Build.props index 1b57a4a27..7274c43cc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,11 @@ - + true true + latest-All + $(WarningsNotAsErrors);NU1903 true + true net10.0 enable enable @@ -11,4 +14,4 @@ 1591 - \ No newline at end of file + diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs index 7562dbb51..1cac798e4 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs @@ -3,16 +3,31 @@ namespace Clean.Architecture.Core.ContributorAggregate.Handlers; -public class ContributorDeletedHandler(ILogger logger, +public partial class ContributorDeletedHandler( + ILogger logger, IEmailSender emailSender) : INotificationHandler { - public async ValueTask Handle(ContributorDeletedEvent domainEvent, CancellationToken cancellationToken) + public async ValueTask Handle( + ContributorDeletedEvent domainEvent, + CancellationToken cancellationToken) { - logger.LogInformation("Handling Contributed Deleted event for {contributorId}", domainEvent.ContributorId); + Guard.Against.Null(domainEvent); - await emailSender.SendEmailAsync("to@test.com", - "from@test.com", - "Contributor Deleted", - $"Contributor with id {domainEvent.ContributorId} was deleted."); + LogHandlingContributorDeleted(logger, domainEvent.ContributorId); + + await emailSender.SendEmailAsync( + "to@test.com", + "from@test.com", + "Contributor Deleted", + $"Contributor with id {domainEvent.ContributorId} was deleted.") + .ConfigureAwait(false); } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Handling Contributor Deleted event for {ContributorId}")] + private static partial void LogHandlingContributorDeleted( + ILogger logger, + ContributorId contributorId); } diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs index f35f27e51..adf4e7242 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs @@ -3,17 +3,33 @@ namespace Clean.Architecture.Core.ContributorAggregate.Handlers; -public class ContributorNameUpdatedEmailNotificationHandler( +public partial class ContributorNameUpdatedEmailNotificationHandler( ILogger logger, IEmailSender emailSender) : INotificationHandler { - public async ValueTask Handle(ContributorNameUpdatedEvent domainEvent, CancellationToken cancellationToken) + public async ValueTask Handle( + ContributorNameUpdatedEvent domainEvent, + CancellationToken cancellationToken) { - logger.LogInformation("Handling Contributor Name Updated event for {contributorId}", domainEvent.Contributor.Id); + Guard.Against.Null(domainEvent); - await emailSender.SendEmailAsync("to@test.com", - "from@test.com", - $"Contributor {domainEvent.Contributor.Id} Name Updated", -$"Contributor with id {domainEvent.Contributor.Id} had their name updated to {domainEvent.Contributor.Name}."); + LogHandlingContributorNameUpdated( + logger, + domainEvent.Contributor.Id); + + await emailSender.SendEmailAsync( + "to@test.com", + "from@test.com", + $"Contributor {domainEvent.Contributor.Id} Name Updated", + $"Contributor with id {domainEvent.Contributor.Id} had their name updated to {domainEvent.Contributor.Name}.") + .ConfigureAwait(false); } + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "Handling Contributor Name Updated event for {ContributorId}")] + private static partial void LogHandlingContributorNameUpdated( + ILogger logger, + ContributorId contributorId); } diff --git a/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs b/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs index 68d689de2..04766a3df 100644 --- a/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs +++ b/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs @@ -2,5 +2,5 @@ public interface IEmailSender { - Task SendEmailAsync(string to, string from, string subject, string body); + Task SendEmailAsync(string recipient, string from, string subject, string body); } diff --git a/src/Clean.Architecture.Core/Services/DeleteContributorService.cs b/src/Clean.Architecture.Core/Services/DeleteContributorService.cs index 153fb0a0b..7966c2100 100644 --- a/src/Clean.Architecture.Core/Services/DeleteContributorService.cs +++ b/src/Clean.Architecture.Core/Services/DeleteContributorService.cs @@ -1,4 +1,4 @@ -using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.Core.ContributorAggregate.Events; using Clean.Architecture.Core.Interfaces; @@ -8,23 +8,43 @@ namespace Clean.Architecture.Core.Services; /// This is here mainly so there's an example of a domain service /// and also to demonstrate how to fire domain events from a service. /// -/// -/// -/// -public class DeleteContributorService(IRepository _repository, - IMediator _mediator, - ILogger _logger) : IDeleteContributorService +public partial class DeleteContributorService( + IRepository repository, + IMediator mediator, + ILogger logger) : IDeleteContributorService { - public async ValueTask DeleteContributor(ContributorId contributorId) + public async ValueTask DeleteContributor( + ContributorId contributorId) { - _logger.LogInformation("Deleting Contributor {contributorId}", contributorId); - Contributor? aggregateToDelete = await _repository.GetByIdAsync(contributorId); - if (aggregateToDelete == null) return Result.NotFound(); + LogDeletingContributor(logger, contributorId); + + Contributor? aggregateToDelete = await repository + .GetByIdAsync(contributorId) + .ConfigureAwait(false); + + if (aggregateToDelete is null) + { + return Result.NotFound(); + } + + await repository + .DeleteAsync(aggregateToDelete) + .ConfigureAwait(false); - await _repository.DeleteAsync(aggregateToDelete); var domainEvent = new ContributorDeletedEvent(contributorId); - await _mediator.Publish(domainEvent); + + await mediator + .Publish(domainEvent) + .ConfigureAwait(false); return Result.Success(); } + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Information, + Message = "Deleting Contributor {ContributorId}")] + private static partial void LogDeletingContributor( + ILogger logger, + ContributorId contributorId); } diff --git a/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs b/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs index 541d0074f..d85cfc619 100644 --- a/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs +++ b/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs @@ -7,6 +7,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op protected override void OnModelCreating(ModelBuilder modelBuilder) { + Guard.Against.Null(modelBuilder); + base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); } diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs b/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs index c01c38d4e..9aa34c9d9 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs @@ -6,6 +6,8 @@ public class ContributorConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { + Guard.Against.Null(builder); + builder.Property(entity => entity.Id) .ValueGeneratedOnAdd() .HasVogenConversion() @@ -18,9 +20,9 @@ public void Configure(EntityTypeBuilder builder) builder.OwnsOne(builder => builder.PhoneNumber); - builder.Property(x => x.Status) + builder.Property(entity => entity.Status) .HasConversion( - x => x.Value, - x => ContributorStatus.FromValue(x)); + status => status.Value, + value => ContributorStatus.FromValue(value)); } } diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs b/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs index 302816215..c4fdb20ba 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs @@ -2,5 +2,7 @@ public static class DataSchemaConstants { +#pragma warning disable CA1707 // Preserve the established public constant name. public const int DEFAULT_NAME_LENGTH = 100; +#pragma warning restore CA1707 } diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs b/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs index b26a46854..0868368ed 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs @@ -1,8 +1,11 @@ -using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.Core.ContributorAggregate; using Vogen; namespace Clean.Architecture.Infrastructure.Data.Config; +// This type is intentionally used as a marker for the Vogen source generator. +#pragma warning disable CA1812, CA1852 [EfCoreConverter] [EfCoreConverter] internal partial class VogenEfCoreConverters; +#pragma warning restore CA1812, CA1852 diff --git a/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs b/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs index 0d879cdae..e11fcea83 100644 --- a/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs +++ b/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs @@ -3,31 +3,42 @@ namespace Clean.Architecture.Infrastructure.Data; // Intercepts SaveChanges to dispatch domain events after changes are successfully saved -public class EventDispatchInterceptor(IDomainEventDispatcher domainEventDispatcher) : SaveChangesInterceptor +public class EventDispatchInterceptor( + IDomainEventDispatcher domainEventDispatcher) + : SaveChangesInterceptor { - private readonly IDomainEventDispatcher _domainEventDispatcher = domainEventDispatcher; + private readonly IDomainEventDispatcher _domainEventDispatcher = + domainEventDispatcher; // Called after SaveChangesAsync has completed successfully - public override async ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, - CancellationToken cancellationToken = new CancellationToken()) + public override async ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, + int result, + CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(eventData); + var context = eventData.Context; + if (context is not AppDbContext appDbContext) { - return await base.SavedChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false); + return await base + .SavedChangesAsync(eventData, result, cancellationToken) + .ConfigureAwait(false); } - // Retrieve all tracked entities that have domain events - var entitiesWithEvents = appDbContext.ChangeTracker.Entries() - .Select(e => e.Entity) - .Where(e => e.DomainEvents.Any()) + var entitiesWithEvents = appDbContext.ChangeTracker + .Entries() + .Select(entry => entry.Entity) + .Where(entity => entity.DomainEvents.Count > 0) .ToArray(); - // Dispatch and clear domain events - await _domainEventDispatcher.DispatchAndClearEvents(entitiesWithEvents); - - return await base.SavedChangesAsync(eventData, result, cancellationToken); + await _domainEventDispatcher + .DispatchAndClearEvents(entitiesWithEvents) + .ConfigureAwait(false); + return await base + .SavedChangesAsync(eventData, result, cancellationToken) + .ConfigureAwait(false); } } - diff --git a/src/Clean.Architecture.Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs b/src/Clean.Architecture.Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs index 0043a8a9c..e237077b3 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs @@ -10,6 +10,8 @@ public partial class PhoneNumber : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + Guard.Against.Null(migrationBuilder); + migrationBuilder.CreateTable( name: "Contributors", columns: table => new @@ -31,6 +33,8 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + Guard.Against.Null(migrationBuilder); + migrationBuilder.DropTable( name: "Contributors"); } diff --git a/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs b/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs index 37ca0dd54..7259b438b 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs @@ -10,6 +10,8 @@ public partial class UpdateForNet10 : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + Guard.Against.Null(migrationBuilder); + // Only alter columns if using SQL Server // SQLite uses dynamic typing so these changes aren't necessary if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer") @@ -73,6 +75,8 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + Guard.Against.Null(migrationBuilder); + if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer") { migrationBuilder.AlterColumn( diff --git a/src/Clean.Architecture.Infrastructure/Data/Migrations/20260424000000_UseDbGeneratedIds.cs b/src/Clean.Architecture.Infrastructure/Data/Migrations/20260424000000_UseDbGeneratedIds.cs index 703043822..432454f16 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Migrations/20260424000000_UseDbGeneratedIds.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Migrations/20260424000000_UseDbGeneratedIds.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -10,6 +10,8 @@ public partial class UseDbGeneratedIds : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + Guard.Against.Null(migrationBuilder); + if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer") { // SQL Server requires drop/recreate to add IDENTITY to an existing column. @@ -48,6 +50,8 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + Guard.Against.Null(migrationBuilder); + if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer") { migrationBuilder.Sql("EXEC sp_rename N'[Contributors]', N'[Contributors_old]'"); diff --git a/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs b/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs index 28c24f36e..ccfbbf5e6 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs @@ -22,9 +22,9 @@ public ListContributorsQueryService(AppDbContext db) .Take(perPage) .Select(c => new ContributorDto(c.Id, c.Name, c.PhoneNumber ?? PhoneNumber.Unknown)) .AsNoTracking() - .ToListAsync(); + .ToListAsync().ConfigureAwait(false); - int totalCount = await _db.Contributors.CountAsync(); + int totalCount = await _db.Contributors.CountAsync().ConfigureAwait(false); int totalPages = (int)Math.Ceiling(totalCount / (double)perPage); var result = new UseCases.PagedResult(items, page, perPage, totalCount, totalPages); diff --git a/src/Clean.Architecture.Infrastructure/Data/SeedData.cs b/src/Clean.Architecture.Infrastructure/Data/SeedData.cs index 8e447d5d7..3eb44c253 100644 --- a/src/Clean.Architecture.Infrastructure/Data/SeedData.cs +++ b/src/Clean.Architecture.Infrastructure/Data/SeedData.cs @@ -4,27 +4,55 @@ namespace Clean.Architecture.Infrastructure.Data; public static class SeedData { +#pragma warning disable CA1707 // Preserve the established public constant name. public const int NUMBER_OF_CONTRIBUTORS = 27; // including the 2 below - public static readonly ContributorName Contributor1Name = ContributorName.From("Ardalis"); - public static readonly ContributorName Contributor2Name = ContributorName.From("Ilyana"); +#pragma warning restore CA1707 + + public static readonly ContributorName Contributor1Name = + ContributorName.From("Ardalis"); + + public static readonly ContributorName Contributor2Name = + ContributorName.From("Ilyana"); public static async Task InitializeAsync(AppDbContext dbContext) { - if (await dbContext.Contributors.AnyAsync()) return; // DB has been seeded + Guard.Against.Null(dbContext); + + var hasContributors = await dbContext.Contributors + .AnyAsync() + .ConfigureAwait(false); - await PopulateTestDataAsync(dbContext); + if (hasContributors) + { + return; + } + + await PopulateTestDataAsync(dbContext) + .ConfigureAwait(false); } public static async Task PopulateTestDataAsync(AppDbContext dbContext) { - // Use SQL inserts to avoid key generation/conversion issues with value object IDs. - await dbContext.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({Contributor1Name.Value}, {ContributorStatus.NotSet.Value})"); - await dbContext.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({Contributor2Name.Value}, {ContributorStatus.NotSet.Value})"); + Guard.Against.Null(dbContext); + + await dbContext.Database + .ExecuteSqlInterpolatedAsync( + $"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({Contributor1Name.Value}, {ContributorStatus.NotSet.Value})") + .ConfigureAwait(false); - // Add a bunch more contributors to support demonstrating paging. - for (int i = 1; i <= NUMBER_OF_CONTRIBUTORS - 2; i++) + await dbContext.Database + .ExecuteSqlInterpolatedAsync( + $"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({Contributor2Name.Value}, {ContributorStatus.NotSet.Value})") + .ConfigureAwait(false); + + for (var index = 1; index <= NUMBER_OF_CONTRIBUTORS - 2; index++) { - await dbContext.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({$"Contributor {i}"}, {ContributorStatus.NotSet.Value})"); + var contributorName = $"Contributor {index}"; + + await dbContext.Database + .ExecuteSqlInterpolatedAsync( + $"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({contributorName}, {ContributorStatus.NotSet.Value})") + .ConfigureAwait(false); } } } diff --git a/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs b/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs index 945d002d6..b54be018b 100644 --- a/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs +++ b/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs @@ -1,13 +1,31 @@ -using Clean.Architecture.Core.Interfaces; +using Clean.Architecture.Core.Interfaces; namespace Clean.Architecture.Infrastructure.Email; -public class FakeEmailSender(ILogger logger) : IEmailSender +public partial class FakeEmailSender( + ILogger logger) + : IEmailSender { private readonly ILogger _logger = logger; - public Task SendEmailAsync(string to, string from, string subject, string body) + + public Task SendEmailAsync( + string recipient, + string from, + string subject, + string body) { - _logger.LogInformation("Not actually sending an email to {to} from {from} with subject {subject}", to, from, subject); + LogEmailNotSent(_logger, recipient, from, subject); + return Task.CompletedTask; } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Not actually sending an email to {Recipient} from {From} with subject {Subject}")] + private static partial void LogEmailNotSent( + ILogger logger, + string recipient, + string from, + string subject); } diff --git a/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs b/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs index c1846335a..699f9af00 100644 --- a/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs +++ b/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs @@ -2,28 +2,64 @@ namespace Clean.Architecture.Infrastructure.Email; -public class MimeKitEmailSender(ILogger logger, - IOptions mailserverOptions) : IEmailSender +public partial class MimeKitEmailSender( + ILogger logger, + IOptions mailserverOptions) + : IEmailSender { private readonly ILogger _logger = logger; - private readonly MailserverConfiguration _mailserverConfiguration = mailserverOptions.Value!; - public async Task SendEmailAsync(string to, string from, string subject, string body) + private readonly MailserverConfiguration _mailserverConfiguration = + mailserverOptions.Value!; + + public async Task SendEmailAsync( + string recipient, + string from, + string subject, + string body) { - _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, this.ToString()); + LogSendingEmail( + _logger, + recipient, + from, + subject, + nameof(MimeKitEmailSender)); + + using var client = new MailKit.Net.Smtp.SmtpClient(); + + await client + .ConnectAsync( + _mailserverConfiguration.Hostname, + _mailserverConfiguration.Port, + false) + .ConfigureAwait(false); + + using var message = new MimeMessage(); - using var client = new MailKit.Net.Smtp.SmtpClient(); - await client.ConnectAsync(_mailserverConfiguration.Hostname, - _mailserverConfiguration.Port, false); - var message = new MimeMessage(); message.From.Add(new MailboxAddress(from, from)); - message.To.Add(new MailboxAddress(to, to)); + message.To.Add(new MailboxAddress(recipient, recipient)); message.Subject = subject; message.Body = new TextPart("plain") { Text = body }; - await client.SendAsync(message); + await client + .SendAsync(message) + .ConfigureAwait(false); - await client.DisconnectAsync(true, - new CancellationToken(canceled: true)); + await client + .DisconnectAsync( + true, + new CancellationToken(canceled: true)) + .ConfigureAwait(false); } + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Warning, + Message = "Sending email to {Recipient} from {From} with subject {Subject} using {SenderType}.")] + private static partial void LogSendingEmail( + ILogger logger, + string recipient, + string from, + string subject, + string senderType); } diff --git a/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs b/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs index 3ff1d9091..e22c0b53b 100644 --- a/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs +++ b/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs @@ -5,7 +5,7 @@ using Clean.Architecture.UseCases.Contributors.List; namespace Clean.Architecture.Infrastructure; -public static class InfrastructureServiceExtensions +public static partial class InfrastructureServiceExtensions { public static IServiceCollection AddInfrastructureServices( this IServiceCollection services, @@ -18,7 +18,7 @@ public static IServiceCollection AddInfrastructureServices( // 3. "SqliteConnection" - fallback to SQLite bool isWindows = OperatingSystem.IsWindows(); bool forceSqlServer = Environment.GetEnvironmentVariable("USE_SQL_SERVER") == "true"; - + string? connectionString = config.GetConnectionString("cleanarchitecture") ?? ((isWindows || forceSqlServer) ? config.GetConnectionString("DefaultConnection") : null) ?? config.GetConnectionString("SqliteConnection"); @@ -30,9 +30,9 @@ public static IServiceCollection AddInfrastructureServices( services.AddDbContext((provider, options) => { var eventDispatchInterceptor = provider.GetRequiredService(); - + // Use SQL Server if Aspire or DefaultConnection (on Windows or forced) is available, otherwise use SQLite - if (config.GetConnectionString("cleanarchitecture") != null || + if (config.GetConnectionString("cleanarchitecture") != null || ((isWindows || forceSqlServer) && config.GetConnectionString("DefaultConnection") != null)) { options.UseSqlServer(connectionString); @@ -41,7 +41,7 @@ public static IServiceCollection AddInfrastructureServices( { options.UseSqlite(connectionString); } - + options.AddInterceptors(eventDispatchInterceptor); }); @@ -50,8 +50,15 @@ public static IServiceCollection AddInfrastructureServices( .AddScoped() .AddScoped(); - logger.LogInformation("{Project} services registered", "Infrastructure"); + LogInfrastructureServicesRegistered(logger); return services; } + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Information, + Message = "Infrastructure services registered")] + private static partial void LogInfrastructureServicesRegistered( + ILogger logger); } diff --git a/src/Clean.Architecture.ServiceDefaults/Extensions.cs b/src/Clean.Architecture.ServiceDefaults/Extensions.cs index 8ffe5aed0..6dd32cb68 100644 --- a/src/Clean.Architecture.ServiceDefaults/Extensions.cs +++ b/src/Clean.Architecture.ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -13,7 +12,9 @@ namespace Microsoft.Extensions.Hosting; // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +#pragma warning disable CA1724 // Preserve the established public extension class name. public static class Extensions +#pragma warning restore CA1724 { private const string HealthEndpointPath = "/health"; private const string AlivenessEndpointPath = "/alive"; @@ -63,10 +64,14 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w { tracing.AddSource(builder.Environment.ApplicationName) .AddAspNetCoreInstrumentation(tracing => - // Exclude health check requests from tracing - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments( + HealthEndpointPath, + StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments( + AlivenessEndpointPath, + StringComparison.OrdinalIgnoreCase) ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() @@ -108,6 +113,8 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) w public static WebApplication MapDefaultEndpoints(this WebApplication app) { + + ArgumentNullException.ThrowIfNull(app); // Adding health checks endpoints to applications in non-development environments has security implications. // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. if (app.Environment.IsDevelopment()) diff --git a/src/Clean.Architecture.UseCases/Constants.cs b/src/Clean.Architecture.UseCases/Constants.cs index 22ba6c62e..2505886b7 100644 --- a/src/Clean.Architecture.UseCases/Constants.cs +++ b/src/Clean.Architecture.UseCases/Constants.cs @@ -1,7 +1,11 @@ namespace Clean.Architecture.UseCases; +#pragma warning disable CA1052 // Preserve the established public type shape. public class Constants +#pragma warning restore CA1052 { +#pragma warning disable CA1707 // Preserve the established public constant names. public const int DEFAULT_PAGE_SIZE = 10; public const int MAX_PAGE_SIZE = 100; +#pragma warning restore CA1707 } diff --git a/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs index 51f5b157c..f6f849ee7 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs @@ -1,20 +1,32 @@ -using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.Core.ContributorAggregate; namespace Clean.Architecture.UseCases.Contributors.Create; -public class CreateContributorHandler(IRepository _repository) +public class CreateContributorHandler( + IRepository repository) : ICommandHandler> { - public async ValueTask> Handle(CreateContributorCommand command, + public async ValueTask> Handle( + CreateContributorCommand command, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(command); + var newContributor = new Contributor(command.Name); + if (!string.IsNullOrEmpty(command.PhoneNumber)) { - var phoneNumber = new PhoneNumber("+1", command.PhoneNumber, String.Empty); + var phoneNumber = new PhoneNumber( + "+1", + command.PhoneNumber, + String.Empty); + newContributor.UpdatePhoneNumber(phoneNumber); } - var createdItem = await _repository.AddAsync(newContributor, cancellationToken); + + var createdItem = await repository + .AddAsync(newContributor, cancellationToken) + .ConfigureAwait(false); return createdItem.Id; } diff --git a/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs index 8f56d47df..b8eec46df 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs @@ -1,14 +1,22 @@ -using Clean.Architecture.Core.Interfaces; +using Clean.Architecture.Core.Interfaces; namespace Clean.Architecture.UseCases.Contributors.Delete; -public class DeleteContributorHandler(IDeleteContributorService _deleteContributorService) +public class DeleteContributorHandler( + IDeleteContributorService deleteContributorService) : ICommandHandler { - public async ValueTask Handle(DeleteContributorCommand request, CancellationToken cancellationToken) => + public async ValueTask Handle( + DeleteContributorCommand request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + // This Approach: Keep Domain Events in the Domain Model / Core project; this becomes a pass-through // This is @ardalis's preferred approach - await _deleteContributorService.DeleteContributor(request.ContributorId); + return await deleteContributorService + .DeleteContributor(request.ContributorId) + .ConfigureAwait(false); // Another Approach: Do the real work here including dispatching domain events - change the event from internal to public // @ardalis prefers using the service above so that **domain** event behavior remains in the **domain model** (core project) @@ -17,4 +25,5 @@ public async ValueTask Handle(DeleteContributorCommand request, Cancella // await _repository.DeleteAsync(aggregateToDelete); // var domainEvent = new ContributorDeletedEvent(request.ContributorId); // await _mediator.Publish(domainEvent);// return Result.Success(); + } } diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs index 9cdc4d598..2df6e1991 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs @@ -1,20 +1,37 @@ using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.Core.ContributorAggregate.Specifications; +#pragma warning disable CA1716 // Preserve the established public namespace. namespace Clean.Architecture.UseCases.Contributors.Get; +#pragma warning restore CA1716 /// -/// Queries don't necessarily need to use repository methods, but they can if it's convenient +/// Queries don't necessarily need to use repository methods, but they can if it's convenient. /// -public class GetContributorHandler(IReadRepository _repository) +public class GetContributorHandler( + IReadRepository repository) : IQueryHandler> { - public async ValueTask> Handle(GetContributorQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle( + GetContributorQuery request, + CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(request); + var spec = new ContributorByIdSpec(request.ContributorId); - var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); - if (entity == null) return Result.NotFound(); - return new ContributorDto(entity.Id, entity.Name, entity.PhoneNumber ?? PhoneNumber.Unknown); + var entity = await repository + .FirstOrDefaultAsync(spec, cancellationToken) + .ConfigureAwait(false); + + if (entity is null) + { + return Result.NotFound(); + } + + return new ContributorDto( + entity.Id, + entity.Name, + entity.PhoneNumber ?? PhoneNumber.Unknown); } } diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs index c27d2f49b..3ed453f72 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs @@ -1,5 +1,7 @@ using Clean.Architecture.Core.ContributorAggregate; +#pragma warning disable CA1716 // Preserve the established public namespace. namespace Clean.Architecture.UseCases.Contributors.Get; +#pragma warning restore CA1716 public record GetContributorQuery(ContributorId ContributorId) : IQuery>; diff --git a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs index 1dbcd8aa2..5a48fe53a 100644 --- a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs @@ -1,6 +1,7 @@ namespace Clean.Architecture.UseCases.Contributors.List; -public class ListContributorsHandler : IQueryHandler>> +public class ListContributorsHandler + : IQueryHandler>> { private readonly IListContributorsQueryService _query; @@ -9,11 +10,17 @@ public ListContributorsHandler(IListContributorsQueryService query) _query = query; } - public async ValueTask>> Handle(ListContributorsQuery request, - CancellationToken cancellationToken) + public async ValueTask>> Handle( + ListContributorsQuery request, + CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(request); - var result = await _query.ListAsync(request.Page ?? 1, request.PerPage ?? Constants.DEFAULT_PAGE_SIZE); + var result = await _query + .ListAsync( + request.Page ?? 1, + request.PerPage ?? Constants.DEFAULT_PAGE_SIZE) + .ConfigureAwait(false); return Result.Success(result); } diff --git a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs index 6cb2278bf..4321538c7 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs @@ -1,24 +1,35 @@ -using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.Core.ContributorAggregate; namespace Clean.Architecture.UseCases.Contributors.Update; -public class UpdateContributorHandler(IRepository _repository) +public class UpdateContributorHandler( + IRepository repository) : ICommandHandler> { - public async ValueTask> Handle(UpdateContributorCommand command, - CancellationToken ct) + public async ValueTask> Handle( + UpdateContributorCommand command, + CancellationToken cancellationToken) { - var existingContributor = await _repository.GetByIdAsync(command.ContributorId, ct); - if (existingContributor == null) + ArgumentNullException.ThrowIfNull(command); + + var existingContributor = await repository + .GetByIdAsync(command.ContributorId, cancellationToken) + .ConfigureAwait(false); + + if (existingContributor is null) { return Result.NotFound(); } existingContributor.UpdateName(command.NewName); - await _repository.UpdateAsync(existingContributor, ct); + await repository + .UpdateAsync(existingContributor, cancellationToken) + .ConfigureAwait(false); - return new ContributorDto(existingContributor.Id, - existingContributor.Name, existingContributor.PhoneNumber ?? PhoneNumber.Unknown); + return new ContributorDto( + existingContributor.Id, + existingContributor.Name, + existingContributor.PhoneNumber ?? PhoneNumber.Unknown); } } diff --git a/src/Clean.Architecture.Web/.editorconfig b/src/Clean.Architecture.Web/.editorconfig new file mode 100644 index 000000000..39534610a --- /dev/null +++ b/src/Clean.Architecture.Web/.editorconfig @@ -0,0 +1,8 @@ +# CA1515 is not appropriate for this executable web project. +# Public types are required for framework discovery and functional tests. + +[*.cs] +dotnet_diagnostic.CA1515.severity = none + +# Endpoint type names intentionally match their vertical-slice operations. +dotnet_diagnostic.CA1724.severity = none diff --git a/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs b/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs index d5c3ed9c4..f544d2584 100644 --- a/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs +++ b/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs @@ -1,19 +1,27 @@ -using Serilog; +using System.Globalization; +using Serilog; namespace Clean.Architecture.Web.Configurations; public static class LoggerConfigs { - public static WebApplicationBuilder AddLoggerConfigs(this WebApplicationBuilder builder) + public static WebApplicationBuilder AddLoggerConfigs( + this WebApplicationBuilder builder) { - // Add Serilog as an additional logging provider alongside OpenTelemetry - // This allows both Serilog (for console/file) and OpenTelemetry (for Aspire) to work together - builder.Logging.AddSerilog(new LoggerConfiguration() - .ReadFrom.Configuration(builder.Configuration) - .Enrich.FromLogContext() - .Enrich.WithProperty("Application", builder.Environment.ApplicationName) - .WriteTo.Console() - .CreateLogger()); + ArgumentNullException.ThrowIfNull(builder); + + // Add Serilog as an additional logging provider alongside OpenTelemetry. + // This allows Serilog and OpenTelemetry to work together. + builder.Logging.AddSerilog( + new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithProperty( + "Application", + builder.Environment.ApplicationName) + .WriteTo.Console( + formatProvider: CultureInfo.InvariantCulture) + .CreateLogger()); return builder; } diff --git a/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs b/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs index b151bb6d6..5f6e4853a 100644 --- a/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs +++ b/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs @@ -1,45 +1,47 @@ -using Ardalis.SharedKernel; +using Ardalis.SharedKernel; using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.Infrastructure; using Clean.Architecture.UseCases.Contributors.Create; namespace Clean.Architecture.Web.Configurations; -public static class MediatorConfig +public static partial class MediatorConfig { - // Should be called from ServiceConfigs.cs, not Program.cs - public static IServiceCollection AddMediatorSourceGen(this IServiceCollection services, + // Should be called from ServiceConfigs.cs, not Program.cs. + public static IServiceCollection AddMediatorSourceGen( + this IServiceCollection services, Microsoft.Extensions.Logging.ILogger logger) { - logger.LogInformation("Registering Mediator SourceGen and Behaviors"); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + LogRegisteringMediator(logger); + services.AddMediator(options => { - // Lifetime: Singleton is fastest per docs; Scoped/Transient also supported. options.ServiceLifetime = ServiceLifetime.Scoped; - // Supply any TYPE from each assembly you want scanned (the generator finds the assembly from the type) options.Assemblies = [ - typeof(Contributor), // Core - typeof(CreateContributorCommand), // UseCases - typeof(InfrastructureServiceExtensions), // Infrastructure - typeof(MediatorConfig) // Web + typeof(Contributor), + typeof(CreateContributorCommand), + typeof(InfrastructureServiceExtensions), + typeof(MediatorConfig) ]; - // Register pipeline behaviors here (order matters) options.PipelineBehaviors = [ typeof(LoggingBehavior<,>) ]; - - // If you have stream behaviors: - // options.StreamPipelineBehaviors = [ typeof(YourStreamBehavior<,>) ]; }); - // Alternative: register behaviors via DI yourself (useful if not doing AOT): - // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); - // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); - return services; } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Registering Mediator SourceGen and behaviors")] + private static partial void LogRegisteringMediator( + Microsoft.Extensions.Logging.ILogger logger); } diff --git a/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs b/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs index 755847cf7..41bba2c68 100644 --- a/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs +++ b/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs @@ -4,18 +4,21 @@ namespace Clean.Architecture.Web.Configurations; -public static class MiddlewareConfig +public static partial class MiddlewareConfig { - public static async Task UseAppMiddlewareAndSeedDatabase(this WebApplication app) + public static async Task UseAppMiddlewareAndSeedDatabase( + this WebApplication app) { + ArgumentNullException.ThrowIfNull(app); + if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); - app.UseShowAllServicesMiddleware(); // see https://github.com/ardalis/AspNetCoreStartupServices + app.UseShowAllServicesMiddleware(); } else - { - app.UseDefaultExceptionHandler(); // from FastEndpoints + { + app.UseDefaultExceptionHandler(); app.UseHsts(); } @@ -23,16 +26,17 @@ public static async Task UseAppMiddlewareAndSeedDatabase(th if (app.Environment.IsDevelopment()) { - app.UseSwaggerGen(options => - { - options.Path = "/openapi/{documentName}.json"; - }, - settings => - { - settings.Path = "/swagger"; - settings.DocumentPath = "/openapi/{documentName}.json"; - }); - + app.UseSwaggerGen( + options => + { + options.Path = "/openapi/{documentName}.json"; + }, + settings => + { + settings.Path = "/swagger"; + settings.DocumentPath = "/openapi/{documentName}.json"; + }); + app.MapScalarApiReference(options => { options.WithTitle("Clean Architecture API"); @@ -40,22 +44,25 @@ public static async Task UseAppMiddlewareAndSeedDatabase(th }); } - app.UseHttpsRedirection(); // Note this will drop Authorization headers + app.UseHttpsRedirection(); + + var shouldMigrate = + app.Environment.IsDevelopment() || + app.Configuration.GetValue("Database:ApplyMigrationsOnStartup"); - // Run migrations and seed in Development or when explicitly requested via environment variable - var shouldMigrate = app.Environment.IsDevelopment() || - app.Configuration.GetValue("Database:ApplyMigrationsOnStartup"); - if (shouldMigrate) { - await MigrateDatabaseAsync(app); - await SeedDatabaseAsync(app); + await MigrateDatabaseAsync(app) + .ConfigureAwait(false); + + await SeedDatabaseAsync(app) + .ConfigureAwait(false); } return app; } - static async Task MigrateDatabaseAsync(WebApplication app) + private static async Task MigrateDatabaseAsync(WebApplication app) { using var scope = app.Services.CreateScope(); var services = scope.ServiceProvider; @@ -63,30 +70,35 @@ static async Task MigrateDatabaseAsync(WebApplication app) try { - logger.LogInformation("Applying database migrations..."); + LogApplyingDatabaseMigrations(logger); + var context = services.GetRequiredService(); - - // For SQLite, use EnsureCreated instead of migrations (common for dev/local scenarios) - // For SQL Server, use migrations (production scenario) + if (context.Database.IsSqlite()) { - await context.Database.EnsureCreatedAsync(); - logger.LogInformation("SQLite database created successfully"); + await context.Database + .EnsureCreatedAsync() + .ConfigureAwait(false); + + LogSqliteDatabaseCreated(logger); } else { - await context.Database.MigrateAsync(); - logger.LogInformation("Database migrations applied successfully"); + await context.Database + .MigrateAsync() + .ConfigureAwait(false); + + LogDatabaseMigrationsApplied(logger); } } - catch (Exception ex) + catch (Exception exception) { - logger.LogError(ex, "An error occurred migrating the DB. {exceptionMessage}", ex.Message); - throw; // Re-throw to make startup fail if migrations fail + LogDatabaseMigrationError(logger, exception); + throw; } } - static async Task SeedDatabaseAsync(WebApplication app) + private static async Task SeedDatabaseAsync(WebApplication app) { using var scope = app.Services.CreateScope(); var services = scope.ServiceProvider; @@ -94,15 +106,72 @@ static async Task SeedDatabaseAsync(WebApplication app) try { - logger.LogInformation("Seeding database..."); + LogSeedingDatabase(logger); + var context = services.GetRequiredService(); - await SeedData.InitializeAsync(context); - logger.LogInformation("Database seeded successfully"); + + await SeedData + .InitializeAsync(context) + .ConfigureAwait(false); + + LogDatabaseSeeded(logger); } - catch (Exception ex) +#pragma warning disable CA1031 // Seeding failures are intentionally non-fatal. + catch (Exception exception) { - logger.LogError(ex, "An error occurred seeding the DB. {exceptionMessage}", ex.Message); - // Don't re-throw for seeding errors - it's not critical + LogDatabaseSeedingError(logger, exception); } +#pragma warning restore CA1031 } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Applying database migrations...")] + private static partial void LogApplyingDatabaseMigrations( + Microsoft.Extensions.Logging.ILogger logger); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "SQLite database created successfully")] + private static partial void LogSqliteDatabaseCreated( + Microsoft.Extensions.Logging.ILogger logger); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Information, + Message = "Database migrations applied successfully")] + private static partial void LogDatabaseMigrationsApplied( + Microsoft.Extensions.Logging.ILogger logger); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Error, + Message = "An error occurred migrating the database.")] + private static partial void LogDatabaseMigrationError( + Microsoft.Extensions.Logging.ILogger logger, + Exception exception); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Information, + Message = "Seeding database...")] + private static partial void LogSeedingDatabase( + Microsoft.Extensions.Logging.ILogger logger); + + [LoggerMessage( + EventId = 6, + Level = LogLevel.Information, + Message = "Database seeded successfully")] + private static partial void LogDatabaseSeeded( + Microsoft.Extensions.Logging.ILogger logger); + + [LoggerMessage( + EventId = 7, + Level = LogLevel.Error, + Message = "An error occurred seeding the database.")] + private static partial void LogDatabaseSeedingError( + Microsoft.Extensions.Logging.ILogger logger, + Exception exception); } diff --git a/src/Clean.Architecture.Web/Configurations/OptionConfigs.cs b/src/Clean.Architecture.Web/Configurations/OptionConfigs.cs index a1e0e9853..a87be9492 100644 --- a/src/Clean.Architecture.Web/Configurations/OptionConfigs.cs +++ b/src/Clean.Architecture.Web/Configurations/OptionConfigs.cs @@ -1,37 +1,49 @@ -using Ardalis.ListStartupServices; +using Ardalis.ListStartupServices; using Clean.Architecture.Infrastructure.Email; namespace Clean.Architecture.Web.Configurations; -public static class OptionConfigs +public static partial class OptionConfigs { - public static IServiceCollection AddOptionConfigs(this IServiceCollection services, - IConfiguration configuration, - Microsoft.Extensions.Logging.ILogger logger, - WebApplicationBuilder builder) + public static IServiceCollection AddOptionConfigs( + this IServiceCollection services, + IConfiguration configuration, + Microsoft.Extensions.Logging.ILogger logger, + WebApplicationBuilder builder) { - services.Configure(configuration.GetSection("Mailserver")) - // Configure Web Behavior - .Configure(options => - { - options.CheckConsentNeeded = context => true; - options.MinimumSameSitePolicy = SameSiteMode.None; - }); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(builder); + + services + .Configure( + configuration.GetSection("Mailserver")) + .Configure(options => + { + options.CheckConsentNeeded = context => true; + options.MinimumSameSitePolicy = SameSiteMode.None; + }); if (builder.Environment.IsDevelopment()) { - // add list services for diagnostic purposes - see https://github.com/ardalis/AspNetCoreStartupServices + // Add list services for diagnostic purposes. services.Configure(config => { config.Services = new List(builder.Services); - - // optional - default path to view services is /listallservices - recommended to choose your own path config.Path = "/listservices"; }); } - logger.LogInformation("{Project} were configured", "Options"); + LogOptionsConfigured(logger); return services; } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Options were configured")] + private static partial void LogOptionsConfigured( + Microsoft.Extensions.Logging.ILogger logger); } diff --git a/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs b/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs index cca972657..504bb5a8c 100644 --- a/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs +++ b/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs @@ -1,34 +1,43 @@ -using Clean.Architecture.Core.Interfaces; +using Clean.Architecture.Core.Interfaces; using Clean.Architecture.Infrastructure; using Clean.Architecture.Infrastructure.Email; namespace Clean.Architecture.Web.Configurations; -public static class ServiceConfigs +public static partial class ServiceConfigs { - public static IServiceCollection AddServiceConfigs(this IServiceCollection services, Microsoft.Extensions.Logging.ILogger logger, WebApplicationBuilder builder) + public static IServiceCollection AddServiceConfigs( + this IServiceCollection services, + Microsoft.Extensions.Logging.ILogger logger, + WebApplicationBuilder builder) { - services.AddInfrastructureServices(builder.Configuration, logger) - .AddMediatorSourceGen(logger); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(builder); + + services + .AddInfrastructureServices(builder.Configuration, logger) + .AddMediatorSourceGen(logger); if (builder.Environment.IsDevelopment()) { - // Use a local test email server - configured in Aspire - // See: https://ardalis.com/configuring-a-local-test-email-server/ + // Use a local test email server configured in Aspire. services.AddScoped(); - - // Otherwise use this: - //builder.Services.AddScoped(); } else { services.AddScoped(); } - logger.LogInformation("{Project} services registered", "Mediator Source Generator and Email Sender"); + LogServicesRegistered(logger); return services; } - + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Mediator source generator and email sender services registered")] + private static partial void LogServicesRegistered( + Microsoft.Extensions.Logging.ILogger logger); } diff --git a/src/Clean.Architecture.Web/Contributors/Create.cs b/src/Clean.Architecture.Web/Contributors/Create.cs index e061d8fc4..abbf00a2b 100644 --- a/src/Clean.Architecture.Web/Contributors/Create.cs +++ b/src/Clean.Architecture.Web/Contributors/Create.cs @@ -9,7 +9,7 @@ namespace Clean.Architecture.Web.Contributors; // This shows an example of having all related types in one file for simplicity. // Fast-Endpoints generally uses one file per class for larger projects, which -// is the recommended approach. More files, but fewer merge conflicts and easier to +// is the recommended approach. More files, but fewer merge conflicts and easier to // see what changed in a given commit or PR. public class Create(IMediator mediator) @@ -48,15 +48,25 @@ public override void Configure() .ProducesProblem(500)); } +#pragma warning disable CA1725 // Keep descriptive parameter names used by this endpoint. public override async Task, ValidationProblem, ProblemHttpResult>> ExecuteAsync(CreateContributorRequest request, CancellationToken cancellationToken) { - var result = await _mediator.Send(new CreateContributorCommand(ContributorName.From(request.Name!), request.PhoneNumber)); + ArgumentNullException.ThrowIfNull(request); + + var command = new CreateContributorCommand( + ContributorName.From(request.Name!), + request.PhoneNumber); + + var result = await _mediator + .Send(command, cancellationToken) + .ConfigureAwait(false); return result.ToCreatedResult( id => $"/Contributors/{id}", id => new CreateContributorResponse(id.Value, request.Name!)); } +#pragma warning restore CA1725 } public class CreateContributorRequest @@ -65,7 +75,7 @@ public class CreateContributorRequest [Required] public string Name { get; set; } = String.Empty; - public string? PhoneNumber { get; set; } = null; + public string? PhoneNumber { get; set; } } public class CreateContributorValidator : Validator diff --git a/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs b/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs index dbd7dca19..1c5df9e07 100644 --- a/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs +++ b/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs @@ -1,9 +1,16 @@ -namespace Clean.Architecture.Web.Contributors; +using System.Globalization; + +namespace Clean.Architecture.Web.Contributors; public record DeleteContributorRequest { public const string Route = "/Contributors/{ContributorId:int}"; - public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); + + public static string BuildRoute(int contributorId) => + Route.Replace( + "{ContributorId:int}", + contributorId.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); public int ContributorId { get; set; } } diff --git a/src/Clean.Architecture.Web/Contributors/Delete.cs b/src/Clean.Architecture.Web/Contributors/Delete.cs index 3c7207e7f..7b7b705fc 100644 --- a/src/Clean.Architecture.Web/Contributors/Delete.cs +++ b/src/Clean.Architecture.Web/Contributors/Delete.cs @@ -44,8 +44,14 @@ public override void Configure() public override async Task> ExecuteAsync(DeleteContributorRequest req, CancellationToken ct) { - var cmd = new DeleteContributorCommand(ContributorId.From(req.ContributorId)); - var result = await _mediator.Send(cmd, ct); + ArgumentNullException.ThrowIfNull(req); + + var command = new DeleteContributorCommand( + ContributorId.From(req.ContributorId)); + + var result = await _mediator + .Send(command, ct) + .ConfigureAwait(false); return result.ToDeleteResult(); } diff --git a/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs b/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs index e7dec507f..012b44b01 100644 --- a/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs +++ b/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs @@ -1,9 +1,16 @@ -namespace Clean.Architecture.Web.Contributors; +using System.Globalization; + +namespace Clean.Architecture.Web.Contributors; public class GetContributorByIdRequest { public const string Route = "/Contributors/{ContributorId:int}"; - public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); + + public static string BuildRoute(int contributorId) => + Route.Replace( + "{ContributorId:int}", + contributorId.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); public int ContributorId { get; set; } } diff --git a/src/Clean.Architecture.Web/Contributors/GetById.cs b/src/Clean.Architecture.Web/Contributors/GetById.cs index 94ce0555f..98e165d29 100644 --- a/src/Clean.Architecture.Web/Contributors/GetById.cs +++ b/src/Clean.Architecture.Web/Contributors/GetById.cs @@ -44,7 +44,11 @@ public override void Configure() public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync(GetContributorByIdRequest request, CancellationToken ct) { - var result = await mediator.Send(new GetContributorQuery(ContributorId.From(request.ContributorId)), ct); + ArgumentNullException.ThrowIfNull(request); + + var result = await mediator + .Send(new GetContributorQuery(ContributorId.From(request.ContributorId)), ct) + .ConfigureAwait(false); return result.ToGetByIdResult(Map.FromEntity); } @@ -53,5 +57,12 @@ public sealed class GetContributorByIdMapper : Mapper { public override ContributorRecord FromEntity(ContributorDto e) - => new(e.Id.Value, e.Name.Value, e.PhoneNumber.ToString()); + { + ArgumentNullException.ThrowIfNull(e); + + return new ContributorRecord( + e.Id.Value, + e.Name.Value, + e.PhoneNumber.ToString()); + } } diff --git a/src/Clean.Architecture.Web/Contributors/List.cs b/src/Clean.Architecture.Web/Contributors/List.cs index 6914695c5..1251d45f3 100644 --- a/src/Clean.Architecture.Web/Contributors/List.cs +++ b/src/Clean.Architecture.Web/Contributors/List.cs @@ -3,6 +3,7 @@ using Clean.Architecture.UseCases.Contributors.List; using FluentValidation; +#pragma warning disable CA1724 // Endpoint name intentionally follows the project naming convention. namespace Clean.Architecture.Web.Contributors; public class List(IMediator mediator) : Endpoint @@ -46,12 +47,18 @@ public override void Configure() .ProducesProblem(400)); } + #pragma warning disable CA1725 // Keep the descriptive cancellationToken parameter name. public override async Task HandleAsync(ListContributorsRequest request, CancellationToken cancellationToken) { - var result = await _mediator.Send(new ListContributorsQuery(request.Page, request.PerPage)); + ArgumentNullException.ThrowIfNull(request); + + var result = await _mediator + .Send(new ListContributorsQuery(request.Page, request.PerPage), cancellationToken) + .ConfigureAwait(false); if (!result.IsSuccess) { - await Send.ErrorsAsync(statusCode: 400, cancellationToken); + await Send.ErrorsAsync(statusCode: 400, cancellationToken) + .ConfigureAwait(false); return; } @@ -59,9 +66,12 @@ public override async Task HandleAsync(ListContributorsRequest request, Cancella AddLinkHeader(pagedResult.Page, pagedResult.PerPage, pagedResult.TotalPages); var response = Map.FromEntity(pagedResult); - await Send.OkAsync(response, cancellationToken); + await Send.OkAsync(response, cancellationToken) + .ConfigureAwait(false); } + #pragma warning restore CA1725 + private void AddLinkHeader(int page, int perPage, int totalPages) { var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}{HttpContext.Request.Path}"; @@ -123,6 +133,8 @@ public sealed class ListContributorsMapper { public override ContributorListResponse FromEntity(UseCases.PagedResult e) { + ArgumentNullException.ThrowIfNull(e); + var items = e.Items .Select(c => new ContributorRecord(c.Id.Value, c.Name.Value, c.PhoneNumber.ToString())) .ToList(); @@ -130,3 +142,5 @@ public override ContributorListResponse FromEntity(UseCases.PagedResult Route.Replace("{ContributorId:int}", contributorId.ToString()); + + public static string BuildRoute(int contributorId) => + Route.Replace( + "{ContributorId:int}", + contributorId.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); public int ContributorId { get; set; } [Required] public int Id { get; set; } + [Required] public string? Name { get; set; } } diff --git a/src/Clean.Architecture.Web/Contributors/Update.cs b/src/Clean.Architecture.Web/Contributors/Update.cs index 0f017c1ab..71cc367f4 100644 --- a/src/Clean.Architecture.Web/Contributors/Update.cs +++ b/src/Clean.Architecture.Web/Contributors/Update.cs @@ -48,11 +48,15 @@ public override void Configure() public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync(UpdateContributorRequest request, CancellationToken ct) { - var cmd = new UpdateContributorCommand( + ArgumentNullException.ThrowIfNull(request); + + var command = new UpdateContributorCommand( ContributorId.From(request.Id), ContributorName.From(request.Name!)); - var result = await _mediator.Send(cmd, ct); + var result = await _mediator + .Send(command, ct) + .ConfigureAwait(false); return result.ToUpdateResult(Map.FromEntity); } @@ -62,5 +66,13 @@ public sealed class UpdateContributorMapper : Mapper { public override UpdateContributorResponse FromEntity(ContributorDto e) - => new(new ContributorRecord(e.Id.Value, e.Name.Value, "")); + { + ArgumentNullException.ThrowIfNull(e); + + return new UpdateContributorResponse( + new ContributorRecord( + e.Id.Value, + e.Name.Value, + "")); + } } diff --git a/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs b/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs index 8c03c36de..e1bfaab0a 100644 --- a/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs +++ b/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs @@ -12,6 +12,10 @@ public static Results, ValidationProblem, ProblemHttpResult> Func locationBuilder, Func mapResponse) { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(locationBuilder); + ArgumentNullException.ThrowIfNull(mapResponse); + return result.Status switch { ResultStatus.Ok => TypedResults.Created(locationBuilder(result.Value), mapResponse(result.Value)), @@ -47,6 +51,9 @@ public static Results, NotFound, ProblemHttpResult> ToUpdateResult this Result result, Func mapResponse) { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(mapResponse); + return ToOkOrNotFoundResult(result, mapResponse, "Update"); } @@ -56,6 +63,8 @@ public static Results, NotFound, ProblemHttpResult> ToUpdateResult public static Results ToDeleteResult( this Result result) { + ArgumentNullException.ThrowIfNull(result); + return result.Status switch { ResultStatus.Ok => TypedResults.NoContent(), @@ -75,6 +84,9 @@ private static Results, NotFound, ProblemHttpResult> ToOkOrNotFoun Func mapResponse, string operationName) { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(mapResponse); + return result.Status switch { ResultStatus.Ok => TypedResults.Ok(mapResponse(result.Value)), @@ -93,6 +105,9 @@ public static Ok ToOkOnlyResult( this Result result, Func mapResponse) { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(mapResponse); + return TypedResults.Ok(mapResponse(result.Value)); } } diff --git a/src/Clean.Architecture.Web/Program.cs b/src/Clean.Architecture.Web/Program.cs index e45ac2437..6e1c960b5 100644 --- a/src/Clean.Architecture.Web/Program.cs +++ b/src/Clean.Architecture.Web/Program.cs @@ -1,37 +1,61 @@ -using Clean.Architecture.Web.Configurations; +using Clean.Architecture.Web.Configurations; var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults() // This sets up OpenTelemetry logging - .AddLoggerConfigs(); // This adds Serilog for console formatting +builder + .AddServiceDefaults() + .AddLoggerConfigs(); -using var loggerFactory = LoggerFactory.Create(config => config.AddConsole()); -var startupLogger = loggerFactory.CreateLogger(); +using var loggerFactory = LoggerFactory.Create( + config => config.AddConsole()); -startupLogger.LogInformation("Starting web host"); +var startupLogger = loggerFactory.CreateLogger(); -builder.Services.AddOptionConfigs(builder.Configuration, startupLogger, builder); -builder.Services.AddServiceConfigs(startupLogger, builder); +LogStartingWebHost(startupLogger); -builder.Services.AddFastEndpoints() - .SwaggerDocument(o => - { - o.DocumentSettings = s => - { - s.Title = "Clean Architecture API"; - s.Version = "v1"; - s.Description = "HTTP endpoints for the Clean Architecture sample application."; - }; - o.ShortSchemaNames = true; - }); +builder.Services.AddOptionConfigs( + builder.Configuration, + startupLogger, + builder); -var app = builder.Build(); +builder.Services.AddServiceConfigs( + startupLogger, + builder); -await app.UseAppMiddlewareAndSeedDatabase(); +builder.Services + .AddFastEndpoints() + .SwaggerDocument(options => + { + options.DocumentSettings = settings => + { + settings.Title = "Clean Architecture API"; + settings.Version = "v1"; + settings.Description = + "HTTP endpoints for the Clean Architecture sample application."; + }; -app.MapDefaultEndpoints(); // Aspire health checks and metrics + options.ShortSchemaNames = true; + }); -app.Run(); +var app = builder.Build(); -// Make the implicit Program.cs class public, so integration tests can reference the correct assembly for host building -public partial class Program { } +await app + .UseAppMiddlewareAndSeedDatabase() + .ConfigureAwait(false); + +app.MapDefaultEndpoints(); + +await app + .RunAsync() + .ConfigureAwait(false); + +// Make the implicit Program class public so integration tests can reference it. +public partial class Program +{ + [Microsoft.Extensions.Logging.LoggerMessage( + EventId = 1, + Level = Microsoft.Extensions.Logging.LogLevel.Information, + Message = "Starting web host")] + private static partial void LogStartingWebHost( + Microsoft.Extensions.Logging.ILogger logger); +} diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 000000000..a8277d769 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,7 @@ +# Public test and fixture types are required for test discovery and shared fixtures. +# Test names intentionally use underscores for readability. +# Example: Method_Scenario_ExpectedResult + +[*.cs] +dotnet_diagnostic.CA1515.severity = none +dotnet_diagnostic.CA1707.severity = none diff --git a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs index 0a040d0df..b3236f6ff 100644 --- a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs +++ b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs @@ -1,127 +1,140 @@ -using Clean.Architecture.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Testcontainers.MsSql; - -namespace Clean.Architecture.FunctionalTests; - -public class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class -{ - private MsSqlContainer? _dbContainer; - - public async ValueTask InitializeAsync() - { - try - { - _dbContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") - .WithPassword("Your_password123!") - .Build(); - await _dbContainer.StartAsync(); - } - catch (ArgumentException) - { - // Docker is not available; fall back to SQLite (configured via appsettings.Testing.json) - _dbContainer = null; - } - } - - public new async ValueTask DisposeAsync() - { - // Clean up environment variable - Environment.SetEnvironmentVariable("USE_SQL_SERVER", null); - if (_dbContainer != null) - { - await _dbContainer.DisposeAsync(); - } - } - - /// - /// Overriding CreateHost to avoid creating a separate ServiceProvider per this thread: - /// https://github.com/dotnet-architecture/eShopOnWeb/issues/465 - /// - /// - /// - protected override IHost CreateHost(IHostBuilder builder) - { - builder.UseEnvironment("Testing"); // will not send real emails - var host = builder.Build(); - host.Start(); - - // Get service provider. - var serviceProvider = host.Services; - - // Create a scope to obtain a reference to the database - // context (AppDbContext). - using (var scope = serviceProvider.CreateScope()) - { - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); - - var logger = scopedServices - .GetRequiredService>>(); - - try - { - // Functional tests use EnsureCreated to avoid migration-script coupling. - db.Database.EnsureCreated(); - - // Seed the database with test data only if it has not been seeded yet. - // This is safe for container reuse across test runs and multiple fixture instances. - SeedData.InitializeAsync(db).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred seeding the database with test messages. Error: {exceptionMessage}", ex.Message); - throw; - } - } - - return host; - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - if (_dbContainer != null) - { - // Force SQL Server mode even on non-Windows platforms for functional tests - Environment.SetEnvironmentVariable("USE_SQL_SERVER", "true"); - } - - builder - .ConfigureAppConfiguration((context, config) => - { - if (_dbContainer != null) - { - // Set the connection string to use the Testcontainer - config.AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = _dbContainer.GetConnectionString() - }); - } - }) - .ConfigureServices(services => - { - if (_dbContainer != null) - { - // Remove the app's ApplicationDbContext registration - var descriptors = services.Where( - d => d.ServiceType == typeof(AppDbContext) || - d.ServiceType == typeof(DbContextOptions)) - .ToList(); - - foreach (var descriptor in descriptors) - { - services.Remove(descriptor); - } - - // Add ApplicationDbContext using the Testcontainers SQL Server instance - services.AddDbContext((provider, options) => - { - options.UseSqlServer(_dbContainer.GetConnectionString()); - var interceptor = provider.GetRequiredService(); - options.AddInterceptors(interceptor); - }); - } - }); - } -} +using Clean.Architecture.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Testcontainers.MsSql; + +namespace Clean.Architecture.FunctionalTests; + +public class CustomWebApplicationFactory + : WebApplicationFactory, IAsyncLifetime + where TProgram : class +{ + private static readonly Action _logDatabaseSeedingError = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, "DatabaseSeeding"), + "An error occurred seeding the database with test messages. Error: {ExceptionMessage}"); + + private MsSqlContainer? _dbContainer; + + public async ValueTask InitializeAsync() + { + try + { + _dbContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") + .WithPassword("Your_password123!") + .Build(); + + await _dbContainer.StartAsync().ConfigureAwait(false); + } + catch (ArgumentException) + { + // Docker is unavailable; use the SQLite configuration. + _dbContainer = null; + } + } + + public override async ValueTask DisposeAsync() + { + Environment.SetEnvironmentVariable("USE_SQL_SERVER", null); + + if (_dbContainer is not null) + { + await _dbContainer.DisposeAsync().ConfigureAwait(false); + } + + await base.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + /// + /// Overrides CreateHost to avoid creating a separate service provider. + /// + protected override IHost CreateHost(IHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.UseEnvironment("Testing"); + + var host = builder.Build(); + host.Start(); + + var serviceProvider = host.Services; + + using var scope = serviceProvider.CreateScope(); + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + + var logger = scopedServices + .GetRequiredService>>(); + + try + { + // Functional tests use EnsureCreated to avoid migration-script coupling. + db.Database.EnsureCreated(); + + // CreateHost is synchronous, so wait for seeding before disposing the scope. +#pragma warning disable CA2025 + SeedData.InitializeAsync(db).GetAwaiter().GetResult(); +#pragma warning restore CA2025 + } + catch (Exception exception) + { + _logDatabaseSeedingError(logger, exception.Message, exception); + throw; + } + + return host; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (_dbContainer is not null) + { + Environment.SetEnvironmentVariable("USE_SQL_SERVER", "true"); + } + + builder + .ConfigureAppConfiguration((context, config) => + { + if (_dbContainer is not null) + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = _dbContainer.GetConnectionString() + }); + } + }) + .ConfigureServices(services => + { + if (_dbContainer is null) + { + return; + } + + var descriptors = services.Where( + descriptor => + descriptor.ServiceType == typeof(AppDbContext) || + descriptor.ServiceType == typeof(DbContextOptions)) + .ToList(); + + foreach (var descriptor in descriptors) + { + services.Remove(descriptor); + } + + services.AddDbContext((provider, options) => + { + options.UseSqlServer(_dbContainer.GetConnectionString()); + + var interceptor = + provider.GetRequiredService(); + + options.AddInterceptors(interceptor); + }); + }); + } +} diff --git a/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs b/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs index 9e327d8af..0b02688d5 100644 --- a/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs +++ b/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs @@ -8,19 +8,22 @@ public class DockerAvailabilityTests public async Task Docker_ShouldBeRunning_ForFullFunctionalTestCoverage() { var cancellationToken = TestContext.Current.CancellationToken; - try - { - // Ping the Docker daemon directly using the Docker client. - // This has no side effects on container lifecycle or Testcontainers internals. - using var client = new DockerClientConfiguration().CreateClient(); - await client.System.PingAsync(cancellationToken); - } - catch (Exception) + + using var configuration = new DockerClientConfiguration(); + using var client = configuration.CreateClient(); + + var exception = await Record.ExceptionAsync( + () => client.System.PingAsync(cancellationToken)) + .ConfigureAwait(true); + + if (exception is not null) { Assert.Fail( "Docker is not running or is misconfigured. " + - "Functional tests that use SQL Server will fall back to SQLite, which may not catch SQL Server-specific issues. " + - "For full test coverage, please start Docker Desktop (https://www.docker.com/products/docker-desktop/) and re-run the tests."); + "Functional tests that use SQL Server will fall back to SQLite, " + + "which may not catch SQL Server-specific issues. " + + "For full test coverage, please start Docker Desktop and re-run the tests. " + + $"Underlying error: {exception.Message}"); } } } diff --git a/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs b/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs index d5c7b56f1..12e28ded4 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs @@ -3,40 +3,63 @@ namespace Clean.Architecture.IntegrationTests.Data; -public abstract class BaseEfRepoTestFixture +public abstract class BaseEfRepoTestFixture : IDisposable { - protected AppDbContext _dbContext; + private readonly ServiceProvider _serviceProvider; + private readonly AppDbContext _dbContext; + private bool _disposed; + + protected AppDbContext TestDbContext => _dbContext; protected BaseEfRepoTestFixture() { - var options = CreateNewContextOptions(); + var fakeEventDispatcher = Substitute.For(); + + _serviceProvider = new ServiceCollection() + .AddEntityFrameworkInMemoryDatabase() + .AddScoped(_ => fakeEventDispatcher) + .AddScoped() + .BuildServiceProvider(); + + var interceptor = + _serviceProvider.GetRequiredService(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase("cleanarchitecture") + .UseInternalServiceProvider(_serviceProvider) + .AddInterceptors(interceptor) + .Options; + _dbContext = new AppDbContext(options); } - protected static DbContextOptions CreateNewContextOptions() + public void Dispose() { - var fakeEventDispatcher = Substitute.For(); - // Create a fresh service provider, and therefore a fresh - // InMemory database instance. - var serviceProvider = new ServiceCollection() - .AddEntityFrameworkInMemoryDatabase() - .AddScoped(_ => fakeEventDispatcher) - .AddScoped() - .BuildServiceProvider(); - - // Create a new options instance telling the context to use an - // InMemory database and the new service provider. - var interceptor = serviceProvider.GetRequiredService(); - - var builder = new DbContextOptionsBuilder(); - builder.UseInMemoryDatabase("cleanarchitecture") - .UseInternalServiceProvider(serviceProvider) - .AddInterceptors(interceptor); - - return builder.Options; + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _dbContext.Dispose(); + _serviceProvider.Dispose(); + } + + _disposed = true; } + // This is intentionally a factory method because every call creates + // a new repository instance. +#pragma warning disable CA1024 protected EfRepository GetRepository() +#pragma warning restore CA1024 { return new EfRepository(_dbContext); } diff --git a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs index 7d099503a..177484549 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs @@ -16,7 +16,7 @@ public async Task UpdatesItemAfterAddingIt() await repository.AddAsync(Contributor, cancellationToken); // detach the item so we get a different instance - _dbContext.Entry(Contributor).State = EntityState.Detached; + TestDbContext.Entry(Contributor).State = EntityState.Detached; // fetch the item and update its title var newContributor = (await repository.ListAsync(cancellationToken)) diff --git a/tests/Clean.Architecture.UnitTests/NoOpMediator.cs b/tests/Clean.Architecture.UnitTests/NoOpMediator.cs index 2358dab52..8ac024f12 100644 --- a/tests/Clean.Architecture.UnitTests/NoOpMediator.cs +++ b/tests/Clean.Architecture.UnitTests/NoOpMediator.cs @@ -1,59 +1,74 @@ -namespace Clean.Architecture.UnitTests; +namespace Clean.Architecture.UnitTests; public class NoOpMediator : IMediator { - public async Task> CreateStream(IStreamQuery query, CancellationToken cancellationToken = default) + public IAsyncEnumerable CreateStream( + IStreamRequest request, + CancellationToken cancellationToken = default) { - await Task.Delay(1); return AsyncEnumerable.Empty(); } - public IAsyncEnumerable CreateStream(IStreamRequest request, CancellationToken cancellationToken = default) + public IAsyncEnumerable CreateStream( + IStreamCommand command, + CancellationToken cancellationToken = default) { return AsyncEnumerable.Empty(); } - public IAsyncEnumerable CreateStream(IStreamCommand command, CancellationToken cancellationToken = default) - { - return AsyncEnumerable.Empty(); - } - - public IAsyncEnumerable CreateStream(object message, CancellationToken cancellationToken = default) + public IAsyncEnumerable CreateStream( + object message, + CancellationToken cancellationToken = default) { return AsyncEnumerable.Empty(); } - public ValueTask Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification + public ValueTask Publish( + TNotification notification, + CancellationToken cancellationToken = default) + where TNotification : INotification { return ValueTask.CompletedTask; } - public ValueTask Publish(object notification, CancellationToken cancellationToken = default) + public ValueTask Publish( + object notification, + CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; } - public ValueTask Send(IRequest request, CancellationToken cancellationToken = default) + public ValueTask Send( + IRequest request, + CancellationToken cancellationToken = default) { return ValueTask.FromResult(default(TResponse)!); } - public ValueTask Send(ICommand command, CancellationToken cancellationToken = default) + public ValueTask Send( + ICommand command, + CancellationToken cancellationToken = default) { return ValueTask.FromResult(default(TResponse)!); } - public ValueTask Send(IQuery query, CancellationToken cancellationToken = default) + public ValueTask Send( + IQuery query, + CancellationToken cancellationToken = default) { return ValueTask.FromResult(default(TResponse)!); } - public ValueTask Send(object message, CancellationToken cancellationToken = default) + public ValueTask Send( + object message, + CancellationToken cancellationToken = default) { return ValueTask.FromResult(null); } - IAsyncEnumerable ISender.CreateStream(IStreamQuery query, CancellationToken cancellationToken) + IAsyncEnumerable ISender.CreateStream( + IStreamQuery query, + CancellationToken cancellationToken) { return AsyncEnumerable.Empty(); } From 2c9acd740df29daa9dc78ad384f73a889e962b2e Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 20:23:43 +0200 Subject: [PATCH 2/5] Document NU1903 exclusion for vulnerable dependency Added a detailed comment in Directory.Build.props explaining the exclusion of NU1903 from errors. The comment clarifies that the warning is due to a known vulnerable transitive dependency (SQLItePCLRaw.lib.e_sqlit3) required by Microsoft.Data.Sqlite.Core 7.0.0/7.0.1 for .NET 10.0 support, and provides guidance for removing the exclusion once the issue is resolved. No functional changes were made. --- Directory.Build.props | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 7274c43cc..373ac99fd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,13 @@ true true latest-All + $(WarningsNotAsErrors);NU1903 true true From 9fa35affdba39105a269859e0aa2c23b63f3c1ed Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 20:30:09 +0200 Subject: [PATCH 3/5] Clarify comment on NU1903 warning exclusion Updated the comment explaining the exclusion of the NU1903 warning for the vulnerable transitive dependency SQLitePCLRaw.lib.e_sqlite3. The revised comment clarifies that the warning will remain visible but will not fail the build while the issue is being addressed (see issue #1066 and PR #1067). It also notes to remove the exclusion once the dependency is updated or the PR is merged. No functional code changes were made. --- Directory.Build.props | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 373ac99fd..352728b63 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,11 +4,14 @@ true latest-All $(WarningsNotAsErrors);NU1903 true From 7e39f98c60f7e67b9814d9359485b9cc06188c85 Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 20:35:15 +0200 Subject: [PATCH 4/5] Suppress CA1716 for 'Get' namespace keyword conflict Added #pragma warning disable CA1716 and explanatory comments to GetContributorHandler.cs and GetContributorQuery.cs to suppress the analyzer warning about the 'Get' namespace segment conflicting with reserved keywords. Comments clarify that renaming is a breaking change tracked separately. Warnings are restored after the namespace declaration. --- .../Contributors/Get/GetContributorHandler.cs | 5 ++++- .../Contributors/Get/GetContributorQuery.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs index 2df6e1991..e2700288e 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs @@ -1,6 +1,9 @@ using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.Core.ContributorAggregate.Specifications; - +// CA1716 flags the namespace segment "Get" because it conflicts with a +// reserved keyword in some .NET languages. Renaming the namespace changes +// the public namespace and requires all references to be updated. +// The permanent fix is tracked separately in issue #1065. #pragma warning disable CA1716 // Preserve the established public namespace. namespace Clean.Architecture.UseCases.Contributors.Get; #pragma warning restore CA1716 diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs index 3ed453f72..27afec75a 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs @@ -1,5 +1,8 @@ using Clean.Architecture.Core.ContributorAggregate; - +// CA1716 flags the namespace segment "Get" because it conflicts with a +// reserved keyword in some .NET languages. Renaming the namespace changes +// the public namespace and requires all references to be updated. +// The permanent fix is tracked separately in issue #1065. #pragma warning disable CA1716 // Preserve the established public namespace. namespace Clean.Architecture.UseCases.Contributors.Get; #pragma warning restore CA1716 From 1abc76df9db009c8fd232744605c3efe19e6b1a2 Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 20:37:48 +0200 Subject: [PATCH 5/5] Suppress CA1716 for "Get" namespace; add explanation Added a comment explaining CA1716 is triggered by the "Get" namespace segment due to reserved keyword conflicts in some .NET languages. Included a pragma directive to disable the warning, preserving the public namespace. Referenced issue #1065 for a permanent fix. --- .../Contributors/Get/GetContributorQuery.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs index 27afec75a..bd34dda88 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs @@ -1,4 +1,5 @@ using Clean.Architecture.Core.ContributorAggregate; + // CA1716 flags the namespace segment "Get" because it conflicts with a // reserved keyword in some .NET languages. Renaming the namespace changes // the public namespace and requires all references to be updated.