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