using FamilyNido.Persistence; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Npgsql; using Testcontainers.PostgreSql; namespace FamilyNido.Tests.Integration; /// /// Single shared fixture for the whole integration-test suite. Owns: /// /// One Postgres container (TestContainers) — schema migrated once at start. /// One bound to that container. /// A primitive that truncates every public table /// between tests to keep them independent. /// /// xUnit will instantiate this fixture once per test collection (see /// ) and dispose it at the end of the run. /// public sealed class IntegrationFixture : IAsyncLifetime { private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .WithDatabase("FamilyNido") .WithUsername("FamilyNido") .WithPassword("FamilyNido") .Build(); /// WebApplicationFactory bound to the Postgres container. public FamilyNidoApiFactory Factory { get; private set; } = null!; /// List of public tables to truncate, captured once after migrations. private string[] _tables = []; /// public async Task InitializeAsync() { await _postgres.StartAsync(); Factory = new FamilyNidoApiFactory(_postgres.GetConnectionString()); // Triggering CreateClient() bootstraps the host, which also runs // migrations because Family.AutoMigrate=true in appsettings.Testing.json. using var bootstrap = Factory.CreateClient(); // Capture the live table list (schema is now stable). Used by ResetAsync. await using var scope = Factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var conn = (NpgsqlConnection)db.Database.GetDbConnection(); await conn.OpenAsync(); await using (var cmd = conn.CreateCommand()) { cmd.CommandText = """ SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '__EFMigrationsHistory' """; var rows = new List(); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { rows.Add(reader.GetString(0)); } _tables = [.. rows]; } } /// public async Task DisposeAsync() { await Factory.DisposeAsync(); await _postgres.DisposeAsync(); } /// /// Wipe every public table between tests so they observe a known state. /// Cheaper than recreating the schema and works fine for our scale. /// public async Task ResetAsync() { if (_tables.Length == 0) return; await using var scope = Factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var conn = (NpgsqlConnection)db.Database.GetDbConnection(); await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); var quoted = string.Join(", ", _tables.Select(t => $"\"{t}\"")); cmd.CommandText = $"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"; await cmd.ExecuteNonQueryAsync(); } } /// Marker collection so every integration test class shares the fixture above. [CollectionDefinition(IntegrationCollection.Name)] public sealed class IntegrationCollection : ICollectionFixture { /// Collection name referenced by tests via [Collection(...)]. public const string Name = "FamilyNido integration"; } /// /// Test-time WebApplicationFactory: rewires the Postgres connection string at /// the configuration layer (rather than re-registering the DbContext) so the /// real wiring stays /// in effect — including migrations + naming conventions. /// public sealed class FamilyNidoApiFactory(string connectionString) : WebApplicationFactory { /// protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.UseSetting("ConnectionStrings:Postgres", connectionString); // Fail fast on missing test config files: the test project copies // appsettings.Testing.json next to the assembly via the csproj. builder.ConfigureAppConfiguration((context, cfg) => { var dir = AppContext.BaseDirectory; cfg.AddJsonFile(Path.Combine(dir, "appsettings.Testing.json"), optional: false); }); // The production setup wires OIDC as the default challenge scheme so // the OnRedirectToLogin event on Cookie is the one that returns 401 // for /api paths. Under test we have no real OIDC provider — keeping // OIDC as the challenge scheme means every anonymous /api hit tries // to fetch a bogus metadata document and crashes with 500. Switching // the default challenge to Cookie in the test environment is enough // to get clean 401s; the local-login slice signs into the cookie // scheme directly so it is not affected. builder.ConfigureTestServices(services => { services.Configure(options => { options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); }); } }