up
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for PostgreSQL integration test fixtures.
|
||||
/// Uses Testcontainers to spin up a real PostgreSQL instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inherit from this class and override <see cref="GetMigrationAssembly"/> and <see cref="GetModuleName"/>
|
||||
/// to provide module-specific migrations.
|
||||
/// </remarks>
|
||||
public abstract class PostgresIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer? _container;
|
||||
private PostgresFixture? _fixture;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL connection string for tests.
|
||||
/// </summary>
|
||||
public string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name for test isolation.
|
||||
/// </summary>
|
||||
public string SchemaName => _fixture?.SchemaName
|
||||
?? throw new InvalidOperationException("Fixture not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL test fixture.
|
||||
/// </summary>
|
||||
public PostgresFixture Fixture => _fixture
|
||||
?? throw new InvalidOperationException("Fixture not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logger for this fixture.
|
||||
/// </summary>
|
||||
protected virtual ILogger Logger => NullLogger.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL Docker image to use.
|
||||
/// </summary>
|
||||
protected virtual string PostgresImage => "postgres:16-alpine";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assembly containing embedded SQL migrations.
|
||||
/// </summary>
|
||||
/// <returns>Assembly with embedded migration resources, or null if no migrations.</returns>
|
||||
protected abstract Assembly? GetMigrationAssembly();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module name for logging and schema naming.
|
||||
/// </summary>
|
||||
protected abstract string GetModuleName();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resource prefix for filtering embedded resources.
|
||||
/// </summary>
|
||||
protected virtual string? GetResourcePrefix() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the PostgreSQL container and runs migrations.
|
||||
/// </summary>
|
||||
public virtual async Task InitializeAsync()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage(PostgresImage)
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
|
||||
var moduleName = GetModuleName();
|
||||
_fixture = PostgresFixtureFactory.Create(ConnectionString, moduleName, Logger);
|
||||
await _fixture.InitializeAsync();
|
||||
|
||||
var migrationAssembly = GetMigrationAssembly();
|
||||
if (migrationAssembly != null)
|
||||
{
|
||||
await _fixture.RunMigrationsFromAssemblyAsync(
|
||||
migrationAssembly,
|
||||
moduleName,
|
||||
GetResourcePrefix());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the PostgreSQL container and fixture.
|
||||
/// </summary>
|
||||
public virtual async Task DisposeAsync()
|
||||
{
|
||||
if (_fixture != null)
|
||||
{
|
||||
await _fixture.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_container != null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all tables in the test schema for test isolation between test methods.
|
||||
/// </summary>
|
||||
public Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
|
||||
=> Fixture.TruncateAllTablesAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Executes raw SQL for test setup.
|
||||
/// </summary>
|
||||
public Task ExecuteSqlAsync(string sql, CancellationToken cancellationToken = default)
|
||||
=> Fixture.ExecuteSqlAsync(sql, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration fixture without migrations.
|
||||
/// Useful for testing the infrastructure itself or creating schemas dynamically.
|
||||
/// </summary>
|
||||
public sealed class PostgresIntegrationFixtureWithoutMigrations : PostgresIntegrationFixture
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly() => null;
|
||||
protected override string GetModuleName() => "Test";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Infrastructure.Postgres.Testing</RootNamespace>
|
||||
<AssemblyName>StellaOps.Infrastructure.Postgres.Testing</AssemblyName>
|
||||
<Description>PostgreSQL test infrastructure for StellaOps module integration tests</Description>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
@@ -110,6 +111,85 @@ public sealed class MigrationRunner
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations from embedded resources in an assembly.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of migrations applied.</returns>
|
||||
public async Task<int> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(name => string.IsNullOrEmpty(resourcePrefix) || name.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name)
|
||||
.ToList();
|
||||
|
||||
if (resourceNames.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No embedded migration resources found in assembly {Assembly} for module {Module}.",
|
||||
assembly.GetName().Name, _moduleName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure schema exists
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure migrations table exists
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get applied migrations
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedCount = 0;
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
// Extract just the filename from the resource name
|
||||
var fileName = ExtractMigrationFileName(resourceName);
|
||||
|
||||
if (appliedMigrations.Contains(fileName))
|
||||
{
|
||||
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
|
||||
fileName, _moduleName);
|
||||
|
||||
await ApplyMigrationFromResourceAsync(connection, assembly, resourceName, fileName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
appliedCount++;
|
||||
|
||||
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
}
|
||||
|
||||
if (appliedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Applied {Count} embedded migration(s) for module {Module}.",
|
||||
appliedCount, _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
|
||||
}
|
||||
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current migration version (latest applied migration).
|
||||
/// </summary>
|
||||
@@ -270,6 +350,63 @@ public sealed class MigrationRunner
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationFromResourceAsync(
|
||||
NpgsqlConnection connection,
|
||||
Assembly assembly,
|
||||
string resourceName,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Could not load embedded resource: {resourceName}");
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var checksum = ComputeChecksum(sql);
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Run migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
|
||||
VALUES (@name, @checksum);
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", fileName);
|
||||
recordCommand.Parameters.AddWithValue("checksum", checksum);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractMigrationFileName(string resourceName)
|
||||
{
|
||||
// Resource names use the LogicalName from .csproj which is just the filename
|
||||
// e.g., "001_initial.sql" or might have path prefix like "Migrations/001_initial.sql"
|
||||
var lastSlash = resourceName.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
@@ -75,7 +76,7 @@ public sealed class PostgresFixture : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs migrations for the test schema.
|
||||
/// Runs migrations for the test schema from filesystem path.
|
||||
/// </summary>
|
||||
/// <param name="migrationsPath">Path to migration SQL files.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
@@ -94,6 +95,41 @@ public sealed class PostgresFixture : IAsyncDisposable
|
||||
await runner.RunAsync(migrationsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs migrations for the test schema from embedded resources in an assembly.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task RunMigrationsFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string moduleName,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runner = new MigrationRunner(
|
||||
_connectionString,
|
||||
_schemaName,
|
||||
moduleName,
|
||||
_logger);
|
||||
|
||||
await runner.RunFromAssemblyAsync(assembly, resourcePrefix, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs migrations for the test schema from embedded resources using a type from the assembly.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAssemblyMarker">Type from the assembly containing migrations.</typeparam>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task RunMigrationsFromAssemblyAsync<TAssemblyMarker>(
|
||||
string moduleName,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, moduleName, resourcePrefix, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Executes raw SQL for test setup.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user