2026-07-24 12:29:06 +08:00

204 lines
7.2 KiB
C#

using BuildFlow.Workflow.Tests.Reliability;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Workflow.Application.BuildFlow.Common.Services;
using Workflow.Application.BuildFlow.Imports.Handlers;
using Workflow.Application.BuildFlow.Imports.Services;
using Workflow.Application.Form.Aggregates;
using Workflow.Application.Form.Derivation;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
namespace BuildFlow.Workflow.Tests.Performance;
/// <summary>
/// 导入任务有界批次事务和取消恢复测试。
/// </summary>
[Collection(PostgresWorkflowCollection.Name)]
public class ImportJobBatchingTests(PostgresWorkflowFixture fixture)
{
[Fact]
public async Task FourHundredOneRowsUseThreeBatchCommitsAndOneCompletionCommit()
{
await ResetAsync();
var filePath = await CreateTempFileAsync();
try
{
var seeded = await SeedImportAsync(filePath);
var rows = Enumerable.Range(0, 401)
.Select(index => ValidRow(index + 2, $"row-{index}"))
.ToArray();
var transactions = new CountingTransactionInterceptor();
await using var db = fixture.CreateDbContext(transactions);
var processor = CreateProcessor(db, rows);
await processor.ProcessAsync(
seeded.JobId,
seeded.LeaseOwner,
TimeSpan.FromMinutes(5),
200,
CancellationToken.None);
transactions.CommitCount.Should().Be(4);
await using var verify = fixture.CreateDbContext();
var job = await verify.BuildFlowImportJobs.SingleAsync(x => x.Id == seeded.JobId);
job.Status.Should().Be(BuildFlowImportJobStatus.Success);
job.TotalRows.Should().Be(401);
job.SuccessRows.Should().Be(401);
job.FailedRows.Should().Be(0);
job.LeaseOwner.Should().BeNull();
(await verify.FormData.CountAsync(x =>
x.FormDefinitionId == seeded.FormDefinitionId)).Should().Be(401);
}
finally
{
System.IO.File.Delete(filePath);
}
}
[Fact]
public async Task CancellationBeforeRowTwoHundredOneKeepsFirstBatchCommitted()
{
await ResetAsync();
var filePath = await CreateTempFileAsync();
using var cts = new CancellationTokenSource();
try
{
var seeded = await SeedImportAsync(filePath);
var rows = new CancellingExcelRows(
Enumerable.Range(0, 401)
.Select(index => ValidRow(index + 2, $"row-{index}"))
.ToArray(),
cancelAtIndex: 200,
cts.Cancel);
await using var db = fixture.CreateDbContext();
var processor = CreateProcessor(db, rows);
var act = () => processor.ProcessAsync(
seeded.JobId,
seeded.LeaseOwner,
TimeSpan.FromMinutes(5),
200,
cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
await using var verify = fixture.CreateDbContext();
var job = await verify.BuildFlowImportJobs.SingleAsync(x => x.Id == seeded.JobId);
job.Status.Should().Be(BuildFlowImportJobStatus.Pending);
job.TotalRows.Should().Be(200);
job.SuccessRows.Should().Be(200);
job.FailedRows.Should().Be(0);
job.LeaseOwner.Should().BeNull();
(await verify.FormData.CountAsync(x =>
x.FormDefinitionId == seeded.FormDefinitionId)).Should().Be(200);
}
finally
{
System.IO.File.Delete(filePath);
}
}
[Fact]
public async Task ExistingFormWithNullSchemaUsesEmptyObjectSchema()
{
await ResetAsync();
var filePath = await CreateTempFileAsync();
try
{
var seeded = await SeedImportAsync(filePath, schemaJson: null);
await using var db = fixture.CreateDbContext();
var processor = CreateProcessor(db, [ValidRow(2, "row")]);
await processor.ProcessAsync(
seeded.JobId,
seeded.LeaseOwner,
TimeSpan.FromMinutes(5),
1,
CancellationToken.None);
await using var verify = fixture.CreateDbContext();
var job = await verify.BuildFlowImportJobs.SingleAsync(x => x.Id == seeded.JobId);
job.Status.Should().Be(BuildFlowImportJobStatus.Success);
job.SuccessRows.Should().Be(1);
}
finally
{
System.IO.File.Delete(filePath);
}
}
private static ImportJobProcessor CreateProcessor(
WorkflowDbContext db,
IEnumerable<ExcelParsedRow> rows)
{
var derivation = new FormValueDerivationService([], [], db);
var aggregates = new FormAggregateRefreshService(db);
return new ImportJobProcessor(
db,
new EnumerableFormSchemaExcelService(rows),
new ImportBatchWriter(db, derivation, aggregates));
}
private async Task ResetAsync()
{
await using var db = fixture.CreateDbContext();
await db.FormData.ExecuteDeleteAsync();
await db.BuildFlowImportJobs.ExecuteDeleteAsync();
await db.FormDefinitions.ExecuteDeleteAsync();
}
private async Task<(Guid JobId, Guid FormDefinitionId, string LeaseOwner)> SeedImportAsync(
string filePath,
string? schemaJson = Schema)
{
await using var db = fixture.CreateDbContext();
var formDefinitionId = Guid.NewGuid();
var jobId = Guid.NewGuid();
const string leaseOwner = "batch-import-worker";
db.FormDefinitions.Add(new FormDefinition
{
Id = formDefinitionId,
Name = "批量导入测试表单",
Code = $"batch-import-{formDefinitionId:N}",
Version = 1,
SchemaJson = schemaJson,
Status = FormStatus.Published,
});
db.BuildFlowImportJobs.Add(new BuildFlowImportJob
{
Id = jobId,
FormDefinitionId = formDefinitionId,
Status = BuildFlowImportJobStatus.Running,
FileUrl = filePath,
OriginalFileName = "batch.xlsx",
LeaseOwner = leaseOwner,
LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5),
AttemptCount = 1,
LastHeartbeatAt = DateTime.UtcNow,
StartedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return (jobId, formDefinitionId, leaseOwner);
}
private static ExcelParsedRow ValidRow(int rowNumber, string name)
=> new(rowNumber, $$"""{"name":"{{name}}"}""", []);
private static async Task<string> CreateTempFileAsync()
{
var path = Path.Combine(Path.GetTempPath(), $"buildflow-import-{Guid.NewGuid():N}.xlsx");
await System.IO.File.WriteAllBytesAsync(path, [1, 2, 3]);
return path;
}
private const string Schema = """
{
"type": "object",
"properties": {
"name": { "type": "string", "required": true, "x-component": "Input" }
}
}
""";
}