Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for running database migrations.
|
||||
/// </summary>
|
||||
public interface IMigrationRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the schema name for this migration runner.
|
||||
/// </summary>
|
||||
string SchemaName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module name for this migration runner.
|
||||
/// </summary>
|
||||
string ModuleName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs pending migrations from the specified path.
|
||||
/// </summary>
|
||||
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
|
||||
/// <param name="options">Migration execution options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of migration execution.</returns>
|
||||
Task<MigrationResult> RunAsync(
|
||||
string migrationsPath,
|
||||
MigrationRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Runs 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.</param>
|
||||
/// <param name="options">Migration execution options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of migration execution.</returns>
|
||||
Task<MigrationResult> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
MigrationRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current migration version (latest applied migration).
|
||||
/// </summary>
|
||||
Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all applied migrations.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates checksums of applied migrations against source files.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of checksum validation errors, empty if all valid.</returns>
|
||||
Task<IReadOnlyList<string>> ValidateChecksumsAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for migration execution.
|
||||
/// </summary>
|
||||
public sealed class MigrationRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter migrations by category. If null, all categories are included.
|
||||
/// </summary>
|
||||
public MigrationCategory? CategoryFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, only show what would be executed without applying.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for individual migration execution. Default: 300.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// If true, validate checksums before applying new migrations.
|
||||
/// </summary>
|
||||
public bool ValidateChecksums { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// If true, fail if checksum validation errors are found.
|
||||
/// </summary>
|
||||
public bool FailOnChecksumMismatch { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a migration execution.
|
||||
/// </summary>
|
||||
public sealed class MigrationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the migration run was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of migrations applied.
|
||||
/// </summary>
|
||||
public int AppliedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of migrations skipped (already applied).
|
||||
/// </summary>
|
||||
public int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of migrations filtered out by category.
|
||||
/// </summary>
|
||||
public int FilteredCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration in milliseconds.
|
||||
/// </summary>
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Details of applied migrations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AppliedMigrationDetail> AppliedMigrations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Checksum validation errors, if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if migration failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static MigrationResult Successful(
|
||||
int appliedCount,
|
||||
int skippedCount,
|
||||
int filteredCount,
|
||||
long durationMs,
|
||||
IReadOnlyList<AppliedMigrationDetail> appliedMigrations) => new()
|
||||
{
|
||||
Success = true,
|
||||
AppliedCount = appliedCount,
|
||||
SkippedCount = skippedCount,
|
||||
FilteredCount = filteredCount,
|
||||
DurationMs = durationMs,
|
||||
AppliedMigrations = appliedMigrations
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static MigrationResult Failed(string errorMessage, IReadOnlyList<string>? checksumErrors = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage,
|
||||
ChecksumErrors = checksumErrors ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of an applied migration.
|
||||
/// </summary>
|
||||
public sealed record AppliedMigrationDetail(
|
||||
string Name,
|
||||
MigrationCategory Category,
|
||||
long DurationMs,
|
||||
bool WasDryRun);
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Categorizes migrations by when they should be executed.
|
||||
/// </summary>
|
||||
public enum MigrationCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatic migrations that run at application startup.
|
||||
/// Must be non-breaking and complete quickly (< 60s).
|
||||
/// Prefix: 001-099
|
||||
/// </summary>
|
||||
Startup,
|
||||
|
||||
/// <summary>
|
||||
/// Manual migrations that require CLI execution before deployment.
|
||||
/// Used for breaking changes that need coordination.
|
||||
/// Prefix: 100-199
|
||||
/// </summary>
|
||||
Release,
|
||||
|
||||
/// <summary>
|
||||
/// Seed data that is inserted once.
|
||||
/// Prefix: S001-S999
|
||||
/// </summary>
|
||||
Seed,
|
||||
|
||||
/// <summary>
|
||||
/// Long-running data migrations that run as background jobs.
|
||||
/// Prefix: DM001-DM999
|
||||
/// </summary>
|
||||
Data
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for migration category operations.
|
||||
/// </summary>
|
||||
public static class MigrationCategoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines the category of a migration based on its filename.
|
||||
/// </summary>
|
||||
public static MigrationCategory GetCategory(string migrationName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(migrationName);
|
||||
|
||||
// Extract numeric prefix
|
||||
var name = Path.GetFileNameWithoutExtension(migrationName);
|
||||
|
||||
if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MigrationCategory.Data;
|
||||
}
|
||||
|
||||
if (name.StartsWith("S", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(1)))
|
||||
{
|
||||
return MigrationCategory.Seed;
|
||||
}
|
||||
|
||||
// Try to parse leading digits
|
||||
var numericPrefix = new string(name.TakeWhile(char.IsDigit).ToArray());
|
||||
if (int.TryParse(numericPrefix, out var prefix))
|
||||
{
|
||||
return prefix switch
|
||||
{
|
||||
>= 1 and <= 99 => MigrationCategory.Startup,
|
||||
>= 100 and <= 199 => MigrationCategory.Release,
|
||||
>= 200 => MigrationCategory.Release,
|
||||
_ => MigrationCategory.Startup
|
||||
};
|
||||
}
|
||||
|
||||
// Default to startup for unknown patterns
|
||||
return MigrationCategory.Startup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this migration should run automatically at startup.
|
||||
/// </summary>
|
||||
public static bool IsAutomatic(this MigrationCategory category) =>
|
||||
category is MigrationCategory.Startup or MigrationCategory.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this migration requires manual CLI execution.
|
||||
/// </summary>
|
||||
public static bool RequiresManualExecution(this MigrationCategory category) =>
|
||||
category is MigrationCategory.Release or MigrationCategory.Data;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering migration services.
|
||||
/// </summary>
|
||||
public static class MigrationServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a startup migration host for the specified module.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">Options type containing the connection string.</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="migrationsAssembly">Assembly containing embedded SQL migrations.</param>
|
||||
/// <param name="connectionStringSelector">Function to extract connection string from options.</param>
|
||||
/// <param name="configureOptions">Optional configuration for migration behavior.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStartupMigrations<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
Func<TOptions, string> connectionStringSelector,
|
||||
Action<StartupMigrationOptions>? configureOptions = null)
|
||||
where TOptions : class
|
||||
{
|
||||
var migrationOptions = new StartupMigrationOptions();
|
||||
configureOptions?.Invoke(migrationOptions);
|
||||
|
||||
services.AddHostedService(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
|
||||
var connectionString = connectionStringSelector(options);
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
|
||||
var lifetime = sp.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
return new GenericStartupMigrationHost(
|
||||
connectionString,
|
||||
schemaName,
|
||||
moduleName,
|
||||
migrationsAssembly,
|
||||
logger,
|
||||
lifetime,
|
||||
migrationOptions);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a startup migration host using PostgresOptions.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddStartupMigrations(
|
||||
this IServiceCollection services,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
Action<StartupMigrationOptions>? configureOptions = null)
|
||||
{
|
||||
return services.AddStartupMigrations<PostgresOptions>(
|
||||
schemaName,
|
||||
moduleName,
|
||||
migrationsAssembly,
|
||||
options => options.ConnectionString,
|
||||
configureOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a migration runner as a singleton for manual/CLI migration execution.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMigrationRunner<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Func<TOptions, string> connectionStringSelector)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
|
||||
var connectionString = connectionStringSelector(options);
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
|
||||
|
||||
return new MigrationRunner(connectionString, schemaName, moduleName, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the migration status service for querying migration state.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMigrationStatus<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
Func<TOptions, string> connectionStringSelector)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddSingleton<IMigrationStatusService>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
|
||||
var connectionString = connectionStringSelector(options);
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"MigrationStatus.{moduleName}");
|
||||
|
||||
return new MigrationStatusService(
|
||||
connectionString,
|
||||
schemaName,
|
||||
moduleName,
|
||||
migrationsAssembly,
|
||||
logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class GenericStartupMigrationHost : StartupMigrationHost
|
||||
{
|
||||
public GenericStartupMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
StartupMigrationOptions options)
|
||||
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying migration status without applying migrations.
|
||||
/// </summary>
|
||||
public interface IMigrationStatusService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current migration status for the module.
|
||||
/// </summary>
|
||||
Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current migration status for a module.
|
||||
/// </summary>
|
||||
public sealed record MigrationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Module name.
|
||||
/// </summary>
|
||||
public required string ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema name.
|
||||
/// </summary>
|
||||
public required string SchemaName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of applied migrations.
|
||||
/// </summary>
|
||||
public int AppliedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of pending startup migrations.
|
||||
/// </summary>
|
||||
public int PendingStartupCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of pending release migrations.
|
||||
/// </summary>
|
||||
public int PendingReleaseCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last applied migration name.
|
||||
/// </summary>
|
||||
public string? LastAppliedMigration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the last migration was applied.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastAppliedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of pending migrations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PendingMigrationInfo> PendingMigrations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Any checksum mismatches detected.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the database is up to date (no pending startup/release migrations).
|
||||
/// </summary>
|
||||
public bool IsUpToDate => PendingStartupCount == 0 && PendingReleaseCount == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there are blocking issues (pending release migrations or checksum errors).
|
||||
/// </summary>
|
||||
public bool HasBlockingIssues => PendingReleaseCount > 0 || ChecksumErrors.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a pending migration.
|
||||
/// </summary>
|
||||
public sealed record PendingMigrationInfo(string Name, MigrationCategory Category);
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of migration status service.
|
||||
/// </summary>
|
||||
internal sealed class MigrationStatusService : IMigrationStatusService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly Assembly _migrationsAssembly;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public MigrationStatusService(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_schemaName = schemaName;
|
||||
_moduleName = moduleName;
|
||||
_migrationsAssembly = migrationsAssembly;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new Npgsql.NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if migrations table exists
|
||||
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var applied = new Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>(StringComparer.Ordinal);
|
||||
if (tableExists)
|
||||
{
|
||||
applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Load all migrations from assembly
|
||||
var allMigrations = LoadMigrationsFromAssembly();
|
||||
|
||||
// Find pending and validate checksums
|
||||
var pending = new List<PendingMigrationInfo>();
|
||||
var checksumErrors = new List<string>();
|
||||
|
||||
foreach (var migration in allMigrations)
|
||||
{
|
||||
if (applied.TryGetValue(migration.Name, out var appliedInfo))
|
||||
{
|
||||
if (!string.Equals(migration.Checksum, appliedInfo.Checksum, StringComparison.Ordinal))
|
||||
{
|
||||
checksumErrors.Add(
|
||||
$"Checksum mismatch for '{migration.Name}': " +
|
||||
$"expected '{migration.Checksum[..16]}...', found '{appliedInfo.Checksum[..16]}...'");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pending.Add(new PendingMigrationInfo(migration.Name, migration.Category));
|
||||
}
|
||||
}
|
||||
|
||||
var lastApplied = applied
|
||||
.OrderByDescending(kvp => kvp.Value.AppliedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new MigrationStatus
|
||||
{
|
||||
ModuleName = _moduleName,
|
||||
SchemaName = _schemaName,
|
||||
AppliedCount = applied.Count,
|
||||
PendingStartupCount = pending.Count(p => p.Category.IsAutomatic()),
|
||||
PendingReleaseCount = pending.Count(p => p.Category.RequiresManualExecution()),
|
||||
LastAppliedMigration = lastApplied.Key,
|
||||
LastAppliedAt = lastApplied.Key is not null ? lastApplied.Value.AppliedAt : null,
|
||||
PendingMigrations = pending,
|
||||
ChecksumErrors = checksumErrors
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> CheckMigrationsTableExistsAsync(
|
||||
Npgsql.NpgsqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = @schema
|
||||
AND table_name = 'schema_migrations'
|
||||
)
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("schema", _schemaName);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>> GetAppliedMigrationsAsync(
|
||||
Npgsql.NpgsqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<string, (string, DateTimeOffset)>(StringComparer.Ordinal);
|
||||
|
||||
await using var command = new Npgsql.NpgsqlCommand(
|
||||
$"SELECT migration_name, checksum, applied_at FROM {_schemaName}.schema_migrations",
|
||||
connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
result[reader.GetString(0)] = (reader.GetString(1), reader.GetFieldValue<DateTimeOffset>(2));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<(string Name, MigrationCategory Category, string Checksum)> LoadMigrationsFromAssembly()
|
||||
{
|
||||
var migrations = new List<(string, MigrationCategory, string)>();
|
||||
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = reader.ReadToEnd();
|
||||
|
||||
var fileName = ExtractFileName(resourceName);
|
||||
var category = MigrationCategoryExtensions.GetCategory(fileName);
|
||||
var checksum = ComputeChecksum(content);
|
||||
|
||||
migrations.Add((fileName, category, checksum));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private static string ExtractFileName(string resourceName)
|
||||
{
|
||||
var parts = resourceName.Split('.');
|
||||
for (var i = parts.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
|
||||
}
|
||||
}
|
||||
return resourceName;
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that runs database migrations at application startup.
|
||||
/// Uses advisory locks to prevent race conditions in multi-instance deployments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This service:
|
||||
/// - Acquires an advisory lock to prevent concurrent migrations
|
||||
/// - Validates checksums of already-applied migrations
|
||||
/// - Blocks startup if pending release migrations exist
|
||||
/// - Runs only Category A (startup) and seed migrations automatically
|
||||
/// </remarks>
|
||||
public abstract class StartupMigrationHost : IHostedService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly Assembly _migrationsAssembly;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly StartupMigrationOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new startup migration host.
|
||||
/// </summary>
|
||||
protected StartupMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
StartupMigrationOptions? options = null)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
|
||||
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
|
||||
_migrationsAssembly = migrationsAssembly ?? throw new ArgumentNullException(nameof(migrationsAssembly));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
|
||||
_options = options ?? new StartupMigrationOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Migration: Startup migrations disabled for {Module}.", _moduleName);
|
||||
return;
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Migration: Starting migration check for {Module}...", _moduleName);
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 1: Acquire advisory lock
|
||||
var lockKey = ComputeLockKey(_schemaName);
|
||||
if (!await TryAcquireLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Migration: Failed to acquire lock for {Module} within {Timeout}s. Another instance may be running migrations.",
|
||||
_moduleName, _options.LockTimeoutSeconds);
|
||||
|
||||
if (_options.FailOnLockTimeout)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw new InvalidOperationException(
|
||||
$"Could not acquire migration lock for {_moduleName} within {_options.LockTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Migration: Lock acquired for {Module}.", _moduleName);
|
||||
|
||||
try
|
||||
{
|
||||
// Step 2: Ensure schema and migrations table exist
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 3: Load and categorize migrations
|
||||
var allMigrations = LoadMigrationsFromAssembly();
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: Validate checksums
|
||||
var checksumErrors = ValidateChecksums(allMigrations, appliedMigrations);
|
||||
if (checksumErrors.Count > 0)
|
||||
{
|
||||
foreach (var error in checksumErrors)
|
||||
{
|
||||
_logger.LogError("Migration: {Error}", error);
|
||||
}
|
||||
|
||||
if (_options.FailOnChecksumMismatch)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw new InvalidOperationException(
|
||||
$"Migration checksum validation failed for {_moduleName}. See logs for details.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Check for pending release migrations
|
||||
var pendingRelease = allMigrations
|
||||
.Where(m => !appliedMigrations.ContainsKey(m.Name))
|
||||
.Where(m => m.Category.RequiresManualExecution())
|
||||
.ToList();
|
||||
|
||||
if (pendingRelease.Count > 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Migration: {Count} pending release migration(s) require manual execution for {Module}:",
|
||||
pendingRelease.Count, _moduleName);
|
||||
|
||||
foreach (var migration in pendingRelease)
|
||||
{
|
||||
_logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category);
|
||||
}
|
||||
|
||||
_logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName);
|
||||
|
||||
if (_options.FailOnPendingReleaseMigrations)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw new InvalidOperationException(
|
||||
$"Pending release migrations block startup for {_moduleName}. Run CLI migration first.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Execute pending startup migrations
|
||||
var pendingStartup = allMigrations
|
||||
.Where(m => !appliedMigrations.ContainsKey(m.Name))
|
||||
.Where(m => m.Category.IsAutomatic())
|
||||
.OrderBy(m => m.Name)
|
||||
.ToList();
|
||||
|
||||
if (pendingStartup.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("Migration: Database is up to date for {Module}.", _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Migration: {Count} pending startup migration(s) for {Module}.",
|
||||
pendingStartup.Count, _moduleName);
|
||||
|
||||
foreach (var migration in pendingStartup)
|
||||
{
|
||||
await ApplyMigrationAsync(connection, migration, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Migration: Applied {Count} migration(s) for {Module} in {Elapsed}ms.",
|
||||
pendingStartup.Count, _moduleName, sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Step 7: Release lock
|
||||
await ReleaseLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Migration: Lock released for {Module}.", _moduleName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not InvalidOperationException)
|
||||
{
|
||||
_logger.LogError(ex, "Migration: Failed for {Module}.", _moduleName);
|
||||
|
||||
if (_options.FailOnMigrationError)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private async Task<bool> TryAcquireLockAsync(
|
||||
NpgsqlConnection connection,
|
||||
long lockKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(_options.LockTimeoutSeconds);
|
||||
var deadline = DateTime.UtcNow.Add(timeout);
|
||||
var retryDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"SELECT pg_try_advisory_lock(@key)",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("key", lockKey);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (result is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Migration: Lock for {Module} is held by another instance, retrying in {Delay}ms...",
|
||||
_moduleName, retryDelay.TotalMilliseconds);
|
||||
|
||||
await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Exponential backoff up to 5 seconds
|
||||
retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 1.5, 5000));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task ReleaseLockAsync(NpgsqlConnection connection, long lockKey, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_unlock(@key)",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("key", lockKey);
|
||||
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"CREATE SCHEMA IF NOT EXISTS {_schemaName}",
|
||||
connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureMigrationsTableAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"""
|
||||
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
|
||||
migration_name TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL DEFAULT 'startup',
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
applied_by TEXT,
|
||||
duration_ms INT,
|
||||
CONSTRAINT valid_category CHECK (category IN ('startup', 'release', 'seed', 'data'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at
|
||||
ON {_schemaName}.schema_migrations(applied_at DESC);
|
||||
""",
|
||||
connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, AppliedMigration>> GetAppliedMigrationsAsync(
|
||||
NpgsqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<string, AppliedMigration>(StringComparer.Ordinal);
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT migration_name, category, checksum, applied_at FROM {_schemaName}.schema_migrations",
|
||||
connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
result[name] = new AppliedMigration(
|
||||
Name: name,
|
||||
Category: reader.GetString(1),
|
||||
Checksum: reader.GetString(2),
|
||||
AppliedAt: reader.GetFieldValue<DateTimeOffset>(3));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<PendingMigration> LoadMigrationsFromAssembly()
|
||||
{
|
||||
var migrations = new List<PendingMigration>();
|
||||
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = reader.ReadToEnd();
|
||||
|
||||
var fileName = ExtractFileName(resourceName);
|
||||
var category = MigrationCategoryExtensions.GetCategory(fileName);
|
||||
var checksum = ComputeChecksum(content);
|
||||
|
||||
migrations.Add(new PendingMigration(
|
||||
Name: fileName,
|
||||
ResourceName: resourceName,
|
||||
Category: category,
|
||||
Checksum: checksum,
|
||||
Content: content));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private List<string> ValidateChecksums(
|
||||
List<PendingMigration> allMigrations,
|
||||
Dictionary<string, AppliedMigration> appliedMigrations)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var migration in allMigrations)
|
||||
{
|
||||
if (appliedMigrations.TryGetValue(migration.Name, out var applied))
|
||||
{
|
||||
if (!string.Equals(migration.Checksum, applied.Checksum, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add(
|
||||
$"Checksum mismatch for '{migration.Name}': " +
|
||||
$"expected '{migration.Checksum[..16]}...', found '{applied.Checksum[..16]}...'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
NpgsqlConnection connection,
|
||||
PendingMigration migration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Migration: Applying {Migration} ({Category})...",
|
||||
migration.Name, migration.Category);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Execute migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(migration.Content, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = _options.MigrationTimeoutSeconds;
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations
|
||||
(migration_name, category, checksum, duration_ms, applied_by)
|
||||
VALUES (@name, @category, @checksum, @duration, @applied_by)
|
||||
ON CONFLICT (migration_name) DO NOTHING
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", migration.Name);
|
||||
recordCommand.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant());
|
||||
recordCommand.Parameters.AddWithValue("checksum", migration.Checksum);
|
||||
recordCommand.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds);
|
||||
recordCommand.Parameters.AddWithValue("applied_by", Environment.MachineName);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Migration: {Migration} completed in {Duration}ms.",
|
||||
migration.Name, sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static long ComputeLockKey(string schemaName)
|
||||
{
|
||||
// Use a deterministic hash of the schema name as the lock key
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(schemaName));
|
||||
return BitConverter.ToInt64(hash, 0);
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
// Normalize line endings for consistent checksums across platforms
|
||||
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string ExtractFileName(string resourceName)
|
||||
{
|
||||
var lastSlash = resourceName.LastIndexOf('/');
|
||||
var lastDot = resourceName.LastIndexOf('.');
|
||||
|
||||
// Handle namespace-style resource names (e.g., "Namespace.Migrations.001_schema.sql")
|
||||
if (lastSlash < 0)
|
||||
{
|
||||
// Find the filename by looking for pattern like "001_" or "S001_"
|
||||
var parts = resourceName.Split('.');
|
||||
for (var i = parts.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
|
||||
}
|
||||
|
||||
private record AppliedMigration(string Name, string Category, string Checksum, DateTimeOffset AppliedAt);
|
||||
private record PendingMigration(string Name, string ResourceName, MigrationCategory Category, string Checksum, string Content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for startup migration behavior.
|
||||
/// </summary>
|
||||
public sealed class StartupMigrationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to run migrations at startup. Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for acquiring the advisory lock. Default: 120.
|
||||
/// </summary>
|
||||
public int LockTimeoutSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for individual migration execution. Default: 300.
|
||||
/// </summary>
|
||||
public int MigrationTimeoutSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if lock cannot be acquired. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnLockTimeout { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if checksum validation fails. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnChecksumMismatch { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if there are pending release migrations. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnPendingReleaseMigrations { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if migration execution fails. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnMigrationError { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
|
||||
|
||||
public class MigrationCategoryTests
|
||||
{
|
||||
#region GetCategory Tests - Startup Migrations (001-099)
|
||||
|
||||
[Theory]
|
||||
[InlineData("001_initial_schema.sql", MigrationCategory.Startup)]
|
||||
[InlineData("001_initial_schema", MigrationCategory.Startup)]
|
||||
[InlineData("01_short_prefix.sql", MigrationCategory.Startup)]
|
||||
[InlineData("1_single_digit.sql", MigrationCategory.Startup)]
|
||||
[InlineData("050_middle_range.sql", MigrationCategory.Startup)]
|
||||
[InlineData("099_last_startup.sql", MigrationCategory.Startup)]
|
||||
public void GetCategory_StartupMigrations_ReturnsStartup(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Release Migrations (100-199, 200+)
|
||||
|
||||
[Theory]
|
||||
[InlineData("100_drop_legacy_columns.sql", MigrationCategory.Release)]
|
||||
[InlineData("150_rename_table.sql", MigrationCategory.Release)]
|
||||
[InlineData("199_last_release.sql", MigrationCategory.Release)]
|
||||
[InlineData("200_major_version.sql", MigrationCategory.Release)]
|
||||
[InlineData("250_another_major.sql", MigrationCategory.Release)]
|
||||
[InlineData("999_very_high_number.sql", MigrationCategory.Release)]
|
||||
public void GetCategory_ReleaseMigrations_ReturnsRelease(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Seed Migrations (S001-S999)
|
||||
|
||||
[Theory]
|
||||
[InlineData("S001_default_roles.sql", MigrationCategory.Seed)]
|
||||
[InlineData("S100_builtin_policies.sql", MigrationCategory.Seed)]
|
||||
[InlineData("S999_last_seed.sql", MigrationCategory.Seed)]
|
||||
[InlineData("s001_lowercase.sql", MigrationCategory.Seed)]
|
||||
public void GetCategory_SeedMigrations_ReturnsSeed(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Schema_setup.sql")] // S followed by non-digit
|
||||
[InlineData("Setup_tables.sql")]
|
||||
public void GetCategory_SPrefix_NotFollowedByDigit_ReturnsStartup(string migrationName)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(MigrationCategory.Startup);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Data Migrations (DM001-DM999)
|
||||
|
||||
[Theory]
|
||||
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
|
||||
[InlineData("DM100_MigrateUserPrefs.sql", MigrationCategory.Data)]
|
||||
[InlineData("DM999_FinalDataMigration.sql", MigrationCategory.Data)]
|
||||
[InlineData("dm001_lowercase.sql", MigrationCategory.Data)]
|
||||
public void GetCategory_DataMigrations_ReturnsData(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_NullMigrationName_ThrowsArgumentNullException()
|
||||
{
|
||||
var act = () => MigrationCategoryExtensions.GetCategory(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_EmptyMigrationName_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => MigrationCategoryExtensions.GetCategory(string.Empty);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_WhitespaceMigrationName_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => MigrationCategoryExtensions.GetCategory(" ");
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("no_prefix_migration.sql", MigrationCategory.Startup)]
|
||||
[InlineData("migration.sql", MigrationCategory.Startup)]
|
||||
[InlineData("abc_123.sql", MigrationCategory.Startup)]
|
||||
public void GetCategory_UnknownPattern_DefaultsToStartup(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("migrations/subfolder/001_test.sql", MigrationCategory.Startup)]
|
||||
[InlineData("100_release.SQL", MigrationCategory.Release)] // Different extension case
|
||||
[InlineData("001_test", MigrationCategory.Startup)] // No extension
|
||||
public void GetCategory_PathVariations_ExtractsCorrectly(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_ZeroPrefix_ReturnsStartup()
|
||||
{
|
||||
// 0 should default to Startup as per the switch expression
|
||||
var result = MigrationCategoryExtensions.GetCategory("0_zero_prefix.sql");
|
||||
result.Should().Be(MigrationCategory.Startup);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAutomatic Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(MigrationCategory.Startup, true)]
|
||||
[InlineData(MigrationCategory.Seed, true)]
|
||||
[InlineData(MigrationCategory.Release, false)]
|
||||
[InlineData(MigrationCategory.Data, false)]
|
||||
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
|
||||
{
|
||||
var result = category.IsAutomatic();
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RequiresManualExecution Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(MigrationCategory.Startup, false)]
|
||||
[InlineData(MigrationCategory.Seed, false)]
|
||||
[InlineData(MigrationCategory.Release, true)]
|
||||
[InlineData(MigrationCategory.Data, true)]
|
||||
public void RequiresManualExecution_ReturnsExpectedValue(MigrationCategory category, bool expected)
|
||||
{
|
||||
var result = category.RequiresManualExecution();
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAutomatic and RequiresManualExecution are Mutually Exclusive
|
||||
|
||||
[Theory]
|
||||
[InlineData(MigrationCategory.Startup)]
|
||||
[InlineData(MigrationCategory.Release)]
|
||||
[InlineData(MigrationCategory.Seed)]
|
||||
[InlineData(MigrationCategory.Data)]
|
||||
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category)
|
||||
{
|
||||
var isAutomatic = category.IsAutomatic();
|
||||
var requiresManual = category.RequiresManualExecution();
|
||||
|
||||
// They should be opposite of each other
|
||||
(isAutomatic ^ requiresManual).Should().BeTrue(
|
||||
$"Category {category} should be either automatic OR manual, not both or neither");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World Migration Name Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("001_create_auth_schema.sql", MigrationCategory.Startup)]
|
||||
[InlineData("002_create_tenants_table.sql", MigrationCategory.Startup)]
|
||||
[InlineData("003_create_users_table.sql", MigrationCategory.Startup)]
|
||||
[InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)]
|
||||
[InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)]
|
||||
[InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)]
|
||||
[InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)]
|
||||
[InlineData("S002_system_permissions.sql", MigrationCategory.Seed)]
|
||||
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
|
||||
[InlineData("DM002_MigratePasswordHashes.sql", MigrationCategory.Data)]
|
||||
public void GetCategory_RealWorldMigrationNames_CategorizesCorrectly(
|
||||
string migrationName,
|
||||
MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for StartupMigrationHost.
|
||||
/// Uses Testcontainers to spin up a real PostgreSQL instance.
|
||||
/// </summary>
|
||||
public sealed class StartupMigrationHostTests : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer? _container;
|
||||
private string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not initialized");
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container != null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region Migration Execution Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithPendingStartupMigrations_AppliesThem()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
// FailOnPendingReleaseMigrations = false because test assembly includes release migrations
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Check that migrations table has records
|
||||
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
|
||||
appliedCount.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify specific startup/seed migrations were applied (not release)
|
||||
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
|
||||
migrations.Should().Contain("001_create_test_table.sql");
|
||||
migrations.Should().Contain("002_add_column.sql");
|
||||
migrations.Should().Contain("S001_seed_data.sql");
|
||||
migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithAlreadyAppliedMigrations_SkipsThem()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
|
||||
// First run - apply migrations
|
||||
var host1 = CreateTestHost(schemaName, options: options);
|
||||
await host1.StartAsync(CancellationToken.None);
|
||||
|
||||
var initialCount = await GetAppliedMigrationCountAsync(schemaName);
|
||||
|
||||
// Second run - should skip already applied
|
||||
var host2 = CreateTestHost(schemaName, options: options);
|
||||
await host2.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - count should remain the same
|
||||
var finalCount = await GetAppliedMigrationCountAsync(schemaName);
|
||||
finalCount.Should().Be(initialCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CreatesSchemaAndMigrationsTable()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - schema should exist
|
||||
var schemaExists = await SchemaExistsAsync(schemaName);
|
||||
schemaExists.Should().BeTrue();
|
||||
|
||||
// Assert - migrations table should exist
|
||||
var tableExists = await TableExistsAsync(schemaName, "schema_migrations");
|
||||
tableExists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithDisabled_SkipsMigrations()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { Enabled = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - schema should NOT be created
|
||||
var schemaExists = await SchemaExistsAsync(schemaName);
|
||||
schemaExists.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Release Migration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithPendingReleaseMigrations_ThrowsAndStopsApplication()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var lifetimeMock = new Mock<IHostApplicationLifetime>();
|
||||
// Default FailOnPendingReleaseMigrations = true, which should cause failure
|
||||
// because the test assembly includes 100_release_migration.sql
|
||||
var host = CreateTestHost(schemaName, lifetime: lifetimeMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => host.StartAsync(CancellationToken.None));
|
||||
|
||||
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithPendingReleaseMigrations_WhenFailOnPendingFalse_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act - should not throw
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - startup migrations should still be applied
|
||||
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
|
||||
migrations.Should().Contain("001_create_test_table.sql");
|
||||
migrations.Should().NotContain("100_release_migration.sql"); // Release not applied automatically
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Checksum Validation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithChecksumMismatch_ThrowsAndStopsApplication()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
|
||||
// First, apply migrations normally
|
||||
var host1 = CreateTestHost(schemaName, options: options);
|
||||
await host1.StartAsync(CancellationToken.None);
|
||||
|
||||
// Corrupt a checksum in the database
|
||||
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
|
||||
|
||||
// Try to run again with checksum validation (and still ignore release migrations)
|
||||
var lifetimeMock = new Mock<IHostApplicationLifetime>();
|
||||
var options2 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host2 = CreateTestHost(schemaName, options: options2, lifetime: lifetimeMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => host2.StartAsync(CancellationToken.None));
|
||||
|
||||
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithChecksumMismatch_WhenFailOnMismatchFalse_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options1 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
|
||||
// First, apply migrations normally
|
||||
var host1 = CreateTestHost(schemaName, options: options1);
|
||||
await host1.StartAsync(CancellationToken.None);
|
||||
|
||||
// Corrupt a checksum
|
||||
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
|
||||
|
||||
// Try with checksum mismatch allowed
|
||||
var options2 = new StartupMigrationOptions
|
||||
{
|
||||
FailOnChecksumMismatch = false,
|
||||
FailOnPendingReleaseMigrations = false
|
||||
};
|
||||
var host2 = CreateTestHost(schemaName, options: options2);
|
||||
|
||||
// Act - should not throw
|
||||
await host2.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Advisory Lock Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_MultipleConcurrentInstances_OnlyOneRunsMigrations()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var runCount = 0;
|
||||
var lockObject = new object();
|
||||
|
||||
// Create 5 concurrent hosts
|
||||
var tasks = Enumerable.Range(0, 5)
|
||||
.Select(_ =>
|
||||
{
|
||||
var host = CreateTestHost(
|
||||
schemaName,
|
||||
options: new StartupMigrationOptions
|
||||
{
|
||||
LockTimeoutSeconds = 30,
|
||||
FailOnPendingReleaseMigrations = false
|
||||
});
|
||||
return host.StartAsync(CancellationToken.None)
|
||||
.ContinueWith(_ =>
|
||||
{
|
||||
lock (lockObject) { runCount++; }
|
||||
});
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - all should complete, but migrations applied only once
|
||||
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
|
||||
migrations.Should().Contain("001_create_test_table.sql");
|
||||
|
||||
// Each migration should only appear once in the table
|
||||
var counts = await GetMigrationAppliedCountsAsync(schemaName);
|
||||
foreach (var count in counts.Values)
|
||||
{
|
||||
count.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_LockTimeout_ThrowsWhenFailOnLockTimeoutTrue()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions
|
||||
{
|
||||
LockTimeoutSeconds = 1,
|
||||
FailOnLockTimeout = true
|
||||
};
|
||||
|
||||
// Hold the lock manually
|
||||
await using var lockConn = new NpgsqlConnection(ConnectionString);
|
||||
await lockConn.OpenAsync();
|
||||
|
||||
var lockKey = ComputeLockKey(schemaName);
|
||||
await using var lockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_lock(@key)", lockConn);
|
||||
lockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var lifetimeMock = new Mock<IHostApplicationLifetime>();
|
||||
var host = CreateTestHost(schemaName, options: options, lifetime: lifetimeMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => host.StartAsync(CancellationToken.None));
|
||||
|
||||
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release the lock
|
||||
await using var unlockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_unlock(@key)", lockConn);
|
||||
unlockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await unlockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_LockTimeout_DoesNotThrowWhenFailOnLockTimeoutFalse()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions
|
||||
{
|
||||
LockTimeoutSeconds = 1,
|
||||
FailOnLockTimeout = false
|
||||
};
|
||||
|
||||
// Hold the lock manually
|
||||
await using var lockConn = new NpgsqlConnection(ConnectionString);
|
||||
await lockConn.OpenAsync();
|
||||
|
||||
var lockKey = ComputeLockKey(schemaName);
|
||||
await using var lockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_lock(@key)", lockConn);
|
||||
lockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act - should not throw, just skip migrations
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - schema should NOT be created since lock wasn't acquired
|
||||
var schemaExists = await SchemaExistsAsync(schemaName);
|
||||
schemaExists.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release the lock
|
||||
await using var unlockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_unlock(@key)", lockConn);
|
||||
unlockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await unlockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Migration Recording Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_RecordsMigrationMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - check metadata is recorded
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT category, checksum, duration_ms, applied_by FROM {schemaName}.schema_migrations WHERE migration_name = '001_create_test_table.sql'",
|
||||
conn);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
reader.Read().Should().BeTrue();
|
||||
|
||||
reader.GetString(0).Should().Be("startup"); // category
|
||||
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
|
||||
reader.GetInt32(2).Should().BeGreaterOrEqualTo(0); // duration_ms
|
||||
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategory()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT category FROM {schemaName}.schema_migrations WHERE migration_name = 'S001_seed_data.sql'",
|
||||
conn);
|
||||
|
||||
var category = await cmd.ExecuteScalarAsync();
|
||||
category.Should().Be("seed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private TestMigrationHost CreateTestHost(
|
||||
string schemaName,
|
||||
StartupMigrationOptions? options = null,
|
||||
IHostApplicationLifetime? lifetime = null)
|
||||
{
|
||||
return new TestMigrationHost(
|
||||
connectionString: ConnectionString,
|
||||
schemaName: schemaName,
|
||||
moduleName: "Test",
|
||||
migrationsAssembly: typeof(StartupMigrationHostTests).Assembly,
|
||||
logger: NullLogger<TestMigrationHost>.Instance,
|
||||
lifetime: lifetime ?? CreateMockLifetime(),
|
||||
options: options);
|
||||
}
|
||||
|
||||
private static IHostApplicationLifetime CreateMockLifetime()
|
||||
{
|
||||
var mock = new Mock<IHostApplicationLifetime>();
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private async Task<int> GetAppliedMigrationCountAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT COUNT(*) FROM {schemaName}.schema_migrations",
|
||||
conn);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private async Task<List<string>> GetAppliedMigrationNamesAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT migration_name FROM {schemaName}.schema_migrations ORDER BY migration_name",
|
||||
conn);
|
||||
|
||||
var names = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
names.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, int>> GetMigrationAppliedCountsAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT migration_name, COUNT(*) FROM {schemaName}.schema_migrations GROUP BY migration_name",
|
||||
conn);
|
||||
|
||||
var counts = new Dictionary<string, int>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
counts[reader.GetString(0)] = Convert.ToInt32(reader.GetInt64(1));
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
private async Task<bool> SchemaExistsAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("name", schemaName);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task<bool> TableExistsAsync(string schemaName, string tableName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table)",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("schema", schemaName);
|
||||
cmd.Parameters.AddWithValue("table", tableName);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task CorruptChecksumAsync(string schemaName, string migrationName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"UPDATE {schemaName}.schema_migrations SET checksum = 'corrupted_checksum' WHERE migration_name = @name",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("name", migrationName);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static long ComputeLockKey(string schemaName)
|
||||
{
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(schemaName));
|
||||
return BitConverter.ToInt64(hash, 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete test implementation of StartupMigrationHost.
|
||||
/// Uses embedded resources from the test assembly.
|
||||
/// </summary>
|
||||
internal sealed class TestMigrationHost : StartupMigrationHost
|
||||
{
|
||||
public TestMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
StartupMigrationOptions? options)
|
||||
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: 001_create_test_table
|
||||
-- Category: startup
|
||||
-- Description: Create initial test table
|
||||
|
||||
CREATE TABLE IF NOT EXISTS test_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: 002_add_column
|
||||
-- Category: startup
|
||||
-- Description: Add description column to test table
|
||||
|
||||
ALTER TABLE test_table ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: 100_release_migration
|
||||
-- Category: release
|
||||
-- Description: A release migration that requires manual execution
|
||||
|
||||
ALTER TABLE test_table DROP COLUMN IF EXISTS deprecated_column;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Migration: S001_seed_data
|
||||
-- Category: seed
|
||||
-- Description: Insert seed data
|
||||
|
||||
INSERT INTO test_table (name, description)
|
||||
VALUES ('seed1', 'First seed record')
|
||||
ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user