362 lines
13 KiB
C#
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();
|
|
}
|
|
}
|
|
|