Files
git.stella-ops.org/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Persistence.Tests/SchedulerMigrationTests.cs

362 lines
13 KiB
C#

// -----------------------------------------------------------------------------
// SchedulerMigrationTests.cs
// Sprint: SPRINT_5100_0009_0008_scheduler_tests
// Task: SCHEDULER-5100-005
// Description: Model S1 migration tests for Scheduler.Storage
// -----------------------------------------------------------------------------
using System.Reflection;
using Dapper;
using FluentAssertions;
using Npgsql;
using StellaOps.TestKit;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Scheduler.Persistence.Postgres.Tests;
/// <summary>
/// Migration tests for Scheduler.Storage.
/// Implements Model S1 (Storage/Postgres) migration test requirements:
/// - Apply all migrations from scratch (fresh database)
/// - Apply migrations from N-1 (incremental application)
/// - Verify migration idempotency (apply twice → no error)
/// </summary>
[Trait("Category", TestCategories.Integration)]
[Trait("Category", "StorageMigration")]
public sealed class SchedulerMigrationTests : IAsyncLifetime
{
private PostgreSqlContainer _container = null!;
public async ValueTask InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("scheduler_migration_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
[Fact]
public async Task ApplyMigrations_FromScratch_AllTablesCreated()
{
// Arrange
var connectionString = _container.GetConnectionString();
// Act - Apply all migrations from scratch
await ApplyAllMigrationsAsync(connectionString);
// Assert - Verify Scheduler tables exist
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var tables = await connection.QueryAsync<string>(
@"SELECT table_name FROM information_schema.tables
WHERE table_schema = 'scheduler'
ORDER BY table_name");
var tableList = tables.ToList();
// Verify core Scheduler tables exist
tableList.Should().Contain("jobs", "jobs table should exist");
}
[Fact]
public async Task ApplyMigrations_FromScratch_AllMigrationsRecorded()
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
// Assert - Verify migrations are recorded
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var migrationsApplied = await connection.QueryAsync<string>(
"SELECT migration_id FROM __migrations ORDER BY applied_at");
var migrationList = migrationsApplied.ToList();
migrationList.Should().NotBeEmpty("migrations should be tracked");
}
[Fact]
public async Task ApplyMigrations_Twice_IsIdempotent()
{
// Arrange
var connectionString = _container.GetConnectionString();
// Act - Apply migrations twice
await ApplyAllMigrationsAsync(connectionString);
var applyAgain = async () => await ApplyAllMigrationsAsync(connectionString);
// Assert - Second application should not throw
await applyAgain.Should().NotThrowAsync(
"applying migrations twice should be idempotent");
// Verify migrations are not duplicated
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var migrationCount = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM __migrations");
// Count unique migrations
var uniqueMigrations = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(DISTINCT migration_id) FROM __migrations");
migrationCount.Should().Be(uniqueMigrations,
"each migration should only be recorded once");
}
[Fact]
public async Task ApplyMigrations_VerifySchemaIntegrity()
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
// Assert - Verify indexes exist
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var indexes = await connection.QueryAsync<string>(
@"SELECT indexname FROM pg_indexes
WHERE schemaname = 'scheduler'
ORDER BY indexname");
var indexList = indexes.ToList();
indexList.Should().NotBeEmpty("scheduler schema should have indexes");
}
[Fact]
public async Task ApplyMigrations_IndividualMigrationsCanRollForward()
{
// Arrange
var connectionString = _container.GetConnectionString();
// Act - Apply migrations in sequence
var migrationFiles = GetMigrationFiles();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
// Create migration tracking table first
await connection.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS __migrations (
id SERIAL PRIMARY KEY,
migration_id TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ DEFAULT NOW()
)");
// Apply each migration in order
int appliedCount = 0;
foreach (var migrationFile in migrationFiles.OrderBy(f => f))
{
var migrationId = Path.GetFileName(migrationFile);
// Check if already applied
var alreadyApplied = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM __migrations WHERE migration_id = @Id",
new { Id = migrationId });
if (alreadyApplied > 0)
continue;
// Apply migration
var sql = GetMigrationContent(migrationFile);
if (!string.IsNullOrWhiteSpace(sql))
{
await connection.ExecuteAsync(sql);
await connection.ExecuteAsync(
"INSERT INTO __migrations (migration_id) VALUES (@Id)",
new { Id = migrationId });
appliedCount++;
}
}
// Assert - Migrations should be applied (if any exist)
var totalMigrations = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM __migrations");
totalMigrations.Should().BeGreaterThanOrEqualTo(0);
}
[Fact]
public async Task ApplyMigrations_ForeignKeyConstraintsValid()
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
// Assert - Verify foreign key constraints exist and are valid
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var foreignKeys = await connection.QueryAsync<string>(
@"SELECT tc.constraint_name
FROM information_schema.table_constraints tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'scheduler'
ORDER BY tc.constraint_name");
var fkList = foreignKeys.ToList();
// Foreign keys may or may not exist depending on schema design
fkList.Should().NotBeNull();
}
[Fact]
public async Task ApplyMigrations_JobsTableHasRequiredColumns()
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
// Assert - Verify jobs table has required columns
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var columns = await connection.QueryAsync<string>(
@"SELECT column_name FROM information_schema.columns
WHERE table_name = 'jobs' AND table_schema = 'scheduler'
ORDER BY ordinal_position");
var columnList = columns.ToList();
if (columnList.Any())
{
columnList.Should().Contain("id", "jobs table should have id column");
columnList.Should().Contain("tenant_id", "jobs table should have tenant_id column");
columnList.Should().Contain("status", "jobs table should have status column");
columnList.Should().Contain("idempotency_key", "jobs table should have idempotency_key column");
}
}
[Fact]
public async Task ApplyMigrations_SchedulerSchemaExists()
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
// Assert - Verify scheduler schema exists
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var schemaExists = await connection.ExecuteScalarAsync<int>(
@"SELECT COUNT(*) FROM information_schema.schemata
WHERE schema_name = 'scheduler'");
schemaExists.Should().Be(1, "scheduler schema should exist");
}
[Fact]
public async Task InitialSchemaMigration_CanBeReappliedWithoutTriggerConflicts()
{
var connectionString = _container.GetConnectionString();
var migrationResource = GetMigrationResourceByFileName("001_initial_schema.sql");
var sql = GetMigrationContent(migrationResource);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync(sql);
var applyAgain = async () => await connection.ExecuteAsync(sql);
await applyAgain.Should().NotThrowAsync(
"001_initial_schema.sql must remain idempotent when rerun on initialized schemas");
}
[Fact]
public async Task ExceptionLifecycleMigration_CanBeReappliedWithoutPolicyConflicts()
{
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
var migrationResource = GetMigrationResourceByFileName("003_exception_lifecycle.sql");
var sql = GetMigrationContent(migrationResource);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var applyAgain = async () => await connection.ExecuteAsync(sql);
await applyAgain.Should().NotThrowAsync(
"003_exception_lifecycle.sql must remain idempotent when rerun on initialized schemas");
}
private async Task ApplyAllMigrationsAsync(string connectionString)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
// Create migration tracking table
await connection.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS __migrations (
id SERIAL PRIMARY KEY,
migration_id TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ DEFAULT NOW()
)");
// Get and apply all migrations
var migrationFiles = GetMigrationFiles();
foreach (var migrationFile in migrationFiles.OrderBy(f => f))
{
var migrationId = Path.GetFileName(migrationFile);
// Skip if already applied
var alreadyApplied = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM __migrations WHERE migration_id = @Id",
new { Id = migrationId });
if (alreadyApplied > 0)
continue;
// Apply migration
var sql = GetMigrationContent(migrationFile);
if (!string.IsNullOrWhiteSpace(sql))
{
await connection.ExecuteAsync(sql);
await connection.ExecuteAsync(
"INSERT INTO __migrations (migration_id) VALUES (@Id)",
new { Id = migrationId });
}
}
}
private static IEnumerable<string> GetMigrationFiles()
{
var assembly = typeof(SchedulerDataSource).Assembly;
var resourceNames = assembly.GetManifestResourceNames()
.Where(n => n.Contains("Migrations") && n.EndsWith(".sql"))
.OrderBy(n => n);
return resourceNames;
}
private static string GetMigrationResourceByFileName(string fileName)
{
return GetMigrationFiles()
.First(resource => resource.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
}
private static string GetMigrationContent(string resourceName)
{
var assembly = typeof(SchedulerDataSource).Assembly;
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
return string.Empty;
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}