using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Npgsql; namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; /// /// One isolated logical database per test class, on the shared /// server. Real EF Core /// migrations run at startup exactly as production does — this is not a mocked or /// SQLite-backed substitute (docs/22-testing-strategy.md). /// public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory, IAsyncLifetime { private readonly PostgresContainerFixture _postgres; private readonly IReadOnlyDictionary _additionalConfiguration; private readonly string _databaseName = $"test_{Guid.NewGuid():N}"; private readonly string _attachmentsDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-attachments", Guid.NewGuid().ToString("N")); private readonly string _importsDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-imports", Guid.NewGuid().ToString("N")); private readonly string _backupsDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-backups", Guid.NewGuid().ToString("N")); private readonly string _keysDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-keys", Guid.NewGuid().ToString("N")); public KnowledgeHubIntegrationFactory( PostgresContainerFixture postgres, IReadOnlyDictionary? additionalConfiguration = null) { _postgres = postgres; _additionalConfiguration = additionalConfiguration ?? new Dictionary(); } public async Task InitializeAsync() { await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = $"CREATE DATABASE \"{_databaseName}\""; await command.ExecuteNonQueryAsync(); } async Task IAsyncLifetime.DisposeAsync() { // Dispose the hosted TestServer/HttpClient first (base WebApplicationFactory // behavior), then drop this test class's isolated database. await base.DisposeAsync(); if (Directory.Exists(_attachmentsDirectory)) { Directory.Delete(_attachmentsDirectory, recursive: true); } if (Directory.Exists(_importsDirectory)) { Directory.Delete(_importsDirectory, recursive: true); } if (Directory.Exists(_backupsDirectory)) { Directory.Delete(_backupsDirectory, recursive: true); } if (Directory.Exists(_keysDirectory)) { Directory.Delete(_keysDirectory, recursive: true); } try { await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString); await connection.OpenAsync(); await using (var terminate = connection.CreateCommand()) { terminate.CommandText = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = @name AND pid <> pg_backend_pid()"; terminate.Parameters.AddWithValue("name", _databaseName); await terminate.ExecuteNonQueryAsync(); } await using var drop = connection.CreateCommand(); drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\""; await drop.ExecuteNonQueryAsync(); } catch (NpgsqlException) { // Best-effort cleanup: the whole container is disposed at the end of the // run regardless, so a failure to drop one logical database here is not a // correctness problem for the test suite. } } /// The isolated content-addressed attachment storage root for this test /// class, so parallel test classes never share (or race on) blob files. public string AttachmentsDirectory => _attachmentsDirectory; /// The isolated LeafWiki imports mount root for this test class. Tests /// write synthetic fixture files here directly (never a real snapshot). public string ImportsDirectory => _importsDirectory; /// The isolated backups root for this test class, so parallel/sequential /// test classes never share (or have retention-prune) each other's backups. public string BackupsDirectory => _backupsDirectory; protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseContentRoot( Path.Combine( FindRepositoryRoot().FullName, "src", "backend", "ItWorx.KnowledgeHub.Api")); builder.UseEnvironment("IntegrationTest"); builder.ConfigureAppConfiguration((_, configuration) => { var values = new Dictionary { ["KnowledgeHub:Database:Host"] = _postgres.Host, ["KnowledgeHub:Database:Port"] = _postgres.Port.ToString(System.Globalization.CultureInfo.InvariantCulture), ["KnowledgeHub:Database:Name"] = _databaseName, ["KnowledgeHub:Database:Username"] = PostgresContainerFixture.Username, ["KnowledgeHub:Database:Password"] = PostgresContainerFixture.Password, ["KnowledgeHub:Paths:Attachments"] = _attachmentsDirectory, ["KnowledgeHub:Paths:Imports"] = _importsDirectory, ["KnowledgeHub:Paths:Backups"] = _backupsDirectory, ["KnowledgeHub:Paths:Keys"] = _keysDirectory }; foreach (var (key, value) in _additionalConfiguration) { values[key] = value; } configuration.AddInMemoryCollection(values); }); } private static DirectoryInfo FindRepositoryRoot() { DirectoryInfo? current = new(AppContext.BaseDirectory); while (current is not null) { if (File.Exists(Path.Combine(current.FullName, "global.json")) && (File.Exists(Path.Combine(current.FullName, "CLAUDE.md")) || File.Exists(Path.Combine(current.FullName, "PUBLIC_SOURCE_EXPORT.md")))) { return current; } current = current.Parent; } throw new DirectoryNotFoundException("Could not locate the repository root."); } }