2026-07-24 03:03:57 +08:00

224 lines
8.0 KiB
C#

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;
/// <summary>
/// 工作流可靠性迁移的空库安装与旧结构升级测试。
/// </summary>
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<IMigrator>();
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<WorkflowDbContext>()
.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention()
.Options;
return new WorkflowDbContext(options);
}
private static async Task<string> 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<bool> 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<List<string>> 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<string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
columns.Add(reader.GetString(0));
}
return columns;
}
}