// ----------------------------------------------------------------------------- // 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; /// /// 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) /// [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( @"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( "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( "SELECT COUNT(*) FROM __migrations"); // Count unique migrations var uniqueMigrations = await connection.ExecuteScalarAsync( "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( @"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( "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( "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( @"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( @"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( @"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( "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 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(); } }