save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

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

View File

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

View File

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

View File

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