This commit is contained in:
StellaOps Bot
2025-11-29 02:19:50 +02:00
parent 2548abc56f
commit b34f13dc03
86 changed files with 9625 additions and 640 deletions

View File

@@ -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";
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>