using System.Security.Cryptography; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using SIGCM2.Application.Abstractions.Security; using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Security; using Xunit; namespace SIGCM2.TestSupport; /// /// WebApplicationFactory for integration tests against SIGCM2.Api. /// Uses SIGCM2_Test_Api database (isolated from Application.Tests which uses SIGCM2_Test_App). /// /// /// Per-test DI overrides — use when a test needs to /// replace a scoped service (e.g. IProductQueryRepository, IAvisoQueryRepository) /// without touching the shared factory: /// /// /// using var client = _factory.CreateClientWithOverrides(services => /// { /// services.RemoveAll<IMyRepository>(); /// services.AddScoped<IMyRepository>(_ => new MyFakeRepository()); /// }); /// /// /// Internally this calls which /// creates an independent child host. The RSA key singletons are re-loaded from the same PEM files /// and do not conflict with the parent host — each child host owns its own DI container. /// The child factory (and its host) is disposed when the returned is disposed. /// /// /// Why this works safely: WithWebHostBuilder does NOT share the parent host's DI root. /// It re-runs ConfigureWebHost (re-loading RSA keys from disk), then applies the caller's /// ConfigureTestServices on top. The RSA singleton lives in the child's root scope and is /// disposed with that child factory — no cross-factory leakage. /// /// public sealed class TestWebAppFactory : WebApplicationFactory, IAsyncLifetime { private const string TestConnectionString = TestConnectionStrings.ApiTestDb; // Resolved once — absolute paths independent of working directory private static readonly string RepoRoot = ResolveRepoRoot(); private static readonly string PrivateKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "private.pem"); private static readonly string PublicKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "public.pem"); private readonly SqlTestFixture _dbFixture = new(TestConnectionString); protected override void ConfigureWebHost(IWebHostBuilder builder) { // Step 1: Override configuration BEFORE services are built builder.ConfigureAppConfiguration((ctx, config) => { // Clear all existing sources and rebuild with test values // This ensures our paths win over appsettings.json config.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:SqlServer"] = TestConnectionString, ["Jwt:Issuer"] = "sigcm2.api", ["Jwt:Audience"] = "sigcm2.web", ["Jwt:AccessTokenMinutes"] = "60", ["Jwt:RefreshTokenDays"] = "7", ["Jwt:PrivateKeyPath"] = PrivateKeyPath, ["Jwt:PublicKeyPath"] = PublicKeyPath, ["Jwt:PrivateKey"] = null, ["Jwt:PublicKey"] = null, ["Cors:AllowedOrigins:0"] = "http://localhost:5173", ["Serilog:MinimumLevel:Default"] = "Warning", }); }); builder.UseEnvironment("Testing"); // Step 2: Replace SqlConnectionFactory singleton with the correct test connection. // ConfigureAppConfiguration alone is insufficient because WebApplication.CreateBuilder // evaluates configuration for singleton construction before overrides apply. // ConfigureTestServices runs AFTER all services are registered, so it wins. builder.ConfigureTestServices(services => { // Remove the existing SqlConnectionFactory singleton registered by AddInfrastructure var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(SqlConnectionFactory)); if (descriptor is not null) services.Remove(descriptor); // Re-register with the test connection string services.AddSingleton(new SqlConnectionFactory(TestConnectionString)); }); } /// /// Creates an against a child host that inherits all base configuration /// but applies the caller's additional on top via /// ConfigureTestServices. /// /// /// The returned is tied to the child factory's lifetime. /// Dispose the client when the test finishes to release the child host: /// using var client = _factory.CreateClientWithOverrides(s => { ... }); /// /// /// /// Typical usage — inject a stub repository for a specific test scenario: /// /// /// using var client = _factory.CreateClientWithOverrides(services => /// { /// services.RemoveAll<IProductQueryRepository>(); /// services.AddScoped<IProductQueryRepository>(_ => new AlwaysInUseProductQueryRepository()); /// }); /// var resp = await client.SendAsync(...); /// Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); /// /// /// /// Action applied to after all production services are /// registered. Use services.RemoveAll<T>() then services.AddScoped<T>(...) /// to replace an existing registration. /// /// /// A new backed by the child host. The client owns the child factory /// via 's disposal chain. /// public HttpClient CreateClientWithOverrides(Action overrides) { var child = WithWebHostBuilder(builder => builder.ConfigureTestServices(overrides)); return child.CreateClient(); } public async Task InitializeAsync() { await _dbFixture.InitializeAsync(); } public new async Task DisposeAsync() { await _dbFixture.DisposeAsync(); await base.DisposeAsync(); } private static string ResolveRepoRoot() { // Walk up from AppContext.BaseDirectory looking for SIGCM2.slnx var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { if (dir.GetFiles("SIGCM2.slnx").Length > 0) return dir.FullName; dir = dir.Parent; } // Walk up from assembly location var assemblyLocation = typeof(TestWebAppFactory).Assembly.Location; dir = new DirectoryInfo(Path.GetDirectoryName(assemblyLocation)!); while (dir is not null) { if (dir.GetFiles("SIGCM2.slnx").Length > 0) return dir.FullName; dir = dir.Parent; } // Known absolute path (last resort for this machine) const string knownPath = @"E:\SIG-CM2.0"; if (Directory.Exists(knownPath) && File.Exists(Path.Combine(knownPath, "SIGCM2.slnx"))) return knownPath; throw new InvalidOperationException( $"Could not find repo root containing SIGCM2.slnx. " + $"AppContext.BaseDirectory: {AppContext.BaseDirectory}"); } }