using System.Reflection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Testcontainers.PostgreSql; using Xunit; using Xunit.Sdk; namespace StellaOps.Infrastructure.Postgres.Testing; /// /// Base class for PostgreSQL integration test fixtures. /// Uses Testcontainers to spin up a real PostgreSQL instance. /// /// /// Inherit from this class and override and /// to provide module-specific migrations. /// public abstract class PostgresIntegrationFixture : IAsyncLifetime { private PostgreSqlContainer? _container; private PostgresFixture? _fixture; /// /// Gets the PostgreSQL connection string for tests. /// public string ConnectionString => _container?.GetConnectionString() ?? throw new InvalidOperationException("Container not initialized"); /// /// Gets the schema name for test isolation. /// public string SchemaName => _fixture?.SchemaName ?? throw new InvalidOperationException("Fixture not initialized"); /// /// Gets the PostgreSQL test fixture. /// public PostgresFixture Fixture => _fixture ?? throw new InvalidOperationException("Fixture not initialized"); /// /// Gets the logger for this fixture. /// protected virtual ILogger Logger => NullLogger.Instance; /// /// Gets the PostgreSQL Docker image to use. /// protected virtual string PostgresImage => "postgres:16-alpine"; /// /// Gets the assembly containing embedded SQL migrations. /// /// Assembly with embedded migration resources, or null if no migrations. protected abstract Assembly? GetMigrationAssembly(); /// /// Gets the module name for logging and schema naming. /// protected abstract string GetModuleName(); /// /// Gets the resource prefix for filtering embedded resources. /// protected virtual string? GetResourcePrefix() => null; /// /// Initializes the PostgreSQL container and runs migrations. /// public virtual async Task InitializeAsync() { try { _container = new PostgreSqlBuilder() .WithImage(PostgresImage) .Build(); await _container.StartAsync(); } catch (ArgumentException ex) when (ShouldSkipForMissingDocker(ex)) { try { if (_container is not null) { await _container.DisposeAsync(); } } catch { // Ignore cleanup failures during skip. } _container = null; throw SkipException.ForSkip( $"Postgres integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}"); } var moduleName = GetModuleName(); _fixture = PostgresFixtureFactory.Create(ConnectionString, moduleName, Logger); await _fixture.InitializeAsync(); var migrationAssembly = GetMigrationAssembly(); if (migrationAssembly != null) { await _fixture.RunMigrationsFromAssemblyAsync( migrationAssembly, moduleName, GetResourcePrefix()); } } /// /// Cleans up the PostgreSQL container and fixture. /// public virtual async Task DisposeAsync() { if (_fixture != null) { await _fixture.DisposeAsync(); } if (_container != null) { await _container.DisposeAsync(); } } /// /// Truncates all tables in the test schema for test isolation between test methods. /// public Task TruncateAllTablesAsync(CancellationToken cancellationToken = default) => Fixture.TruncateAllTablesAsync(cancellationToken); /// /// Executes raw SQL for test setup. /// public Task ExecuteSqlAsync(string sql, CancellationToken cancellationToken = default) => Fixture.ExecuteSqlAsync(sql, cancellationToken); private static bool ShouldSkipForMissingDocker(ArgumentException exception) { return string.Equals(exception.ParamName, "DockerEndpointAuthConfig", StringComparison.Ordinal) || exception.Message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase); } } /// /// PostgreSQL integration fixture without migrations. /// Useful for testing the infrastructure itself or creating schemas dynamically. /// public sealed class PostgresIntegrationFixtureWithoutMigrations : PostgresIntegrationFixture { protected override Assembly? GetMigrationAssembly() => null; protected override string GetModuleName() => "Test"; }