diff --git a/Directory.Packages.props b/Directory.Packages.props index bcf0c597..d61d13b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,86 +3,89 @@ true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs b/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs index 2c5ef79b..f0d3af6d 100644 --- a/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs +++ b/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs @@ -23,7 +23,7 @@ public static async Task UseDatabaseAsync(this WebApplication app) await EnsureSchemaAsync(sp); await EnsureSchemaAsync(sp); await EnsureSchemaAsync(sp); - await EnsureSchemaAsync(sp); + await EnsureSchemaIfRegisteredAsync(sp); AuthBootstrapSeeder seeder = sp.GetRequiredService(); await seeder.SeedAsync(); @@ -33,7 +33,25 @@ private static async Task EnsureSchemaAsync(IServiceProvider sp) where TContext : DbContext { TContext context = sp.GetRequiredService(); + await EnsureSchemaCoreAsync(context); + } + + /// + /// TickerQ (and its ) is only registered when + /// BackgroundJobs:TickerQ:Enabled is true and Dragonfly is configured. + /// + private static async Task EnsureSchemaIfRegisteredAsync(IServiceProvider sp) + where TContext : DbContext + { + TContext? context = sp.GetService(); + if (context is null) + return; + await EnsureSchemaCoreAsync(context); + } + + private static async Task EnsureSchemaCoreAsync(DbContext context) + { await context.Database.EnsureCreatedAsync(); IRelationalDatabaseCreator creator = context.GetService(); diff --git a/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs b/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs index e299f7c6..0e53332e 100644 --- a/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs +++ b/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs @@ -42,12 +42,13 @@ private TenantIdentity GetTenantIdentity() private Task FindTenantAsync(string tenantCode, CancellationToken ct) { + TenantCode code = TenantCode.FromPersistence(tenantCode); return _dbContext .Tenants.IgnoreQueryFilters([ GlobalQueryFilterNames.SoftDelete, GlobalQueryFilterNames.Tenant, ]) - .FirstOrDefaultAsync(t => t.Code.Value == tenantCode, ct); + .FirstOrDefaultAsync(t => t.Code == code, ct); } private bool CreateTenant(TenantIdentity tenantIdentity) diff --git a/src/Modules/Notifications/Notifications.csproj b/src/Modules/Notifications/Notifications.csproj index 146a4142..029f137e 100644 --- a/src/Modules/Notifications/Notifications.csproj +++ b/src/Modules/Notifications/Notifications.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/src/Modules/Webhooks/Webhooks.csproj b/src/Modules/Webhooks/Webhooks.csproj index 63c7d36b..7f665bb5 100644 --- a/src/Modules/Webhooks/Webhooks.csproj +++ b/src/Modules/Webhooks/Webhooks.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj index 7e8bd02b..cd9d86ed 100644 --- a/tests/APITemplate.Tests/APITemplate.Tests.csproj +++ b/tests/APITemplate.Tests/APITemplate.Tests.csproj @@ -4,45 +4,78 @@ enable enable false - false + + Category!=Integration.Docker - - + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + + - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + diff --git a/tests/APITemplate.Tests/AssemblyFixtures.cs b/tests/APITemplate.Tests/AssemblyFixtures.cs deleted file mode 100644 index 2e53fe53..00000000 --- a/tests/APITemplate.Tests/AssemblyFixtures.cs +++ /dev/null @@ -1,4 +0,0 @@ -using APITemplate.Tests.Integration.Postgres; -using Xunit; - -[assembly: AssemblyFixture(typeof(SharedPostgresContainer))] diff --git a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs index 8ee77cb1..0aa3a698 100644 --- a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs +++ b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs @@ -1,70 +1,82 @@ -using APITemplate.Infrastructure.Persistence; using APITemplate.Tests.Integration.Helpers; +using JasperFx.CommandLine; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; +using Testcontainers.MongoDb; +using Testcontainers.PostgreSql; using Xunit; namespace APITemplate.Tests.Integration; -public class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +/// +/// Hosts the modular API with real PostgreSQL and MongoDB via Testcontainers. Requires a local Docker engine. +/// Containers are created in so fixture construction does not probe Docker. +/// +public sealed class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { - private readonly string _dbName = Guid.NewGuid().ToString(); + private PostgreSqlContainer? _postgres; + private MongoDbContainer? _mongo; - // Pre-warm the server before parallel test classes start calling CreateClient(). - // Without this, concurrent constructors race to call StartServer(), causing duplicate - // InMemory DB seed operations ("An item with the same key has already been added"). - public ValueTask InitializeAsync() + public async ValueTask InitializeAsync() { + // Required so Program.cs can complete RunJasperFxCommands under WebApplicationFactory (see JasperFx docs). + JasperFxEnvironment.AutoStartHost = true; + + _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithUsername("postgres") + .WithPassword("postgres") + .WithCleanUp(true) + .Build(); + + _mongo = new MongoDbBuilder("mongo:7").WithCleanUp(true).Build(); + + await Task.WhenAll(_postgres.StartAsync(), _mongo.StartAsync()); + + // Pre-warm the host before parallel test classes race on StartServer(). _ = Server; - return ValueTask.CompletedTask; } - // DisposeAsync() is satisfied by WebApplicationFactory's IAsyncDisposable implementation. + public new async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (_mongo is not null) + await _mongo.DisposeAsync(); + if (_postgres is not null) + await _postgres.DisposeAsync(); + } protected override void ConfigureWebHost(IWebHostBuilder builder) { + PostgreSqlContainer postgres = RequireStarted(_postgres, "PostgreSQL"); + MongoDbContainer mongo = RequireStarted(_mongo, "MongoDB"); + + string pg = postgres.GetConnectionString(); + string mongoConnectionString = mongo.GetConnectionString(); + + // Host settings are merged so WebApplication.CreateBuilder sees these before reading + // ConnectionStrings:DefaultConnection (see Program.cs) — in-memory config alone can lose to appsettings. + builder.UseSetting("ConnectionStrings:DefaultConnection", pg); + builder.UseSetting("MongoDB:ConnectionString", mongoConnectionString); + builder.UseSetting("MongoDB:DatabaseName", "apitemplate_integration"); + builder.ConfigureAppConfiguration( (_, configBuilder) => { - var config = TestConfigurationHelper.GetBaseConfiguration(); + Dictionary config = TestConfigurationHelper.GetBaseConfiguration(); + config.Remove("ConnectionStrings:DefaultConnection"); + config.Remove("MongoDB:ConnectionString"); + config.Remove("MongoDB:DatabaseName"); configBuilder.AddInMemoryCollection(config); } ); builder.ConfigureTestServices(services => { - services.RemoveAll(typeof(DbContextOptions)); - services.RemoveAll(typeof(AppDbContext)); - - var optionsConfigs = services - .Where(d => - d.ServiceType.IsGenericType - && d.ServiceType.GetGenericTypeDefinition() - .FullName?.Contains("IDbContextOptionsConfiguration") == true - ) - .ToList(); - - foreach (var d in optionsConfigs) - services.Remove(d); - - services.AddDbContext(options => - options - .UseInMemoryDatabase(_dbName) - .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) - ); - - TestServiceHelper.MockMongoServices(services); TestServiceHelper.RemoveExternalHealthChecks(services); - TestServiceHelper.ReplaceProductRepositoryWithInMemory(services); TestServiceHelper.ReplaceOutputCacheWithInMemory(services); TestServiceHelper.ReplaceDataProtectionWithInMemory(services); - TestServiceHelper.ReplaceTicketStoreWithInMemory(services); TestServiceHelper.ConfigureTestAuthentication(services); TestServiceHelper.RemoveTickerQRuntimeServices(services); TestServiceHelper.ReplaceStartupCoordinationWithNoOp(services); @@ -72,4 +84,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseEnvironment("Development"); } + + private static T RequireStarted(T? container, string label) + where T : class => + container + ?? throw new InvalidOperationException( + $"{label} container was not started; IAsyncLifetime.InitializeAsync must run first." + ); } diff --git a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs index e6d7eb90..898ce385 100644 --- a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs @@ -1,20 +1,27 @@ using System.Net; using System.Text; -using APITemplate.Application.Features.Examples.DTOs; using APITemplate.Tests.Integration.Helpers; +using APITemplate.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Mvc.Testing; using Shouldly; +using Webhooks.Contracts; using Xunit; namespace APITemplate.Tests.Integration.Features; -public class WebhooksControllerTests : IClassFixture +[Trait("Category", "Integration.Docker")] +public sealed class WebhooksControllerTests : IClassFixture { - private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + private HttpClient? _client; - public WebhooksControllerTests(CustomWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + /// + /// Lazy client: test class ctor can run before the fixture's , + /// so we must not call in the ctor. + /// + private HttpClient Client => _client ??= _factory.CreateClient(); + + public WebhooksControllerTests(CustomWebApplicationFactory factory) => _factory = factory; [Fact] public async Task Receive_ValidSignature_Returns200() @@ -35,7 +42,7 @@ public async Task Receive_ValidSignature_Returns200() request.Headers.Add(WebhookConstants.SignatureHeader, signature); request.Headers.Add(WebhookConstants.TimestampHeader, timestamp); - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); } @@ -53,7 +60,7 @@ public async Task Receive_InvalidSignature_Returns401() request.Headers.Add(WebhookConstants.SignatureHeader, "wrong-signature"); request.Headers.Add(WebhookConstants.TimestampHeader, timestamp); - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } @@ -76,7 +83,7 @@ public async Task Receive_ExpiredTimestamp_Returns401() request.Headers.Add(WebhookConstants.SignatureHeader, signature); request.Headers.Add(WebhookConstants.TimestampHeader, expiredTimestamp); - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } @@ -90,9 +97,8 @@ public async Task Receive_MissingHeaders_Returns401() { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; - // No signature or timestamp headers - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } } diff --git a/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs index 589a99d3..a32676c8 100644 --- a/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs +++ b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs @@ -1,24 +1,18 @@ -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using APITemplate.Infrastructure.Health; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Persistence.Startup; -using APITemplate.Infrastructure.Security; -using APITemplate.Tests.Helpers; +using BackgroundJobs.TickerQ; +using Identity.Security; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; -using Moq; +using SharedKernel.Application.BackgroundJobs; +using SharedKernel.Application.Options.Infrastructure; using SharedKernel.Application.Startup; +using SharedKernel.Infrastructure.Health; using StackExchange.Redis; namespace APITemplate.Tests.Integration.Helpers; @@ -74,15 +68,16 @@ internal static void RemoveExternalHealthChecks(IServiceCollection services) services.Configure( options => { - var toRemove = options - .Registrations.Where(r => - r.Name - is HealthCheckNames.MongoDb - or HealthCheckNames.Keycloak - or HealthCheckNames.PostgreSql - or HealthCheckNames.Dragonfly - ) - .ToList(); + List toRemove = + options + .Registrations.Where(r => + r.Name + is HealthCheckNames.MongoDb + or HealthCheckNames.Keycloak + or HealthCheckNames.PostgreSql + or HealthCheckNames.Dragonfly + ) + .ToList(); foreach (var r in toRemove) options.Registrations.Remove(r); } @@ -91,8 +86,6 @@ or HealthCheckNames.Dragonfly internal static void ReplaceOutputCacheWithInMemory(IServiceCollection services) { - // Remove DragonFly-backed cache services so tests use in-memory implementations - // and observability startup does not try to connect to a real Redis instance. services.RemoveAll(); services.RemoveAll(); services.AddOutputCache(); @@ -104,51 +97,27 @@ internal static void ReplaceOutputCacheWithInMemory(IServiceCollection services) internal static void ReplaceDataProtectionWithInMemory(IServiceCollection services) { - // Replace DragonFly-backed DataProtection with EphemeralDataProtectionProvider (no key persistence). services.RemoveAll(); services.AddSingleton(); } - internal static void ReplaceTicketStoreWithInMemory(IServiceCollection services) - { - // Replace Redis-backed IDistributedCache with in-memory so DragonflyTicketStore - // works without a real DragonFly instance in tests. - services.RemoveAll(); - services.AddDistributedMemoryCache(); - services.RemoveAll(); - services.AddSingleton(); - } - - internal static void MockMongoServices(IServiceCollection services) - { - services.RemoveAll(typeof(MongoDbContext)); - services.RemoveAll(typeof(IProductDataRepository)); - var mock = new Mock(); - services.AddSingleton(mock); - services.AddSingleton(mock.Object); - } - - internal static void ReplaceProductRepositoryWithInMemory(IServiceCollection services) - { - services.RemoveAll(typeof(IProductRepository)); - services.AddScoped(); - } - internal static void RemoveTickerQRuntimeServices(IServiceCollection services) { - var runtimeDescriptors = services.Where(IsTickerQRuntimeDescriptor).ToList(); - foreach (var descriptor in runtimeDescriptors) - { + List runtimeDescriptors = services + .Where(IsTickerQRuntimeDescriptor) + .ToList(); + foreach (ServiceDescriptor descriptor in runtimeDescriptors) services.Remove(descriptor); - } } internal static void ReplaceStartupCoordinationWithNoOp(IServiceCollection services) { services.RemoveAll(); - services.RemoveAll(); - services.AddScoped(); - services.AddScoped(); + services.RemoveAll(); + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService() + ); } private static bool IsTickerQRuntimeDescriptor(ServiceDescriptor descriptor) => @@ -160,8 +129,7 @@ private static bool IsTickerQRuntimeDescriptor(ServiceDescriptor descriptor) => || descriptor.ServiceType == typeof(TickerQRecurringJobRegistrar) || descriptor.ServiceType == typeof(IDistributedJobCoordinator) || ( - descriptor.ServiceType - == typeof(Application.Common.BackgroundJobs.IRecurringBackgroundJobRegistration) + descriptor.ServiceType == typeof(IRecurringBackgroundJobRegistration) && IsTickerQRuntimeType(descriptor.ImplementationType) ); @@ -178,22 +146,16 @@ implementationInstance is not null private static bool IsTickerQRuntimeType(Type? type) { if (type is null) - { return false; - } if ( IsTickerQRuntimeNamespace(type.Namespace) || IsTickerQRuntimeAssembly(type.Assembly.GetName().Name) ) - { return true; - } if (!type.IsGenericType) - { return false; - } return type.GetGenericArguments().Any(IsTickerQRuntimeType); } diff --git a/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs deleted file mode 100644 index 27c2a3ca..00000000 --- a/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace APITemplate.Tests.Integration.Helpers; - -internal static class WebhookTestHelper -{ - internal static string ComputeHmacSignature(string body, string timestamp, string secret) - { - var signedContent = $"{timestamp}.{body}"; - var keyBytes = Encoding.UTF8.GetBytes(secret); - var contentBytes = Encoding.UTF8.GetBytes(signedContent); - var hashBytes = HMACSHA256.HashData(keyBytes, contentBytes); - return Convert.ToHexStringLower(hashBytes); - } -} diff --git a/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs b/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs index 0d3c6712..e750a742 100644 --- a/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs +++ b/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs @@ -2,10 +2,11 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Security.Cryptography; -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using APITemplate.Infrastructure.Persistence; +using Identity.Entities; +using Identity.Enums; +using Identity.Persistence; +using Identity.Security; +using Identity.ValueObjects; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -17,7 +18,7 @@ internal static class IntegrationAuthHelper internal static readonly RsaSecurityKey SecurityKey = new(RsaKey); - private static readonly SigningCredentials SigningCredentials = new( + private static readonly SigningCredentials _signingCredentials = new( SecurityKey, SecurityAlgorithms.RsaSha256 ); @@ -46,7 +47,7 @@ public static string CreateTestToken( audience: "api-template", claims: claims, expires: DateTime.UtcNow.AddHours(1), - signingCredentials: SigningCredentials + signingCredentials: _signingCredentials ); return new JwtSecurityTokenHandler().WriteToken(token); @@ -60,7 +61,7 @@ public static void Authenticate( UserRole role = UserRole.PlatformAdmin ) { - var token = CreateTestToken(userId, tenantId, username, role); + string token = CreateTestToken(userId, tenantId, username, role); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } @@ -89,6 +90,9 @@ public static void AuthenticateAsTenantAdmin( ) => Authenticate(client, userId, tenantId, username: "tenantadmin", role: UserRole.TenantAdmin); + /// + /// Seeds an additional tenant and user for tests that need data beyond the bootstrap tenant. + /// public static async Task<(Tenant Tenant, AppUser User)> SeedTenantUserAsync( IServiceProvider services, string username, @@ -98,25 +102,31 @@ public static void AuthenticateAsTenantAdmin( CancellationToken ct = default ) { - await using var scope = services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + await using AsyncServiceScope scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + string tenantCodeValue = $"t{Guid.NewGuid():N}"[..12]; + TenantCode tenantCode = TenantCode.FromPersistence(tenantCodeValue); + var tenantId = Guid.NewGuid(); var tenant = new Tenant { - Id = Guid.NewGuid(), + Id = tenantId, TenantId = Guid.Empty, - Code = $"tenant-{Guid.NewGuid():N}", + Code = tenantCode, Name = $"Tenant {username}", - IsActive = tenantIsActive, }; + if (!tenantIsActive) + tenant.Deactivate(); + + Email emailVo = Email.FromPersistence(email); var user = new AppUser { Id = Guid.NewGuid(), - TenantId = tenant.Id, - KeycloakUserId = $"kc-{Guid.NewGuid():N}", + TenantId = tenantId, Username = username, - Email = email, + Email = emailVo, + KeycloakUserId = $"kc-{Guid.NewGuid():N}", IsActive = userIsActive, Role = UserRole.User, }; diff --git a/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs index 9b9989d7..b719efbd 100644 --- a/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs @@ -1,5 +1,6 @@ -using APITemplate.Infrastructure.BackgroundJobs.Validation; -using SharedKernel.Application.Options; +using BackgroundJobs.Validation; +using Microsoft.Extensions.Options; +using SharedKernel.Application.Options.BackgroundJobs; using Shouldly; using Xunit; @@ -7,57 +8,73 @@ namespace APITemplate.Tests.Unit.BackgroundJobs; public sealed class BackgroundJobsOptionsValidatorTests { + private readonly BackgroundJobsOptionsValidator _sut = new(); + [Fact] - public void Validate_ReturnsSuccess_WhenDisabledJobsHaveInvalidValues() + public void Validate_WhenTickerQEnabledAndMissingPrefix_Fails() { - var sut = new BackgroundJobsOptionsValidator(); - var options = new BackgroundJobsOptions + BackgroundJobsOptions options = new() { - Cleanup = new CleanupJobOptions - { - Enabled = false, - Cron = "", - BatchSize = 0, - ExpiredInvitationRetentionHours = -1, - SoftDeleteRetentionDays = -1, - OrphanedProductDataRetentionDays = -1, - }, - Reindex = new ReindexJobOptions { Enabled = false, Cron = "" }, - ExternalSync = new ExternalSyncJobOptions { Enabled = false, Cron = "" }, - EmailRetry = new EmailRetryJobOptions - { - Enabled = false, - Cron = "", - BatchSize = 0, - MaxRetryAttempts = 0, - ClaimLeaseMinutes = 0, - DeadLetterAfterHours = -1, - }, + TickerQ = new TickerQSchedulerOptions { Enabled = true, InstanceNamePrefix = " " }, }; - var result = sut.Validate(name: null, options); + ValidateOptionsResult result = _sut.Validate(null, options); - result.Succeeded.ShouldBeTrue(); + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("InstanceNamePrefix"); + } + + [Theory] + [InlineData("not valid cron")] + [InlineData("* * *")] + public void Validate_WhenCleanupEnabledAndCronInvalid_Fails(string cron) + { + BackgroundJobsOptions options = new() + { + Cleanup = new CleanupJobOptions { Enabled = true, Cron = cron }, + }; + + ValidateOptionsResult result = _sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("CRON"); + } + + [Theory] + [InlineData(-1)] + [InlineData(-3)] + public void Validate_WhenCleanupEnabledAndNegativeRetention_Fails(int days) + { + BackgroundJobsOptions options = new() + { + Cleanup = new CleanupJobOptions { Enabled = true, SoftDeleteRetentionDays = days }, + }; + + ValidateOptionsResult result = _sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); } [Fact] - public void Validate_ReturnsFailure_WhenEnabledCleanupHasInvalidValues() + public void Validate_WhenEmailRetryEnabledAndBatchSizeZero_Fails() { - var sut = new BackgroundJobsOptionsValidator(); - var options = new BackgroundJobsOptions + BackgroundJobsOptions options = new() { - Cleanup = new CleanupJobOptions - { - Enabled = true, - Cron = "", - BatchSize = 0, - }, + EmailRetry = new EmailRetryJobOptions { Enabled = true, BatchSize = 0 }, }; - var result = sut.Validate(name: null, options); + ValidateOptionsResult result = _sut.Validate(null, options); result.Failed.ShouldBeTrue(); - result.Failures.ShouldContain("BackgroundJobs:Cleanup:Cron is required."); - result.Failures.ShouldContain("BackgroundJobs:Cleanup:BatchSize must be greater than 0."); + } + + [Fact] + public void Validate_WhenDefaultsAndJobsDisabled_Succeeds() + { + BackgroundJobsOptions options = new(); + + ValidateOptionsResult result = _sut.Validate(null, options); + + result.Succeeded.ShouldBeTrue(); } } diff --git a/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs index 4a845a45..4aa85a27 100644 --- a/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs +++ b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs @@ -1,4 +1,5 @@ using APITemplate.Api.Cache; +using APITemplate.Tests.Unit.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OutputCaching; using Shouldly; @@ -62,6 +63,7 @@ private static OutputCacheContext CreateContext(string method = "GET") var httpContext = new DefaultHttpContext(); httpContext.Request.Method = method; httpContext.Request.Path = "/api/v1/products"; + httpContext.User = TestClaimsPrincipalFactory.WithTenantId(Guid.NewGuid().ToString()); return new OutputCacheContext { HttpContext = httpContext }; } } diff --git a/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs new file mode 100644 index 00000000..dd3d4d38 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using APITemplate.Tests.Unit.Helpers; +using Chatting.Features.GetNotificationStream; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Chatting; + +public sealed class SseStreamRequestValidationTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(101)] + public void Validation_WhenCountOutOfRange_Fails(int count) + { + SseStreamRequest request = new() { Count = count }; + bool valid = DataAnnotationsTestHelper.TryValidateAllProperties( + request, + out List results + ); + + valid.ShouldBeFalse(); + results.ShouldNotBeEmpty(); + } + + [Theory] + [InlineData(1)] + [InlineData(50)] + [InlineData(100)] + public void Validation_WhenCountInRange_Passes(int count) + { + SseStreamRequest request = new() { Count = count }; + bool valid = DataAnnotationsTestHelper.TryValidateAllProperties(request, out _); + + valid.ShouldBeTrue(); + } +} diff --git a/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs b/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs new file mode 100644 index 00000000..35aba4f9 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs @@ -0,0 +1,100 @@ +using FileStorage.Contracts; +using FileStorage.Services; +using Microsoft.Extensions.Options; +using Moq; +using SharedKernel.Application.Context; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.FileStorage; + +public sealed class LocalFileStorageServiceTests : IDisposable +{ + private readonly string _basePath = Path.Combine( + Path.GetTempPath(), + "fs-u-" + Guid.NewGuid().ToString("N") + ); + private bool _disposed; + + public LocalFileStorageServiceTests() + { + Directory.CreateDirectory(_basePath); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch + { + // best-effort cleanup for parallel CI + } + } + + [Fact] + public async Task SaveAsync_WritesFileUnderTenantDirectory() + { + Guid tenantId = Guid.NewGuid(); + Mock tenant = new(); + tenant.Setup(t => t.TenantId).Returns(tenantId); + + LocalFileStorageService sut = CreateSut(tenant.Object); + await using MemoryStream input = new("hello"u8.ToArray()); + + FileStorageResult result = await sut.SaveAsync( + input, + "doc.pdf", + TestContext.Current.CancellationToken + ); + + result.SizeBytes.ShouldBe(5); + File.Exists(result.StoragePath).ShouldBeTrue(); + result.StoragePath.ShouldStartWith(Path.Combine(_basePath, tenantId.ToString())); + } + + [Fact] + public async Task OpenReadAsync_WhenMissing_ReturnsNull() + { + LocalFileStorageService sut = CreateSut(CreateTenant()); + + Stream? stream = await sut.OpenReadAsync( + Path.Combine(_basePath, Guid.NewGuid().ToString(), "missing.bin"), + TestContext.Current.CancellationToken + ); + + stream.ShouldBeNull(); + } + + [Fact] + public async Task OpenReadAsync_WhenOutsideBasePath_Throws() + { + LocalFileStorageService sut = CreateSut(CreateTenant()); + + string outside = Path.GetFullPath(Path.Combine(_basePath, "..", "outside-test.txt")); + + await Should.ThrowAsync(() => + sut.OpenReadAsync(outside, TestContext.Current.CancellationToken) + ); + } + + private static ITenantProvider CreateTenant() + { + Mock tenant = new(); + tenant.Setup(t => t.TenantId).Returns(Guid.NewGuid()); + return tenant.Object; + } + + private LocalFileStorageService CreateSut(ITenantProvider tenant) + { + IOptions options = Options.Create( + new FileStorageOptions { BasePath = _basePath } + ); + return new LocalFileStorageService(options, tenant); + } +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs b/tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs new file mode 100644 index 00000000..a98d4fbb --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace APITemplate.Tests.Unit.Helpers; + +/// +/// Shared DataAnnotations validation for tests (property-level Validator.TryValidateObject, aligned with +/// DataAnnotationsValidator<T> in SharedKernel). +/// +internal static class DataAnnotationsTestHelper +{ + internal static bool TryValidateAllProperties( + object instance, + out List results + ) + { + results = []; + return Validator.TryValidateObject( + instance, + new ValidationContext(instance), + results, + validateAllProperties: true + ); + } +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs b/tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs new file mode 100644 index 00000000..400e2541 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs @@ -0,0 +1,19 @@ +namespace APITemplate.Tests.Unit.Helpers; + +/// Fixed UTC clock for tests that only need a stable . +internal sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => utcNow; +} + +/// UTC clock that tests can advance (e.g. TTL / expiry scenarios). +internal sealed class MutableFakeTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + public MutableFakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs b/tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs new file mode 100644 index 00000000..bfa093bb --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using Identity.Security; + +namespace APITemplate.Tests.Unit.Helpers; + +internal static class TestClaimsPrincipalFactory +{ + internal const string TestAuthenticationType = "Test"; + + internal static ClaimsPrincipal WithTenantId(string tenantId) => + new( + new ClaimsIdentity( + [new Claim(AuthConstants.Claims.TenantId, tenantId)], + authenticationType: TestAuthenticationType + ) + ); + + internal static ClaimsPrincipal WithOptionalTenantClaim(string? tenantValue) + { + Claim[] claims = tenantValue is null + ? [] + : [new Claim(AuthConstants.Claims.TenantId, tenantValue)]; + return new ClaimsPrincipal( + new ClaimsIdentity(claims, authenticationType: TestAuthenticationType) + ); + } +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs b/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs new file mode 100644 index 00000000..b62970b9 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs @@ -0,0 +1,12 @@ +using Webhooks.Security; + +namespace APITemplate.Tests.Unit.Helpers; + +internal static class WebhookTestHelper +{ + internal static string ComputeHmacSignature(string body, string timestamp, string secret) + { + byte[] hashBytes = HmacHelper.ComputeHash(HmacHelper.GetKeyBytes(secret), timestamp, body); + return Convert.ToHexStringLower(hashBytes); + } +} diff --git a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs index 123c8e5e..7f65cd37 100644 --- a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -1,3 +1,4 @@ +using APITemplate.Tests.Unit.Helpers; using SharedKernel.Application.Contracts; using SharedKernel.Infrastructure.Idempotency; using Shouldly; @@ -5,18 +6,37 @@ namespace APITemplate.Tests.Unit.Idempotency; +/// +/// Covers in-process idempotency. depends on Redis Lua scripts; +/// add container-backed integration tests if multi-instance lock semantics need automated coverage. +/// public sealed class InMemoryIdempotencyStoreTests { private static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(5); - [Fact] - public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken() + [Theory] + [InlineData("key-1")] + [InlineData("tenant:scope:123")] + [InlineData("")] + public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken(string key) { - CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); - InMemoryIdempotencyStore store = new(time); + (InMemoryIdempotencyStore store, CancellationToken ct) = CreateStore(); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); + string? token = await store.TryAcquireAsync(key, DefaultTtl, ct); + + token.ShouldNotBeNull(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3600000)] + public async Task TryAcquireAsync_VariousTtlsStillAcquires(int ttlMs) + { + (InMemoryIdempotencyStore store, CancellationToken ct) = CreateStore(); + string key = Guid.NewGuid().ToString("N"); + + string? token = await store.TryAcquireAsync(key, TimeSpan.FromMilliseconds(ttlMs), ct); token.ShouldNotBeNull(); } @@ -25,7 +45,7 @@ public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken() public async Task TryAcquireAsync_WhenLockAlreadyHeld_ReturnsNull() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? first = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -39,7 +59,7 @@ public async Task TryAcquireAsync_WhenLockAlreadyHeld_ReturnsNull() public async Task TryAcquireAsync_WhenCachedResultExists_ReturnsNull() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -58,7 +78,7 @@ public async Task TryAcquireAsync_WhenCachedResultExists_ReturnsNull() public async Task TryAcquireAsync_WhenCachedResultExpired_ReturnsNewToken() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -79,7 +99,7 @@ public async Task TryAcquireAsync_WhenCachedResultExpired_ReturnsNewToken() public async Task ReleaseAsync_WithCorrectToken_ReleasesLock() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -95,7 +115,7 @@ public async Task ReleaseAsync_WithCorrectToken_ReleasesLock() public async Task ReleaseAsync_WithWrongToken_DoesNotReleaseLock() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -111,7 +131,7 @@ public async Task ReleaseAsync_WithWrongToken_DoesNotReleaseLock() public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); IdempotencyCacheEntry entry = new( @@ -135,7 +155,7 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() public async Task TryGetAsync_WhenExpired_ReturnsNull() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); IdempotencyCacheEntry entry = new(200, "{}", "application/json"); @@ -147,18 +167,10 @@ public async Task TryGetAsync_WhenExpired_ReturnsNull() cached.ShouldBeNull(); } - private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider + private static (InMemoryIdempotencyStore Store, CancellationToken Ct) CreateStore() { - private DateTimeOffset _utcNow = utcNow; - - public override DateTimeOffset GetUtcNow() - { - return _utcNow; - } - - public void Advance(TimeSpan duration) - { - _utcNow = _utcNow.Add(duration); - } + CancellationToken ct = TestContext.Current.CancellationToken; + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); + return (new InMemoryIdempotencyStore(time), ct); } } diff --git a/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs new file mode 100644 index 00000000..f49631f6 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs @@ -0,0 +1,54 @@ +using APITemplate.Tests.Unit.TestData; +using ErrorOr; +using Identity.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Identity; + +public sealed class EmailValueObjectTests +{ + [Theory] + [MemberData(nameof(EmailTheoryData.InvalidRawInputs), MemberType = typeof(EmailTheoryData))] + public void Create_WhenInvalid_ReturnsError(string? raw) + { + ErrorOr result = Email.Create(raw!); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [MemberData( + nameof(EmailTheoryData.TrimmingAndNormalizationCases), + MemberType = typeof(EmailTheoryData) + )] + public void Create_WhenValid_TrimsInput(string raw, string expectedValue) + { + ErrorOr result = Email.Create(raw); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(expectedValue); + } + + [Fact] + public void Create_WhenValid_CanRoundTripImplicitlyToString() + { + ErrorOr result = Email.Create("person@domain.example"); + + ((string)result.Value).ShouldBe("person@domain.example"); + } + + [Fact] + public void NormalizeRaw_TrimsAndUppercases() + { + Email.NormalizeRaw(" a@b.co ").ShouldBe("A@B.CO"); + } + + [Fact] + public void Normalize_OnEmail_UppercasesValue() + { + Email email = Email.Create("a@b.co").Value; + + email.Normalize().ShouldBe("A@B.CO"); + } +} diff --git a/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs b/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs new file mode 100644 index 00000000..9000331c --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs @@ -0,0 +1,36 @@ +using APITemplate.Tests.Unit.Helpers; +using Identity.Security; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Identity; + +public sealed class TenantClaimValidatorTests +{ + [Theory] + [InlineData("00000000-0000-0000-0000-000000000000")] + [InlineData("not-a-guid")] + [InlineData("")] + public void HasValidTenantClaim_WhenMissingOrInvalid_ReturnsFalse(string? tenantValue) + { + TenantClaimValidator + .HasValidTenantClaim(TestClaimsPrincipalFactory.WithOptionalTenantClaim(tenantValue)) + .ShouldBeFalse(); + } + + [Fact] + public void HasValidTenantClaim_WhenNonEmptyGuid_ReturnsTrue() + { + string tenantId = Guid.NewGuid().ToString(); + + TenantClaimValidator + .HasValidTenantClaim(TestClaimsPrincipalFactory.WithTenantId(tenantId)) + .ShouldBeTrue(); + } + + [Fact] + public void HasValidTenantClaim_WhenPrincipalNull_ReturnsFalse() + { + TenantClaimValidator.HasValidTenantClaim(null).ShouldBeFalse(); + } +} diff --git a/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs new file mode 100644 index 00000000..5175a49e --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs @@ -0,0 +1,54 @@ +using ErrorOr; +using Identity.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Identity; + +public sealed class TenantCodeValueObjectTests +{ + [Fact] + public void Create_WhenNull_ReturnsError() + { + ErrorOr result = TenantCode.Create(null!); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Create_WhenEmptyOrWhitespace_ReturnsError(string raw) + { + ErrorOr result = TenantCode.Create(raw); + + result.IsError.ShouldBeTrue(); + } + + [Fact] + public void Create_WhenLongerThan100_ReturnsError() + { + ErrorOr result = TenantCode.Create(new string('x', 101)); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [InlineData("acme", "acme")] + [InlineData(" acme ", "acme")] + public void Create_WhenValid_TrimsAndPreservesValue(string raw, string expected) + { + ErrorOr result = TenantCode.Create(raw); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(expected); + } + + [Fact] + public void FromPersistence_BypassesValidation() + { + TenantCode code = TenantCode.FromPersistence("any"); + + code.Value.ShouldBe("any"); + } +} diff --git a/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs b/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs index c7176746..fe78b196 100644 --- a/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs +++ b/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs @@ -1,5 +1,5 @@ -using APITemplate.Infrastructure.Logging; using SharedKernel.Application.Options.Security; +using SharedKernel.Infrastructure.Logging; using Shouldly; using Xunit; diff --git a/tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs b/tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs new file mode 100644 index 00000000..ef373715 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs @@ -0,0 +1,35 @@ +using Notifications.Domain; +using Notifications.Services; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Notifications; + +public sealed class FailedEmailErrorNormalizerTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + public void Normalize_WhenNullOrEmpty_ReturnsSame(string? error) + { + FailedEmailErrorNormalizer.Normalize(error).ShouldBe(error); + } + + [Fact] + public void Normalize_WhenShorterThanMax_ReturnsUnchanged() + { + string msg = new string('e', 100); + + FailedEmailErrorNormalizer.Normalize(msg).ShouldBe(msg); + } + + [Fact] + public void Normalize_WhenLongerThanMax_TruncatesToMaxLength() + { + string msg = new string('x', FailedEmail.LastErrorMaxLength + 50); + + string? normalized = FailedEmailErrorNormalizer.Normalize(msg); + + normalized!.Length.ShouldBe(FailedEmail.LastErrorMaxLength); + } +} diff --git a/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs b/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs new file mode 100644 index 00000000..2517f634 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs @@ -0,0 +1,43 @@ +using Notifications.Contracts; +using Notifications.Services; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Notifications; + +public sealed class FluidEmailTemplateRendererTests +{ + private static readonly string UnknownTemplateId = + $"{EmailTemplateNames.UserRegistration}.fixture-unknown-template"; + + private readonly FluidEmailTemplateRenderer _sut = new(); + + [Fact] + public async Task RenderAsync_UserRegistration_SubstitutesModel() + { + var model = new + { + Username = "Ada", + Email = "ada@example.com", + LoginUrl = "https://app/login", + }; + + string html = await _sut.RenderAsync( + EmailTemplateNames.UserRegistration, + model, + TestContext.Current.CancellationToken + ); + + html.ShouldContain("Ada"); + html.ShouldContain("ada@example.com"); + html.ShouldContain("https://app/login"); + } + + [Fact] + public async Task RenderAsync_UnknownTemplate_Throws() + { + await Should.ThrowAsync(() => + _sut.RenderAsync(UnknownTemplateId, new { }, TestContext.Current.CancellationToken) + ); + } +} diff --git a/tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs b/tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs new file mode 100644 index 00000000..3b1c4281 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs @@ -0,0 +1,49 @@ +using APITemplate.Tests.Unit.TestData; +using ErrorOr; +using ProductCatalog.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.ProductCatalog; + +public sealed class PriceValueObjectTests +{ + [Theory] + [MemberData( + nameof(PriceTheoryData.InvalidNegativeAmounts), + MemberType = typeof(PriceTheoryData) + )] + public void Create_WhenNegative_ReturnsError(decimal amount) + { + ErrorOr result = Price.Create(amount); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [MemberData( + nameof(PriceTheoryData.ValidNonNegativeAmounts), + MemberType = typeof(PriceTheoryData) + )] + public void Create_WhenNonNegative_ReturnsPrice(decimal amount) + { + ErrorOr result = Price.Create(amount); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(amount); + } + + [Fact] + public void Zero_IsZero() + { + Price.Zero.Value.ShouldBe(0m); + } + + [Fact] + public void FromPersistence_DoesNotRejectNegative() + { + Price p = Price.FromPersistence(-5m); + + p.Value.ShouldBe(-5m); + } +} diff --git a/tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs b/tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs new file mode 100644 index 00000000..01695120 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs @@ -0,0 +1,35 @@ +using ProductCatalog.Entities; +using ProductCatalog.SoftDelete; +using ProductCatalog.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.ProductCatalog; + +public sealed class ProductSoftDeleteCascadeRuleTests +{ + private readonly ProductSoftDeleteCascadeRule _sut = new(); + + [Fact] + public void CanHandle_WhenProduct_ReturnsTrue() + { + Product product = new() + { + Id = Guid.NewGuid(), + Name = "P", + Description = "D", + Price = Price.FromPersistence(1m), + CategoryId = Guid.NewGuid(), + }; + + _sut.CanHandle(product).ShouldBeTrue(); + } + + [Fact] + public void CanHandle_WhenCategory_ReturnsFalse() + { + Category category = new() { Id = Guid.NewGuid(), Name = "C" }; + + _sut.CanHandle(category).ShouldBeFalse(); + } +} diff --git a/tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs b/tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs new file mode 100644 index 00000000..ad76caf0 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs @@ -0,0 +1,74 @@ +using FluentValidation.Results; +using Reviews.Features; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Reviews; + +public sealed class ProductReviewFilterValidatorTests +{ + private readonly ProductReviewFilterValidator _sut = new(); + + [Theory] + [InlineData(0, 5)] + [InlineData(6, 5)] + [InlineData(2, 1)] + public void Validate_WhenRatingRangeInvalid_Fails(int min, int max) + { + ProductReviewFilter filter = new(MinRating: min, MaxRating: max); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeFalse(); + } + + [Theory] + [InlineData(2, 4)] + [InlineData(5, 5)] + public void Validate_WhenRatingRangeValid_Passes(int min, int max) + { + ProductReviewFilter filter = new(MinRating: min, MaxRating: max); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData("not-a-field")] + [InlineData("productId")] + public void Validate_WhenSortByUnknown_Fails(string sortBy) + { + ProductReviewFilter filter = new(SortBy: sortBy, SortDirection: "asc"); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeFalse(); + } + + [Theory] + [InlineData("rating")] + [InlineData("Rating")] + [InlineData("createdAt")] + [InlineData("CREATEDAT")] + public void Validate_WhenSortByAllowed_Passes(string sortBy) + { + ProductReviewFilter filter = new(SortBy: sortBy, SortDirection: "desc"); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData("up")] + [InlineData("")] + public void Validate_WhenSortDirectionInvalid_Fails(string direction) + { + ProductReviewFilter filter = new(SortBy: "rating", SortDirection: direction); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeFalse(); + } +} diff --git a/tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs new file mode 100644 index 00000000..67870fa1 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs @@ -0,0 +1,41 @@ +using ErrorOr; +using Reviews.Domain; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Reviews; + +public sealed class RatingValueObjectTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(6)] + [InlineData(int.MinValue)] + public void Create_WhenOutOfRange_ReturnsError(int value) + { + ErrorOr result = Rating.Create(value); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public void Create_WhenInRange_ReturnsRating(int value) + { + ErrorOr result = Rating.Create(value); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(value); + } + + [Fact] + public void FromPersistence_DoesNotValidateRange() + { + Rating r = Rating.FromPersistence(99); + + r.Value.ShouldBe(99); + } +} diff --git a/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs b/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs index f0e7aeac..b0928256 100644 --- a/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs +++ b/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs @@ -1,11 +1,5 @@ -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Persistence.Auditing; -using APITemplate.Infrastructure.Persistence.EntityNormalization; -using APITemplate.Infrastructure.StoredProcedures; using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.SoftDelete; +using SharedKernel.Infrastructure.StoredProcedures; using Shouldly; using Xunit; @@ -16,42 +10,23 @@ public sealed class StoredProcedureExecutorTests [Fact] public async Task ExecuteAsync_WhenProviderDoesNotSupportRawSql_Throws() { - await using var dbContext = CreateDbContext(); - var sut = new StoredProcedureExecutor(dbContext); + await using TestEfContext dbContext = CreateDbContext(); + StoredProcedureExecutor sut = new(dbContext); await Should.ThrowAsync(() => sut.ExecuteAsync($"select 1", TestContext.Current.CancellationToken) ); } - private static AppDbContext CreateDbContext() + private static TestEfContext CreateDbContext() { - var options = new DbContextOptionsBuilder() + DbContextOptions options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; - var stateManager = new AuditableEntityStateManager(); - - return new AppDbContext( - options, - new TestTenantProvider(), - new TestActorProvider(), - TimeProvider.System, - [], - new AppUserEntityNormalizationService(), - stateManager, - new SoftDeleteProcessor(stateManager) - ); + return new TestEfContext(options); } - private sealed class TestTenantProvider : ITenantProvider - { - public Guid TenantId => Guid.Empty; - public bool HasTenant => false; - } - - private sealed class TestActorProvider : IActorProvider - { - public Guid ActorId => Guid.Empty; - } + private sealed class TestEfContext(DbContextOptions options) + : DbContext(options); } diff --git a/tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs new file mode 100644 index 00000000..d95a0f32 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs @@ -0,0 +1,19 @@ +using Xunit; + +namespace APITemplate.Tests.Unit.TestData; + +/// Shared inputs for parametrized tests. +public static class EmailTheoryData +{ + public static IEnumerable InvalidRawInputs() + { + yield return [null]; + yield return [""]; + yield return [" "]; + yield return ["not-an-email"]; + yield return [$"{new string('a', 316)}@x.co"]; + } + + public static TheoryData TrimmingAndNormalizationCases => + new() { { " user@example.com ", "user@example.com" }, { "A@B.CO", "A@B.CO" } }; +} diff --git a/tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs new file mode 100644 index 00000000..86b7e568 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace APITemplate.Tests.Unit.TestData; + +/// Shared inputs for parametrized tests. +public static class PriceTheoryData +{ + public static TheoryData InvalidNegativeAmounts => + new() { -0.01m, -1m, decimal.MinValue }; + + public static TheoryData ValidNonNegativeAmounts => + new() { 0m, 0.01m, 1m, 999999999.99m }; +} diff --git a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs new file mode 100644 index 00000000..70645df8 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs @@ -0,0 +1,25 @@ +using System.Net; + +namespace APITemplate.Tests.Unit.TestData; + +/// Prohibited local IPv4 strings (loopback, private, and link-local) for SSRF guard tests. +public static class SsrfTheoryData +{ + public static readonly string[] ProhibitedIpv4List = + [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.1.1", + ]; + + public static IEnumerable PrivateIpv4Cases() => MapProhibitedIpv4(ip => ip); + + public static IEnumerable ProhibitedPrivateIpv4Addresses() => + MapProhibitedIpv4(IPAddress.Parse); + + private static IEnumerable MapProhibitedIpv4(Func map) + where T : notnull => ProhibitedIpv4List.Select(ip => new object[] { map(ip) }); +} diff --git a/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs b/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs index 0f15514d..79fbc2b5 100644 --- a/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs @@ -1,3 +1,4 @@ +using APITemplate.Tests.Unit.TestData; using ProductCatalog.Features.Product.CreateProducts; using Shouldly; using Xunit; @@ -36,8 +37,10 @@ public void Annotation_NameExceeds200Characters_IsInvalid() } [Theory] - [InlineData(-1)] - [InlineData(-100.50)] + [MemberData( + nameof(PriceTheoryData.InvalidNegativeAmounts), + MemberType = typeof(PriceTheoryData) + )] public void Annotation_PriceNegative_IsInvalid(decimal price) { var request = new CreateProductRequest("Valid Name", null, price); diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs index 89ac1456..06ae426e 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs @@ -1,7 +1,8 @@ -using APITemplate.Infrastructure.Webhooks; +using APITemplate.Tests.Unit.Helpers; using Microsoft.Extensions.Options; -using SharedKernel.Application.Options; using Shouldly; +using Webhooks.Contracts; +using Webhooks.Security; using Xunit; namespace APITemplate.Tests.Unit.Webhooks; @@ -53,9 +54,4 @@ public void Sign_TimestampReflectsTimeProvider() result.Timestamp.ShouldBe(fixedTime.ToUnixTimeSeconds().ToString()); } - - private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider - { - public override DateTimeOffset GetUtcNow() => utcNow; - } } diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs index 9a9a74d6..bc290fe5 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs @@ -1,8 +1,8 @@ -using APITemplate.Infrastructure.Webhooks; -using APITemplate.Tests.Integration.Helpers; +using APITemplate.Tests.Unit.Helpers; using Microsoft.Extensions.Options; -using SharedKernel.Application.Options; using Shouldly; +using Webhooks.Contracts; +using Webhooks.Security; using Xunit; namespace APITemplate.Tests.Unit.Webhooks; @@ -45,6 +45,22 @@ public void IsValid_WrongHmac_ReturnsFalse() validator.IsValid(payload, "wrong-signature", timestamp).ShouldBeFalse(); } + [Theory] + [InlineData("", "sig", "1")] + [InlineData("{}", "", "1")] + [InlineData("{}", "sig", "")] + public void IsValid_WhenPayloadEmptyOrSignatureOrTimestampMissing_ReturnsFalse( + string payload, + string signature, + string timestamp + ) + { + var now = DateTimeOffset.UtcNow; + var validator = CreateValidator(new FakeTimeProvider(now)); + + validator.IsValid(payload, signature, timestamp).ShouldBeFalse(); + } + [Fact] public void IsValid_TimestampWithinTolerance_ReturnsTrue() { @@ -84,9 +100,4 @@ public void IsValid_ExactBoundary_ReturnsTrue() validator.IsValid(payload, signature, boundaryTimestamp).ShouldBeTrue(); } - - private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider - { - public override DateTimeOffset GetUtcNow() => utcNow; - } } diff --git a/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs index b3169982..f72cd27a 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Reflection; +using APITemplate.Tests.Unit.TestData; using Shouldly; using Webhooks.Services; using Xunit; @@ -20,12 +21,7 @@ private static bool IsProhibited(IPAddress address) } [Theory] - [InlineData("127.0.0.1")] - [InlineData("10.0.0.1")] - [InlineData("172.16.0.1")] - [InlineData("172.31.255.255")] - [InlineData("192.168.1.1")] - [InlineData("169.254.1.1")] + [MemberData(nameof(SsrfTheoryData.PrivateIpv4Cases), MemberType = typeof(SsrfTheoryData))] public void IsProhibitedAddress_BlocksPrivateIPv4(string ip) { IsProhibited(IPAddress.Parse(ip)).ShouldBeTrue($"{ip} should be prohibited");