using System.Reflection; using Testcontainers.PostgreSql; using Xunit; namespace StellaOps.TestKit.Fixtures; /// /// Isolation modes for PostgreSQL test fixtures. /// public enum PostgresIsolationMode { /// Each test gets its own schema. Default, most isolated. SchemaPerTest, /// Truncate all tables between tests. Faster but shared schema. Truncation, /// Each test gets its own database. Maximum isolation, slowest. DatabasePerTest } /// /// Represents a migration source for PostgreSQL fixtures. /// public sealed record MigrationSource(string Module, string ScriptPath); /// /// Test fixture for PostgreSQL database using Testcontainers. /// Provides an isolated PostgreSQL instance for integration tests with /// configurable isolation modes and migration support. /// public sealed class PostgresFixture : IAsyncLifetime { private readonly PostgreSqlContainer _container; private readonly List _migrations = new(); private int _schemaCounter; private int _databaseCounter; public PostgresFixture() { _container = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .WithDatabase("testdb") .WithUsername("testuser") .WithPassword("testpass") .Build(); } /// /// Gets or sets the isolation mode for tests. /// public PostgresIsolationMode IsolationMode { get; set; } = PostgresIsolationMode.SchemaPerTest; /// /// Gets the connection string for the PostgreSQL container. /// public string ConnectionString => _container.GetConnectionString(); /// /// Gets the database name. /// public string DatabaseName => "testdb"; /// /// Gets the hostname of the PostgreSQL container. /// public string Host => _container.Hostname; /// /// Gets the exposed port of the PostgreSQL container. /// public ushort Port => _container.GetMappedPublicPort(5432); public async ValueTask InitializeAsync() { await _container.StartAsync(); } public async ValueTask DisposeAsync() { await _container.DisposeAsync(); } /// /// Registers migrations to be applied for a module. /// public void RegisterMigrations(string module, string scriptPath) { _migrations.Add(new MigrationSource(module, scriptPath)); } /// /// Creates a new test session with appropriate isolation. /// public async Task CreateSessionAsync(string? testName = null) { return IsolationMode switch { PostgresIsolationMode.SchemaPerTest => await CreateSchemaSessionAsync(testName), PostgresIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName), PostgresIsolationMode.Truncation => new PostgresTestSession(ConnectionString, "public", this), _ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}") }; } /// /// Creates a schema-isolated session for a test. /// public async Task CreateSchemaSessionAsync(string? testName = null) { var schemaName = $"test_{Interlocked.Increment(ref _schemaCounter):D4}_{testName ?? "anon"}"; await ExecuteSqlAsync($"CREATE SCHEMA IF NOT EXISTS \"{schemaName}\""); // Apply migrations to the new schema await ApplyMigrationsAsync(schemaName); var connectionString = new Npgsql.NpgsqlConnectionStringBuilder(ConnectionString) { SearchPath = schemaName }.ToString(); return new PostgresTestSession(connectionString, schemaName, this); } /// /// Creates a database-isolated session for a test. /// public async Task CreateDatabaseSessionAsync(string? testName = null) { var dbName = $"test_{Interlocked.Increment(ref _databaseCounter):D4}_{testName ?? "anon"}"; await CreateDatabaseAsync(dbName); var connectionString = GetConnectionString(dbName); // Apply migrations to the new database await ApplyMigrationsToDatabaseAsync(connectionString); return new PostgresTestSession(connectionString, "public", this, dbName); } /// /// Truncates all user tables in the public schema. /// public async Task TruncateAllTablesAsync() { const string truncateSql = """ DO $$ DECLARE r RECORD; BEGIN FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE'; END LOOP; END $$; """; await ExecuteSqlAsync(truncateSql); } /// /// Applies all registered migrations to a schema. /// public async Task ApplyMigrationsAsync(string schemaName) { foreach (var migration in _migrations) { if (File.Exists(migration.ScriptPath)) { var sql = await File.ReadAllTextAsync(migration.ScriptPath); var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\"."); await ExecuteSqlAsync(schemaQualifiedSql); } } } /// /// Applies migrations from an assembly's embedded resources to a schema. /// /// Assembly containing embedded SQL migration resources. /// Target schema name. /// Optional prefix to filter resources (e.g., "Migrations"). public async Task ApplyMigrationsFromAssemblyAsync( Assembly assembly, string schemaName, string? resourcePrefix = null) { ArgumentNullException.ThrowIfNull(assembly); ArgumentException.ThrowIfNullOrWhiteSpace(schemaName); var resourceNames = assembly.GetManifestResourceNames() .Where(r => r.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) .Where(r => string.IsNullOrEmpty(resourcePrefix) || r.Contains(resourcePrefix)) .OrderBy(r => r) .ToList(); foreach (var resourceName in resourceNames) { await using var stream = assembly.GetManifestResourceStream(resourceName); if (stream is null) continue; using var reader = new StreamReader(stream); var sql = await reader.ReadToEndAsync(); // Replace public schema with target schema var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\"."); await ExecuteSqlAsync(schemaQualifiedSql); } } /// /// Applies migrations from an assembly's embedded resources using a marker type. /// /// Type from the assembly containing migrations. /// Target schema name. /// Optional prefix to filter resources. public Task ApplyMigrationsFromAssemblyAsync( string schemaName, string? resourcePrefix = null) => ApplyMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, schemaName, resourcePrefix); /// /// Applies all registered migrations to a database. /// private async Task ApplyMigrationsToDatabaseAsync(string connectionString) { foreach (var migration in _migrations) { if (File.Exists(migration.ScriptPath)) { var sql = await File.ReadAllTextAsync(migration.ScriptPath); await using var conn = new Npgsql.NpgsqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new Npgsql.NpgsqlCommand(sql, conn); await cmd.ExecuteNonQueryAsync(); } } } /// /// Executes a SQL command against the database. /// public async Task ExecuteSqlAsync(string sql) { await using var conn = new Npgsql.NpgsqlConnection(ConnectionString); await conn.OpenAsync(); await using var cmd = new Npgsql.NpgsqlCommand(sql, conn); await cmd.ExecuteNonQueryAsync(); } /// /// Creates a new database within the container. /// public async Task CreateDatabaseAsync(string databaseName) { var createDbSql = $"CREATE DATABASE \"{databaseName}\""; await ExecuteSqlAsync(createDbSql); } /// /// Drops a database within the container. /// public async Task DropDatabaseAsync(string databaseName) { var dropDbSql = $"DROP DATABASE IF EXISTS \"{databaseName}\""; await ExecuteSqlAsync(dropDbSql); } /// /// Drops a schema within the database. /// public async Task DropSchemaAsync(string schemaName) { var dropSchemaSql = $"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE"; await ExecuteSqlAsync(dropSchemaSql); } /// /// Gets a connection string for a specific database in the container. /// public string GetConnectionString(string databaseName) { var builder = new Npgsql.NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName }; return builder.ToString(); } } /// /// Represents an isolated test session within PostgreSQL. /// public sealed class PostgresTestSession : IAsyncDisposable { private readonly PostgresFixture _fixture; private readonly string? _databaseName; public PostgresTestSession(string connectionString, string schema, PostgresFixture fixture, string? databaseName = null) { ConnectionString = connectionString; Schema = schema; _fixture = fixture; _databaseName = databaseName; } /// Connection string for this session. public string ConnectionString { get; } /// Schema name for this session. public string Schema { get; } /// /// Cleans up the session resources. /// public async ValueTask DisposeAsync() { if (_databaseName != null) { await _fixture.DropDatabaseAsync(_databaseName); } else if (Schema != "public") { await _fixture.DropSchemaAsync(Schema); } } } /// /// Collection fixture for PostgreSQL to share the container across multiple test classes. /// [CollectionDefinition("Postgres")] public class PostgresCollection : ICollectionFixture { // This class has no code, and is never created. Its purpose is simply // to be the place to apply [CollectionDefinition] and all the // ICollectionFixture<> interfaces. }