save progress
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// <copyright file="Models.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
|
||||
// Task: CCUT-006, CCUT-007
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Testing.SchemaEvolution;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a schema version.
|
||||
/// </summary>
|
||||
/// <param name="VersionId">Version identifier (e.g., "v2024.11", "v2024.12").</param>
|
||||
/// <param name="MigrationId">Migration identifier if applicable.</param>
|
||||
/// <param name="AppliedAt">When this version was applied.</param>
|
||||
public sealed record SchemaVersion(
|
||||
string VersionId,
|
||||
string? MigrationId,
|
||||
DateTimeOffset AppliedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Result of schema compatibility test.
|
||||
/// </summary>
|
||||
/// <param name="IsCompatible">Whether the test passed.</param>
|
||||
/// <param name="BaselineVersion">Schema version used as baseline.</param>
|
||||
/// <param name="TargetVersion">Target schema version tested against.</param>
|
||||
/// <param name="TestedOperation">Type of operation tested.</param>
|
||||
/// <param name="ErrorMessage">Error message if not compatible.</param>
|
||||
/// <param name="Exception">Exception if one occurred.</param>
|
||||
public sealed record SchemaCompatibilityResult(
|
||||
bool IsCompatible,
|
||||
string BaselineVersion,
|
||||
string TargetVersion,
|
||||
SchemaOperationType TestedOperation,
|
||||
string? ErrorMessage = null,
|
||||
Exception? Exception = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of schema operation tested.
|
||||
/// </summary>
|
||||
public enum SchemaOperationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Read operation (SELECT).
|
||||
/// </summary>
|
||||
Read,
|
||||
|
||||
/// <summary>
|
||||
/// Write operation (INSERT/UPDATE).
|
||||
/// </summary>
|
||||
Write,
|
||||
|
||||
/// <summary>
|
||||
/// Delete operation (DELETE).
|
||||
/// </summary>
|
||||
Delete,
|
||||
|
||||
/// <summary>
|
||||
/// Migration forward (upgrade).
|
||||
/// </summary>
|
||||
MigrationUp,
|
||||
|
||||
/// <summary>
|
||||
/// Migration rollback (downgrade).
|
||||
/// </summary>
|
||||
MigrationDown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for schema evolution tests.
|
||||
/// </summary>
|
||||
/// <param name="SupportedVersions">Versions to test compatibility with.</param>
|
||||
/// <param name="CurrentVersion">Current schema version.</param>
|
||||
/// <param name="BackwardCompatibilityVersionCount">Number of previous versions to test backward compatibility.</param>
|
||||
/// <param name="ForwardCompatibilityVersionCount">Number of future versions to test forward compatibility.</param>
|
||||
/// <param name="TimeoutPerTest">Timeout per individual test.</param>
|
||||
public sealed record SchemaEvolutionConfig(
|
||||
ImmutableArray<string> SupportedVersions,
|
||||
string CurrentVersion,
|
||||
int BackwardCompatibilityVersionCount = 2,
|
||||
int ForwardCompatibilityVersionCount = 1,
|
||||
TimeSpan TimeoutPerTest = default)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the timeout per test.
|
||||
/// </summary>
|
||||
public TimeSpan TimeoutPerTest { get; init; } =
|
||||
TimeoutPerTest == default ? TimeSpan.FromMinutes(5) : TimeoutPerTest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a database migration.
|
||||
/// </summary>
|
||||
/// <param name="MigrationId">Unique migration identifier.</param>
|
||||
/// <param name="Version">Version this migration belongs to.</param>
|
||||
/// <param name="Description">Human-readable description.</param>
|
||||
/// <param name="HasUpScript">Whether up migration script exists.</param>
|
||||
/// <param name="HasDownScript">Whether down migration script exists.</param>
|
||||
/// <param name="AppliedAt">When the migration was applied.</param>
|
||||
public sealed record MigrationInfo(
|
||||
string MigrationId,
|
||||
string Version,
|
||||
string Description,
|
||||
bool HasUpScript,
|
||||
bool HasDownScript,
|
||||
DateTimeOffset? AppliedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Result of testing migration rollback.
|
||||
/// </summary>
|
||||
/// <param name="Migration">Migration that was tested.</param>
|
||||
/// <param name="Success">Whether rollback succeeded.</param>
|
||||
/// <param name="DurationMs">Duration of rollback in milliseconds.</param>
|
||||
/// <param name="ErrorMessage">Error message if rollback failed.</param>
|
||||
public sealed record MigrationRollbackResult(
|
||||
MigrationInfo Migration,
|
||||
bool Success,
|
||||
long DurationMs,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Test data seeding result.
|
||||
/// </summary>
|
||||
/// <param name="SchemaVersion">Schema version data was seeded for.</param>
|
||||
/// <param name="RecordsSeeded">Number of records seeded.</param>
|
||||
/// <param name="DurationMs">Duration of seeding in milliseconds.</param>
|
||||
public sealed record SeedDataResult(
|
||||
string SchemaVersion,
|
||||
int RecordsSeeded,
|
||||
long DurationMs);
|
||||
|
||||
/// <summary>
|
||||
/// Report of schema evolution test suite.
|
||||
/// </summary>
|
||||
/// <param name="TotalTests">Total number of tests executed.</param>
|
||||
/// <param name="PassedTests">Number of passed tests.</param>
|
||||
/// <param name="FailedTests">Number of failed tests.</param>
|
||||
/// <param name="SkippedTests">Number of skipped tests.</param>
|
||||
/// <param name="Results">Individual test results.</param>
|
||||
/// <param name="TotalDurationMs">Total duration in milliseconds.</param>
|
||||
public sealed record SchemaEvolutionReport(
|
||||
int TotalTests,
|
||||
int PassedTests,
|
||||
int FailedTests,
|
||||
int SkippedTests,
|
||||
ImmutableArray<SchemaCompatibilityResult> Results,
|
||||
long TotalDurationMs)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether all tests passed.
|
||||
/// </summary>
|
||||
public bool IsSuccess => FailedTests == 0;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// <copyright file="PostgresSchemaEvolutionTestBase.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
|
||||
// Task: CCUT-007, CCUT-008
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace StellaOps.Testing.SchemaEvolution;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-based schema evolution test base using Testcontainers.
|
||||
/// </summary>
|
||||
public abstract class PostgresSchemaEvolutionTestBase : SchemaEvolutionTestBase
|
||||
{
|
||||
private readonly Dictionary<string, PostgreSqlContainer> _containers = new();
|
||||
private readonly SemaphoreSlim _containerLock = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresSchemaEvolutionTestBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
protected PostgresSchemaEvolutionTestBase(ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema versions available for testing.
|
||||
/// </summary>
|
||||
protected abstract IReadOnlyList<string> AvailableSchemaVersions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL image tag for a schema version.
|
||||
/// Override to use version-specific images.
|
||||
/// </summary>
|
||||
/// <param name="schemaVersion">Schema version.</param>
|
||||
/// <returns>Docker image tag.</returns>
|
||||
protected virtual string GetPostgresImageTag(string schemaVersion)
|
||||
{
|
||||
// Default to standard PostgreSQL 16
|
||||
return "postgres:16-alpine";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string GetPreviousSchemaVersion(string current)
|
||||
{
|
||||
var index = AvailableSchemaVersions.ToList().IndexOf(current);
|
||||
if (index <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No previous version available for {current}");
|
||||
}
|
||||
|
||||
return AvailableSchemaVersions[index - 1];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<string> CreateDatabaseWithSchemaAsync(string schemaVersion, CancellationToken ct)
|
||||
{
|
||||
await _containerLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_containers.TryGetValue(schemaVersion, out var existing))
|
||||
{
|
||||
return existing.GetConnectionString();
|
||||
}
|
||||
|
||||
var container = new PostgreSqlBuilder()
|
||||
.WithImage(GetPostgresImageTag(schemaVersion))
|
||||
.WithDatabase($"test_{schemaVersion.Replace(".", "_")}")
|
||||
.WithUsername("test")
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
|
||||
await container.StartAsync(ct);
|
||||
|
||||
// Apply migrations up to specified version
|
||||
var connectionString = container.GetConnectionString();
|
||||
await ApplyMigrationsToVersionAsync(connectionString, schemaVersion, ct);
|
||||
|
||||
_containers[schemaVersion] = container;
|
||||
return connectionString;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_containerLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply migrations up to a specific version.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">Database connection string.</param>
|
||||
/// <param name="targetVersion">Target schema version.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Task representing the async operation.</returns>
|
||||
protected abstract Task ApplyMigrationsToVersionAsync(
|
||||
string connectionString,
|
||||
string targetVersion,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<IReadOnlyList<MigrationInfo>> GetMigrationHistoryAsync(CancellationToken ct)
|
||||
{
|
||||
// Default implementation queries the migration history table
|
||||
// Subclasses should override for their specific migration tool
|
||||
var migrations = new List<MigrationInfo>();
|
||||
|
||||
if (DataSource == null)
|
||||
{
|
||||
return migrations;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = DataSource.CreateCommand(
|
||||
"SELECT migration_id, version, description, applied_at FROM __migrations ORDER BY applied_at");
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
migrations.Add(new MigrationInfo(
|
||||
MigrationId: reader.GetString(0),
|
||||
Version: reader.GetString(1),
|
||||
Description: reader.GetString(2),
|
||||
HasUpScript: true, // Assume up script exists if migration was applied
|
||||
HasDownScript: await CheckDownScriptExistsAsync(reader.GetString(0), ct),
|
||||
AppliedAt: reader.GetDateTime(3)));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Migration table may not exist in older versions
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a down script exists for a migration.
|
||||
/// </summary>
|
||||
/// <param name="migrationId">Migration identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if down script exists.</returns>
|
||||
protected virtual Task<bool> CheckDownScriptExistsAsync(string migrationId, CancellationToken ct)
|
||||
{
|
||||
// Default: assume down scripts exist
|
||||
// Subclasses should override to check actual migration files
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task ApplyMigrationDownAsync(
|
||||
NpgsqlDataSource dataSource,
|
||||
MigrationInfo migration,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var downScript = await GetMigrationDownScriptAsync(migration.MigrationId, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(downScript))
|
||||
{
|
||||
throw new InvalidOperationException($"No down script found for migration {migration.MigrationId}");
|
||||
}
|
||||
|
||||
await using var cmd = dataSource.CreateCommand(downScript);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the down script for a migration.
|
||||
/// </summary>
|
||||
/// <param name="migrationId">Migration identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Down script SQL.</returns>
|
||||
protected abstract Task<string?> GetMigrationDownScriptAsync(string migrationId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Dispose resources.
|
||||
/// </summary>
|
||||
/// <returns>ValueTask representing the async operation.</returns>
|
||||
public new async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _containerLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
foreach (var container in _containers.Values)
|
||||
{
|
||||
await container.DisposeAsync();
|
||||
}
|
||||
|
||||
_containers.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_containerLock.Release();
|
||||
_containerLock.Dispose();
|
||||
}
|
||||
|
||||
await base.DisposeAsync();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// <copyright file="SchemaEvolutionTestBase.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
|
||||
// Task: CCUT-007
|
||||
|
||||
using System.Diagnostics;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Testing.SchemaEvolution;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for schema evolution tests that verify backward/forward compatibility.
|
||||
/// </summary>
|
||||
public abstract class SchemaEvolutionTestBase : IAsyncDisposable
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SchemaEvolutionTestBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
protected SchemaEvolutionTestBase(ILogger? logger = null)
|
||||
{
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current schema version.
|
||||
/// </summary>
|
||||
protected string? CurrentSchemaVersion { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the data source for the current test database.
|
||||
/// </summary>
|
||||
protected NpgsqlDataSource? DataSource => _dataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the test environment.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Task representing the async operation.</returns>
|
||||
public virtual async Task InitializeAsync(CancellationToken ct = default)
|
||||
{
|
||||
CurrentSchemaVersion = await GetCurrentSchemaVersionAsync(ct);
|
||||
_logger.LogInformation("Schema evolution test initialized. Current version: {Version}", CurrentSchemaVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test current code against schema version N-1.
|
||||
/// </summary>
|
||||
/// <param name="testAction">Test action to execute.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Compatibility result.</returns>
|
||||
protected async Task<SchemaCompatibilityResult> TestAgainstPreviousSchemaAsync(
|
||||
Func<NpgsqlDataSource, Task> testAction,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (CurrentSchemaVersion == null)
|
||||
{
|
||||
throw new InvalidOperationException("Call InitializeAsync first");
|
||||
}
|
||||
|
||||
var previousVersion = GetPreviousSchemaVersion(CurrentSchemaVersion);
|
||||
return await TestAgainstSchemaVersionAsync(previousVersion, SchemaOperationType.Read, testAction, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test current code against specific schema version.
|
||||
/// </summary>
|
||||
/// <param name="schemaVersion">Schema version to test against.</param>
|
||||
/// <param name="operationType">Type of operation being tested.</param>
|
||||
/// <param name="testAction">Test action to execute.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Compatibility result.</returns>
|
||||
protected async Task<SchemaCompatibilityResult> TestAgainstSchemaVersionAsync(
|
||||
string schemaVersion,
|
||||
SchemaOperationType operationType,
|
||||
Func<NpgsqlDataSource, Task> testAction,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Testing against schema version {SchemaVersion} (operation: {Operation})",
|
||||
schemaVersion, operationType);
|
||||
|
||||
try
|
||||
{
|
||||
// Create isolated database with specific schema
|
||||
var connectionString = await CreateDatabaseWithSchemaAsync(schemaVersion, ct);
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
_dataSource = dataSource;
|
||||
|
||||
// Execute test
|
||||
await testAction(dataSource);
|
||||
|
||||
_logger.LogInformation("Schema compatibility test passed for version {Version}", schemaVersion);
|
||||
|
||||
return new SchemaCompatibilityResult(
|
||||
IsCompatible: true,
|
||||
BaselineVersion: CurrentSchemaVersion ?? "unknown",
|
||||
TargetVersion: schemaVersion,
|
||||
TestedOperation: operationType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Schema compatibility test failed for version {Version}", schemaVersion);
|
||||
|
||||
return new SchemaCompatibilityResult(
|
||||
IsCompatible: false,
|
||||
BaselineVersion: CurrentSchemaVersion ?? "unknown",
|
||||
TargetVersion: schemaVersion,
|
||||
TestedOperation: operationType,
|
||||
ErrorMessage: ex.Message,
|
||||
Exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test read operations work with older schema versions.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of result being read.</typeparam>
|
||||
/// <param name="previousVersions">Previous versions to test.</param>
|
||||
/// <param name="readOperation">Read operation to execute.</param>
|
||||
/// <param name="validateResult">Validation function for results.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of compatibility results.</returns>
|
||||
protected async Task<IReadOnlyList<SchemaCompatibilityResult>> TestReadBackwardCompatibilityAsync<T>(
|
||||
string[] previousVersions,
|
||||
Func<NpgsqlDataSource, Task<T>> readOperation,
|
||||
Func<T, bool> validateResult,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<SchemaCompatibilityResult>();
|
||||
|
||||
foreach (var version in previousVersions)
|
||||
{
|
||||
var result = await TestAgainstSchemaVersionAsync(
|
||||
version,
|
||||
SchemaOperationType.Read,
|
||||
async dataSource =>
|
||||
{
|
||||
// Seed data using old schema
|
||||
await SeedTestDataAsync(dataSource, version, ct);
|
||||
|
||||
// Read using current code
|
||||
var readResult = await readOperation(dataSource);
|
||||
|
||||
// Validate result
|
||||
validateResult(readResult).Should().BeTrue(
|
||||
$"Read operation should work against schema version {version}");
|
||||
},
|
||||
ct);
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test write operations work with newer schema versions.
|
||||
/// </summary>
|
||||
/// <param name="futureVersions">Future versions to test.</param>
|
||||
/// <param name="writeOperation">Write operation to execute.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of compatibility results.</returns>
|
||||
protected async Task<IReadOnlyList<SchemaCompatibilityResult>> TestWriteForwardCompatibilityAsync(
|
||||
string[] futureVersions,
|
||||
Func<NpgsqlDataSource, Task> writeOperation,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<SchemaCompatibilityResult>();
|
||||
|
||||
foreach (var version in futureVersions)
|
||||
{
|
||||
var result = await TestAgainstSchemaVersionAsync(
|
||||
version,
|
||||
SchemaOperationType.Write,
|
||||
async dataSource =>
|
||||
{
|
||||
// Write using current code - should not throw
|
||||
await writeOperation(dataSource);
|
||||
},
|
||||
ct);
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that schema changes have backward-compatible migrations.
|
||||
/// </summary>
|
||||
/// <param name="migrationsToTest">Number of recent migrations to test.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of migration rollback results.</returns>
|
||||
protected async Task<IReadOnlyList<MigrationRollbackResult>> TestMigrationRollbacksAsync(
|
||||
int migrationsToTest = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<MigrationRollbackResult>();
|
||||
var migrations = await GetMigrationHistoryAsync(ct);
|
||||
|
||||
foreach (var migration in migrations.TakeLast(migrationsToTest))
|
||||
{
|
||||
if (!migration.HasDownScript)
|
||||
{
|
||||
results.Add(new MigrationRollbackResult(
|
||||
Migration: migration,
|
||||
Success: false,
|
||||
DurationMs: 0,
|
||||
ErrorMessage: "Migration does not have down script"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = await TestMigrationRollbackAsync(migration, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test a single migration rollback.
|
||||
/// </summary>
|
||||
/// <param name="migration">Migration to test.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rollback result.</returns>
|
||||
protected virtual async Task<MigrationRollbackResult> TestMigrationRollbackAsync(
|
||||
MigrationInfo migration,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Create a fresh database with migrations up to this point
|
||||
var connectionString = await CreateDatabaseWithSchemaAsync(migration.Version, ct);
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
// Apply the down migration
|
||||
await ApplyMigrationDownAsync(dataSource, migration, ct);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new MigrationRollbackResult(
|
||||
Migration: migration,
|
||||
Success: true,
|
||||
DurationMs: sw.ElapsedMilliseconds,
|
||||
ErrorMessage: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
|
||||
return new MigrationRollbackResult(
|
||||
Migration: migration,
|
||||
Success: false,
|
||||
DurationMs: sw.ElapsedMilliseconds,
|
||||
ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed test data for a specific schema version.
|
||||
/// </summary>
|
||||
/// <param name="dataSource">Data source to seed.</param>
|
||||
/// <param name="schemaVersion">Schema version.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Task representing the async operation.</returns>
|
||||
protected abstract Task SeedTestDataAsync(NpgsqlDataSource dataSource, string schemaVersion, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get previous schema version.
|
||||
/// </summary>
|
||||
/// <param name="current">Current schema version.</param>
|
||||
/// <returns>Previous schema version.</returns>
|
||||
protected abstract string GetPreviousSchemaVersion(string current);
|
||||
|
||||
/// <summary>
|
||||
/// Get current schema version from the database or configuration.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Current schema version.</returns>
|
||||
protected abstract Task<string> GetCurrentSchemaVersionAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Create a database with a specific schema version.
|
||||
/// </summary>
|
||||
/// <param name="schemaVersion">Schema version to create.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Connection string to the created database.</returns>
|
||||
protected abstract Task<string> CreateDatabaseWithSchemaAsync(string schemaVersion, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get migration history.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of migrations.</returns>
|
||||
protected abstract Task<IReadOnlyList<MigrationInfo>> GetMigrationHistoryAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Apply a migration down script.
|
||||
/// </summary>
|
||||
/// <param name="dataSource">Data source.</param>
|
||||
/// <param name="migration">Migration to roll back.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Task representing the async operation.</returns>
|
||||
protected abstract Task ApplyMigrationDownAsync(NpgsqlDataSource dataSource, MigrationInfo migration, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Dispose resources.
|
||||
/// </summary>
|
||||
/// <returns>ValueTask representing the async operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dataSource != null)
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseAppHost>true</UseAppHost>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Description>Schema evolution testing framework for backward/forward compatibility verification</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Testing.SchemaEvolution.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit.v3.assert" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit.v3.core" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user