Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
|
||||
|
||||
public class MigrationCategoryTests
|
||||
{
|
||||
#region GetCategory Tests - Startup Migrations (001-099)
|
||||
|
||||
[Theory]
|
||||
[InlineData("001_initial_schema.sql", MigrationCategory.Startup)]
|
||||
[InlineData("001_initial_schema", MigrationCategory.Startup)]
|
||||
[InlineData("01_short_prefix.sql", MigrationCategory.Startup)]
|
||||
[InlineData("1_single_digit.sql", MigrationCategory.Startup)]
|
||||
[InlineData("050_middle_range.sql", MigrationCategory.Startup)]
|
||||
[InlineData("099_last_startup.sql", MigrationCategory.Startup)]
|
||||
public void GetCategory_StartupMigrations_ReturnsStartup(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Release Migrations (100-199, 200+)
|
||||
|
||||
[Theory]
|
||||
[InlineData("100_drop_legacy_columns.sql", MigrationCategory.Release)]
|
||||
[InlineData("150_rename_table.sql", MigrationCategory.Release)]
|
||||
[InlineData("199_last_release.sql", MigrationCategory.Release)]
|
||||
[InlineData("200_major_version.sql", MigrationCategory.Release)]
|
||||
[InlineData("250_another_major.sql", MigrationCategory.Release)]
|
||||
[InlineData("999_very_high_number.sql", MigrationCategory.Release)]
|
||||
public void GetCategory_ReleaseMigrations_ReturnsRelease(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Seed Migrations (S001-S999)
|
||||
|
||||
[Theory]
|
||||
[InlineData("S001_default_roles.sql", MigrationCategory.Seed)]
|
||||
[InlineData("S100_builtin_policies.sql", MigrationCategory.Seed)]
|
||||
[InlineData("S999_last_seed.sql", MigrationCategory.Seed)]
|
||||
[InlineData("s001_lowercase.sql", MigrationCategory.Seed)]
|
||||
public void GetCategory_SeedMigrations_ReturnsSeed(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Schema_setup.sql")] // S followed by non-digit
|
||||
[InlineData("Setup_tables.sql")]
|
||||
public void GetCategory_SPrefix_NotFollowedByDigit_ReturnsStartup(string migrationName)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(MigrationCategory.Startup);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Data Migrations (DM001-DM999)
|
||||
|
||||
[Theory]
|
||||
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
|
||||
[InlineData("DM100_MigrateUserPrefs.sql", MigrationCategory.Data)]
|
||||
[InlineData("DM999_FinalDataMigration.sql", MigrationCategory.Data)]
|
||||
[InlineData("dm001_lowercase.sql", MigrationCategory.Data)]
|
||||
public void GetCategory_DataMigrations_ReturnsData(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategory Tests - Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_NullMigrationName_ThrowsArgumentNullException()
|
||||
{
|
||||
var act = () => MigrationCategoryExtensions.GetCategory(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_EmptyMigrationName_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => MigrationCategoryExtensions.GetCategory(string.Empty);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_WhitespaceMigrationName_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => MigrationCategoryExtensions.GetCategory(" ");
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("no_prefix_migration.sql", MigrationCategory.Startup)]
|
||||
[InlineData("migration.sql", MigrationCategory.Startup)]
|
||||
[InlineData("abc_123.sql", MigrationCategory.Startup)]
|
||||
public void GetCategory_UnknownPattern_DefaultsToStartup(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("migrations/subfolder/001_test.sql", MigrationCategory.Startup)]
|
||||
[InlineData("100_release.SQL", MigrationCategory.Release)] // Different extension case
|
||||
[InlineData("001_test", MigrationCategory.Startup)] // No extension
|
||||
public void GetCategory_PathVariations_ExtractsCorrectly(string migrationName, MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCategory_ZeroPrefix_ReturnsStartup()
|
||||
{
|
||||
// 0 should default to Startup as per the switch expression
|
||||
var result = MigrationCategoryExtensions.GetCategory("0_zero_prefix.sql");
|
||||
result.Should().Be(MigrationCategory.Startup);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAutomatic Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(MigrationCategory.Startup, true)]
|
||||
[InlineData(MigrationCategory.Seed, true)]
|
||||
[InlineData(MigrationCategory.Release, false)]
|
||||
[InlineData(MigrationCategory.Data, false)]
|
||||
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
|
||||
{
|
||||
var result = category.IsAutomatic();
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RequiresManualExecution Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(MigrationCategory.Startup, false)]
|
||||
[InlineData(MigrationCategory.Seed, false)]
|
||||
[InlineData(MigrationCategory.Release, true)]
|
||||
[InlineData(MigrationCategory.Data, true)]
|
||||
public void RequiresManualExecution_ReturnsExpectedValue(MigrationCategory category, bool expected)
|
||||
{
|
||||
var result = category.RequiresManualExecution();
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAutomatic and RequiresManualExecution are Mutually Exclusive
|
||||
|
||||
[Theory]
|
||||
[InlineData(MigrationCategory.Startup)]
|
||||
[InlineData(MigrationCategory.Release)]
|
||||
[InlineData(MigrationCategory.Seed)]
|
||||
[InlineData(MigrationCategory.Data)]
|
||||
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category)
|
||||
{
|
||||
var isAutomatic = category.IsAutomatic();
|
||||
var requiresManual = category.RequiresManualExecution();
|
||||
|
||||
// They should be opposite of each other
|
||||
(isAutomatic ^ requiresManual).Should().BeTrue(
|
||||
$"Category {category} should be either automatic OR manual, not both or neither");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World Migration Name Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("001_create_auth_schema.sql", MigrationCategory.Startup)]
|
||||
[InlineData("002_create_tenants_table.sql", MigrationCategory.Startup)]
|
||||
[InlineData("003_create_users_table.sql", MigrationCategory.Startup)]
|
||||
[InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)]
|
||||
[InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)]
|
||||
[InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)]
|
||||
[InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)]
|
||||
[InlineData("S002_system_permissions.sql", MigrationCategory.Seed)]
|
||||
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
|
||||
[InlineData("DM002_MigratePasswordHashes.sql", MigrationCategory.Data)]
|
||||
public void GetCategory_RealWorldMigrationNames_CategorizesCorrectly(
|
||||
string migrationName,
|
||||
MigrationCategory expected)
|
||||
{
|
||||
var result = MigrationCategoryExtensions.GetCategory(migrationName);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for StartupMigrationHost.
|
||||
/// Uses Testcontainers to spin up a real PostgreSQL instance.
|
||||
/// </summary>
|
||||
public sealed class StartupMigrationHostTests : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer? _container;
|
||||
private string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not initialized");
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container != null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region Migration Execution Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithPendingStartupMigrations_AppliesThem()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
// FailOnPendingReleaseMigrations = false because test assembly includes release migrations
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Check that migrations table has records
|
||||
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
|
||||
appliedCount.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify specific startup/seed migrations were applied (not release)
|
||||
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
|
||||
migrations.Should().Contain("001_create_test_table.sql");
|
||||
migrations.Should().Contain("002_add_column.sql");
|
||||
migrations.Should().Contain("S001_seed_data.sql");
|
||||
migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithAlreadyAppliedMigrations_SkipsThem()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
|
||||
// First run - apply migrations
|
||||
var host1 = CreateTestHost(schemaName, options: options);
|
||||
await host1.StartAsync(CancellationToken.None);
|
||||
|
||||
var initialCount = await GetAppliedMigrationCountAsync(schemaName);
|
||||
|
||||
// Second run - should skip already applied
|
||||
var host2 = CreateTestHost(schemaName, options: options);
|
||||
await host2.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - count should remain the same
|
||||
var finalCount = await GetAppliedMigrationCountAsync(schemaName);
|
||||
finalCount.Should().Be(initialCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CreatesSchemaAndMigrationsTable()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - schema should exist
|
||||
var schemaExists = await SchemaExistsAsync(schemaName);
|
||||
schemaExists.Should().BeTrue();
|
||||
|
||||
// Assert - migrations table should exist
|
||||
var tableExists = await TableExistsAsync(schemaName, "schema_migrations");
|
||||
tableExists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithDisabled_SkipsMigrations()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { Enabled = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - schema should NOT be created
|
||||
var schemaExists = await SchemaExistsAsync(schemaName);
|
||||
schemaExists.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Release Migration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithPendingReleaseMigrations_ThrowsAndStopsApplication()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var lifetimeMock = new Mock<IHostApplicationLifetime>();
|
||||
// Default FailOnPendingReleaseMigrations = true, which should cause failure
|
||||
// because the test assembly includes 100_release_migration.sql
|
||||
var host = CreateTestHost(schemaName, lifetime: lifetimeMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => host.StartAsync(CancellationToken.None));
|
||||
|
||||
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithPendingReleaseMigrations_WhenFailOnPendingFalse_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act - should not throw
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - startup migrations should still be applied
|
||||
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
|
||||
migrations.Should().Contain("001_create_test_table.sql");
|
||||
migrations.Should().NotContain("100_release_migration.sql"); // Release not applied automatically
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Checksum Validation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithChecksumMismatch_ThrowsAndStopsApplication()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
|
||||
// First, apply migrations normally
|
||||
var host1 = CreateTestHost(schemaName, options: options);
|
||||
await host1.StartAsync(CancellationToken.None);
|
||||
|
||||
// Corrupt a checksum in the database
|
||||
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
|
||||
|
||||
// Try to run again with checksum validation (and still ignore release migrations)
|
||||
var lifetimeMock = new Mock<IHostApplicationLifetime>();
|
||||
var options2 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host2 = CreateTestHost(schemaName, options: options2, lifetime: lifetimeMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => host2.StartAsync(CancellationToken.None));
|
||||
|
||||
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithChecksumMismatch_WhenFailOnMismatchFalse_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options1 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
|
||||
// First, apply migrations normally
|
||||
var host1 = CreateTestHost(schemaName, options: options1);
|
||||
await host1.StartAsync(CancellationToken.None);
|
||||
|
||||
// Corrupt a checksum
|
||||
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
|
||||
|
||||
// Try with checksum mismatch allowed
|
||||
var options2 = new StartupMigrationOptions
|
||||
{
|
||||
FailOnChecksumMismatch = false,
|
||||
FailOnPendingReleaseMigrations = false
|
||||
};
|
||||
var host2 = CreateTestHost(schemaName, options: options2);
|
||||
|
||||
// Act - should not throw
|
||||
await host2.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Advisory Lock Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_MultipleConcurrentInstances_OnlyOneRunsMigrations()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var runCount = 0;
|
||||
var lockObject = new object();
|
||||
|
||||
// Create 5 concurrent hosts
|
||||
var tasks = Enumerable.Range(0, 5)
|
||||
.Select(_ =>
|
||||
{
|
||||
var host = CreateTestHost(
|
||||
schemaName,
|
||||
options: new StartupMigrationOptions
|
||||
{
|
||||
LockTimeoutSeconds = 30,
|
||||
FailOnPendingReleaseMigrations = false
|
||||
});
|
||||
return host.StartAsync(CancellationToken.None)
|
||||
.ContinueWith(_ =>
|
||||
{
|
||||
lock (lockObject) { runCount++; }
|
||||
});
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - all should complete, but migrations applied only once
|
||||
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
|
||||
migrations.Should().Contain("001_create_test_table.sql");
|
||||
|
||||
// Each migration should only appear once in the table
|
||||
var counts = await GetMigrationAppliedCountsAsync(schemaName);
|
||||
foreach (var count in counts.Values)
|
||||
{
|
||||
count.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_LockTimeout_ThrowsWhenFailOnLockTimeoutTrue()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions
|
||||
{
|
||||
LockTimeoutSeconds = 1,
|
||||
FailOnLockTimeout = true
|
||||
};
|
||||
|
||||
// Hold the lock manually
|
||||
await using var lockConn = new NpgsqlConnection(ConnectionString);
|
||||
await lockConn.OpenAsync();
|
||||
|
||||
var lockKey = ComputeLockKey(schemaName);
|
||||
await using var lockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_lock(@key)", lockConn);
|
||||
lockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var lifetimeMock = new Mock<IHostApplicationLifetime>();
|
||||
var host = CreateTestHost(schemaName, options: options, lifetime: lifetimeMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => host.StartAsync(CancellationToken.None));
|
||||
|
||||
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release the lock
|
||||
await using var unlockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_unlock(@key)", lockConn);
|
||||
unlockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await unlockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_LockTimeout_DoesNotThrowWhenFailOnLockTimeoutFalse()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions
|
||||
{
|
||||
LockTimeoutSeconds = 1,
|
||||
FailOnLockTimeout = false
|
||||
};
|
||||
|
||||
// Hold the lock manually
|
||||
await using var lockConn = new NpgsqlConnection(ConnectionString);
|
||||
await lockConn.OpenAsync();
|
||||
|
||||
var lockKey = ComputeLockKey(schemaName);
|
||||
await using var lockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_lock(@key)", lockConn);
|
||||
lockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act - should not throw, just skip migrations
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - schema should NOT be created since lock wasn't acquired
|
||||
var schemaExists = await SchemaExistsAsync(schemaName);
|
||||
schemaExists.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release the lock
|
||||
await using var unlockCmd = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_unlock(@key)", lockConn);
|
||||
unlockCmd.Parameters.AddWithValue("key", lockKey);
|
||||
await unlockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Migration Recording Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_RecordsMigrationMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert - check metadata is recorded
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT category, checksum, duration_ms, applied_by FROM {schemaName}.schema_migrations WHERE migration_name = '001_create_test_table.sql'",
|
||||
conn);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
reader.Read().Should().BeTrue();
|
||||
|
||||
reader.GetString(0).Should().Be("startup"); // category
|
||||
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
|
||||
reader.GetInt32(2).Should().BeGreaterOrEqualTo(0); // duration_ms
|
||||
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategory()
|
||||
{
|
||||
// Arrange
|
||||
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
|
||||
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
|
||||
var host = CreateTestHost(schemaName, options: options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT category FROM {schemaName}.schema_migrations WHERE migration_name = 'S001_seed_data.sql'",
|
||||
conn);
|
||||
|
||||
var category = await cmd.ExecuteScalarAsync();
|
||||
category.Should().Be("seed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private TestMigrationHost CreateTestHost(
|
||||
string schemaName,
|
||||
StartupMigrationOptions? options = null,
|
||||
IHostApplicationLifetime? lifetime = null)
|
||||
{
|
||||
return new TestMigrationHost(
|
||||
connectionString: ConnectionString,
|
||||
schemaName: schemaName,
|
||||
moduleName: "Test",
|
||||
migrationsAssembly: typeof(StartupMigrationHostTests).Assembly,
|
||||
logger: NullLogger<TestMigrationHost>.Instance,
|
||||
lifetime: lifetime ?? CreateMockLifetime(),
|
||||
options: options);
|
||||
}
|
||||
|
||||
private static IHostApplicationLifetime CreateMockLifetime()
|
||||
{
|
||||
var mock = new Mock<IHostApplicationLifetime>();
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private async Task<int> GetAppliedMigrationCountAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT COUNT(*) FROM {schemaName}.schema_migrations",
|
||||
conn);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private async Task<List<string>> GetAppliedMigrationNamesAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT migration_name FROM {schemaName}.schema_migrations ORDER BY migration_name",
|
||||
conn);
|
||||
|
||||
var names = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
names.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, int>> GetMigrationAppliedCountsAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT migration_name, COUNT(*) FROM {schemaName}.schema_migrations GROUP BY migration_name",
|
||||
conn);
|
||||
|
||||
var counts = new Dictionary<string, int>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
counts[reader.GetString(0)] = Convert.ToInt32(reader.GetInt64(1));
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
private async Task<bool> SchemaExistsAsync(string schemaName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("name", schemaName);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task<bool> TableExistsAsync(string schemaName, string tableName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table)",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("schema", schemaName);
|
||||
cmd.Parameters.AddWithValue("table", tableName);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task CorruptChecksumAsync(string schemaName, string migrationName)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"UPDATE {schemaName}.schema_migrations SET checksum = 'corrupted_checksum' WHERE migration_name = @name",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("name", migrationName);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static long ComputeLockKey(string schemaName)
|
||||
{
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(schemaName));
|
||||
return BitConverter.ToInt64(hash, 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete test implementation of StartupMigrationHost.
|
||||
/// Uses embedded resources from the test assembly.
|
||||
/// </summary>
|
||||
internal sealed class TestMigrationHost : StartupMigrationHost
|
||||
{
|
||||
public TestMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
StartupMigrationOptions? options)
|
||||
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: 001_create_test_table
|
||||
-- Category: startup
|
||||
-- Description: Create initial test table
|
||||
|
||||
CREATE TABLE IF NOT EXISTS test_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: 002_add_column
|
||||
-- Category: startup
|
||||
-- Description: Add description column to test table
|
||||
|
||||
ALTER TABLE test_table ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: 100_release_migration
|
||||
-- Category: release
|
||||
-- Description: A release migration that requires manual execution
|
||||
|
||||
ALTER TABLE test_table DROP COLUMN IF EXISTS deprecated_column;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Migration: S001_seed_data
|
||||
-- Category: seed
|
||||
-- Description: Insert seed data
|
||||
|
||||
INSERT INTO test_table (name, description)
|
||||
VALUES ('seed1', 'First seed record')
|
||||
ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user