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

- 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:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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