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;
});
});
}
}