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.
}