2026-04-13 21:36:09 -03:00
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using Microsoft.AspNetCore.Hosting;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
2026-04-15 10:47:48 -03:00
|
|
|
using Microsoft.AspNetCore.TestHost;
|
2026-04-13 21:36:09 -03:00
|
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
2026-04-19 16:59:53 -03:00
|
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
2026-04-13 21:36:09 -03:00
|
|
|
using SIGCM2.Application.Abstractions.Security;
|
2026-04-15 10:47:48 -03:00
|
|
|
using SIGCM2.Infrastructure.Persistence;
|
2026-04-13 21:36:09 -03:00
|
|
|
using SIGCM2.Infrastructure.Security;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace SIGCM2.TestSupport;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// WebApplicationFactory for integration tests against SIGCM2.Api.
|
2026-04-18 21:44:19 -03:00
|
|
|
/// Uses SIGCM2_Test_Api database (isolated from Application.Tests which uses SIGCM2_Test_App).
|
2026-04-19 16:59:53 -03:00
|
|
|
///
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Per-test DI overrides</b> — use <see cref="CreateClientWithOverrides"/> when a test needs to
|
|
|
|
|
/// replace a scoped service (e.g. <c>IProductQueryRepository</c>, <c>IAvisoQueryRepository</c>)
|
|
|
|
|
/// without touching the shared factory:
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <code>
|
|
|
|
|
/// using var client = _factory.CreateClientWithOverrides(services =>
|
|
|
|
|
/// {
|
|
|
|
|
/// services.RemoveAll<IMyRepository>();
|
|
|
|
|
/// services.AddScoped<IMyRepository>(_ => new MyFakeRepository());
|
|
|
|
|
/// });
|
|
|
|
|
/// </code>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Internally this calls <see cref="WebApplicationFactory{TEntryPoint}.WithWebHostBuilder"/> 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 <see cref="HttpClient"/> is disposed.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Why this works safely</b>: <c>WithWebHostBuilder</c> does NOT share the parent host's DI root.
|
|
|
|
|
/// It re-runs <c>ConfigureWebHost</c> (re-loading RSA keys from disk), then applies the caller's
|
|
|
|
|
/// <c>ConfigureTestServices</c> on top. The RSA singleton lives in the child's root scope and is
|
|
|
|
|
/// disposed with that child factory — no cross-factory leakage.
|
|
|
|
|
/// </para>
|
2026-04-13 21:36:09 -03:00
|
|
|
/// </summary>
|
|
|
|
|
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
|
|
|
|
{
|
2026-04-18 21:44:19 -03:00
|
|
|
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
2026-04-13 21:36:09 -03:00
|
|
|
|
|
|
|
|
// 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");
|
2026-04-18 20:56:23 -03:00
|
|
|
private static readonly string PublicKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "public.pem");
|
2026-04-13 21:36:09 -03:00
|
|
|
|
|
|
|
|
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<string, string?>
|
|
|
|
|
{
|
2026-04-18 20:56:23 -03:00
|
|
|
["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",
|
2026-04-13 21:36:09 -03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
builder.UseEnvironment("Testing");
|
2026-04-15 10:47:48 -03:00
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
});
|
2026-04-13 21:36:09 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-19 16:59:53 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// Creates an <see cref="HttpClient"/> against a child host that inherits all base configuration
|
|
|
|
|
/// but applies the caller's additional <paramref name="overrides"/> on top via
|
|
|
|
|
/// <c>ConfigureTestServices</c>.
|
|
|
|
|
///
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The returned <see cref="HttpClient"/> is tied to the child factory's lifetime.
|
|
|
|
|
/// Dispose the client when the test finishes to release the child host:
|
|
|
|
|
/// <code>using var client = _factory.CreateClientWithOverrides(s => { ... });</code>
|
|
|
|
|
/// </para>
|
|
|
|
|
///
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Typical usage — inject a stub repository for a specific test scenario:
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <code>
|
|
|
|
|
/// 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);
|
|
|
|
|
/// </code>
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="overrides">
|
|
|
|
|
/// Action applied to <see cref="IServiceCollection"/> after all production services are
|
|
|
|
|
/// registered. Use <c>services.RemoveAll<T>()</c> then <c>services.AddScoped<T>(...)</c>
|
|
|
|
|
/// to replace an existing registration.
|
|
|
|
|
/// </param>
|
|
|
|
|
/// <returns>
|
|
|
|
|
/// A new <see cref="HttpClient"/> backed by the child host. The client owns the child factory
|
|
|
|
|
/// via <see cref="WebApplicationFactory{TEntryPoint}.CreateClient()"/>'s disposal chain.
|
|
|
|
|
/// </returns>
|
|
|
|
|
public HttpClient CreateClientWithOverrides(Action<IServiceCollection> overrides)
|
|
|
|
|
{
|
|
|
|
|
var child = WithWebHostBuilder(builder =>
|
|
|
|
|
builder.ConfigureTestServices(overrides));
|
|
|
|
|
return child.CreateClient();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 21:36:09 -03:00
|
|
|
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}");
|
|
|
|
|
}
|
|
|
|
|
}
|