341 lines
11 KiB
C#
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.
|
|
}
|
|
|
|
|