Files
git.stella-ops.org/src/__Libraries/StellaOps.TestKit/Fixtures/PostgresFixture.cs

341 lines
11 KiB
C#

using System.Reflection;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Isolation modes for PostgreSQL test fixtures.
/// </summary>
public enum PostgresIsolationMode
{
/// <summary>Each test gets its own schema. Default, most isolated.</summary>
SchemaPerTest,
/// <summary>Truncate all tables between tests. Faster but shared schema.</summary>
Truncation,
/// <summary>Each test gets its own database. Maximum isolation, slowest.</summary>
DatabasePerTest
}
/// <summary>
/// Represents a migration source for PostgreSQL fixtures.
/// </summary>
public sealed record MigrationSource(string Module, string ScriptPath);
/// <summary>
/// Test fixture for PostgreSQL database using Testcontainers.
/// Provides an isolated PostgreSQL instance for integration tests with
/// configurable isolation modes and migration support.
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private readonly List<MigrationSource> _migrations = new();
private int _schemaCounter;
private int _databaseCounter;
public PostgresFixture()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("testuser")
.WithPassword("testpass")
.Build();
}
/// <summary>
/// Gets or sets the isolation mode for tests.
/// </summary>
public PostgresIsolationMode IsolationMode { get; set; } = PostgresIsolationMode.SchemaPerTest;
/// <summary>
/// Gets the connection string for the PostgreSQL container.
/// </summary>
public string ConnectionString => _container.GetConnectionString();
/// <summary>
/// Gets the database name.
/// </summary>
public string DatabaseName => "testdb";
/// <summary>
/// Gets the hostname of the PostgreSQL container.
/// </summary>
public string Host => _container.Hostname;
/// <summary>
/// Gets the exposed port of the PostgreSQL container.
/// </summary>
public ushort Port => _container.GetMappedPublicPort(5432);
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
/// <summary>
/// Registers migrations to be applied for a module.
/// </summary>
public void RegisterMigrations(string module, string scriptPath)
{
_migrations.Add(new MigrationSource(module, scriptPath));
}
/// <summary>
/// Creates a new test session with appropriate isolation.
/// </summary>
public async Task<PostgresTestSession> 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}")
};
}
/// <summary>
/// Creates a schema-isolated session for a test.
/// </summary>
public async Task<PostgresTestSession> 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);
}
/// <summary>
/// Creates a database-isolated session for a test.
/// </summary>
public async Task<PostgresTestSession> 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);
}
/// <summary>
/// Truncates all user tables in the public schema.
/// </summary>
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);
}
/// <summary>
/// Applies all registered migrations to a schema.
/// </summary>
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);
}
}
}
/// <summary>
/// Applies migrations from an assembly's embedded resources to a schema.
/// </summary>
/// <param name="assembly">Assembly containing embedded SQL migration resources.</param>
/// <param name="schemaName">Target schema name.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
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);
}
}
/// <summary>
/// Applies migrations from an assembly's embedded resources using a marker type.
/// </summary>
/// <typeparam name="TAssemblyMarker">Type from the assembly containing migrations.</typeparam>
/// <param name="schemaName">Target schema name.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
public Task ApplyMigrationsFromAssemblyAsync<TAssemblyMarker>(
string schemaName,
string? resourcePrefix = null)
=> ApplyMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, schemaName, resourcePrefix);
/// <summary>
/// Applies all registered migrations to a database.
/// </summary>
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();
}
}
}
/// <summary>
/// Executes a SQL command against the database.
/// </summary>
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();
}
/// <summary>
/// Creates a new database within the container.
/// </summary>
public async Task CreateDatabaseAsync(string databaseName)
{
var createDbSql = $"CREATE DATABASE \"{databaseName}\"";
await ExecuteSqlAsync(createDbSql);
}
/// <summary>
/// Drops a database within the container.
/// </summary>
public async Task DropDatabaseAsync(string databaseName)
{
var dropDbSql = $"DROP DATABASE IF EXISTS \"{databaseName}\"";
await ExecuteSqlAsync(dropDbSql);
}
/// <summary>
/// Drops a schema within the database.
/// </summary>
public async Task DropSchemaAsync(string schemaName)
{
var dropSchemaSql = $"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE";
await ExecuteSqlAsync(dropSchemaSql);
}
/// <summary>
/// Gets a connection string for a specific database in the container.
/// </summary>
public string GetConnectionString(string databaseName)
{
var builder = new Npgsql.NpgsqlConnectionStringBuilder(ConnectionString)
{
Database = databaseName
};
return builder.ToString();
}
}
/// <summary>
/// Represents an isolated test session within PostgreSQL.
/// </summary>
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;
}
/// <summary>Connection string for this session.</summary>
public string ConnectionString { get; }
/// <summary>Schema name for this session.</summary>
public string Schema { get; }
/// <summary>
/// Cleans up the session resources.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_databaseName != null)
{
await _fixture.DropDatabaseAsync(_databaseName);
}
else if (Schema != "public")
{
await _fixture.DropSchemaAsync(Schema);
}
}
}
/// <summary>
/// Collection fixture for PostgreSQL to share the container across multiple test classes.
/// </summary>
[CollectionDefinition("Postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture>
{
// 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.
}