using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql; using Workflow.Domain.Enums; using Workflow.Infrastructure.Persistence; using Xunit; namespace BuildFlow.Workflow.Tests.Reliability; /// /// 工作流可靠性迁移的空库安装与旧结构升级测试。 /// public class WorkflowJobMigrationTests { private const string PreviousMigration = "20260719041812_FixWorkflowTaskAttachmentsColumn"; private const string DefaultConnection = "Host=127.0.0.1;Port=55432;Username=buildflow;Password=buildflow;Database=postgres"; [Fact] public async Task EmptyDatabaseMigratesToWorkflowJobReliabilitySchema() { var databaseName = $"workflow_migration_empty_{Guid.NewGuid():N}"; var connectionString = await CreateDatabaseAsync(databaseName); try { await using var db = CreateDbContext(connectionString); await db.Database.MigrateAsync(); await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync(); (await TableExistsAsync(connection, "b_workflow_timer_jobs")).Should().BeTrue(); var columns = await ReadColumnNamesAsync(connection, "b_import_jobs"); columns.Should().Contain( "lease_owner", "lease_expires_at", "attempt_count", "last_heartbeat_at"); } finally { await DropDatabaseAsync(databaseName); } } [Fact] public async Task ExistingImportJobSurvivesReliabilityMigration() { var databaseName = $"workflow_migration_upgrade_{Guid.NewGuid():N}"; var connectionString = await CreateDatabaseAsync(databaseName); var jobId = Guid.NewGuid(); var formDefinitionId = Guid.NewGuid(); const string filePath = "/tmp/existing-import.xlsx"; try { await using var db = CreateDbContext(connectionString); var migrator = db.GetService(); await migrator.MigrateAsync(PreviousMigration); var now = DateTime.UtcNow; await db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO b_import_jobs ( id, form_definition_id, status, file_url, total_rows, success_rows, failed_rows, created_by, created_at, updated_by, updated_at, is_deleted, operator_ip) VALUES ( {jobId}, {formDefinitionId}, {(int)BuildFlowImportJobStatus.Pending}, {filePath}, {0}, {0}, {0}, {"migration-test"}, {now}, {"migration-test"}, {now}, {false}, {"127.0.0.1"}) """); await db.Database.MigrateAsync(); await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT id, status, file_url, attempt_count, lease_owner, lease_expires_at, last_heartbeat_at FROM b_import_jobs WHERE id = @job_id """; command.Parameters.AddWithValue("job_id", jobId); await using var reader = await command.ExecuteReaderAsync(); (await reader.ReadAsync()).Should().BeTrue(); reader.GetGuid(0).Should().Be(jobId); reader.GetInt32(1).Should().Be((int)BuildFlowImportJobStatus.Pending); reader.GetString(2).Should().Be(filePath); reader.GetInt32(3).Should().Be(0); reader.IsDBNull(4).Should().BeTrue(); reader.IsDBNull(5).Should().BeTrue(); reader.IsDBNull(6).Should().BeTrue(); } finally { await DropDatabaseAsync(databaseName); } } private static WorkflowDbContext CreateDbContext(string connectionString) { var options = new DbContextOptionsBuilder() .UseNpgsql(connectionString) .UseSnakeCaseNamingConvention() .Options; return new WorkflowDbContext(options); } private static async Task CreateDatabaseAsync(string databaseName) { var configured = Environment.GetEnvironmentVariable("BUILDFLOW_WORKFLOW_TEST_CONNECTION") ?? DefaultConnection; var adminBuilder = new NpgsqlConnectionStringBuilder(configured) { Database = "postgres", Pooling = false, }; await using (var connection = new NpgsqlConnection(adminBuilder.ConnectionString)) { await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = $"CREATE DATABASE \"{databaseName}\""; await command.ExecuteNonQueryAsync(); } return new NpgsqlConnectionStringBuilder(configured) { Database = databaseName, Pooling = true, }.ConnectionString; } private static async Task DropDatabaseAsync(string databaseName) { NpgsqlConnection.ClearAllPools(); var configured = Environment.GetEnvironmentVariable("BUILDFLOW_WORKFLOW_TEST_CONNECTION") ?? DefaultConnection; var adminBuilder = new NpgsqlConnectionStringBuilder(configured) { Database = "postgres", Pooling = false, }; await using var connection = new NpgsqlConnection(adminBuilder.ConnectionString); await connection.OpenAsync(); await using (var terminate = connection.CreateCommand()) { terminate.CommandText = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @database AND pid <> pg_backend_pid()"; terminate.Parameters.AddWithValue("database", databaseName); await terminate.ExecuteNonQueryAsync(); } await using var drop = connection.CreateCommand(); drop.CommandText = $"DROP DATABASE IF EXISTS \"{databaseName}\""; await drop.ExecuteNonQueryAsync(); } private static async Task TableExistsAsync( NpgsqlConnection connection, string tableName) { await using var command = connection.CreateCommand(); command.CommandText = """ SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = @table_name) """; command.Parameters.AddWithValue("table_name", tableName); return (bool)(await command.ExecuteScalarAsync())!; } private static async Task> ReadColumnNamesAsync( NpgsqlConnection connection, string tableName) { await using var command = connection.CreateCommand(); command.CommandText = """ SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = @table_name """; command.Parameters.AddWithValue("table_name", tableName); var columns = new List(); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { columns.Add(reader.GetString(0)); } return columns; } }