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.Reliability; /// /// 导入任务的事务进度、崩溃恢复和优雅取消测试。 /// [Collection(PostgresWorkflowCollection.Name)] public class ImportJobRecoveryTests(PostgresWorkflowFixture fixture) { [Fact] public async Task RecoveredImportStartsAtCommittedCursorWithoutDuplicatingRows() { await ResetAsync(); var filePath = await CreateTempFileAsync(); try { var seeded = await SeedImportAsync(filePath, successRows: 1); await using var db = fixture.CreateDbContext(); var processor = CreateProcessor( db, [ ValidRow(2, """{"name":"first"}"""), ValidRow(3, """{"name":"second"}"""), ]); 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(2); job.FailedRows.Should().Be(0); job.LeaseOwner.Should().BeNull(); (await verify.FormData.CountAsync(x => x.FormDefinitionId == seeded.FormDefinitionId)) .Should().Be(2); } finally { File.Delete(filePath); } } [Fact] public async Task SuccessfulImportClearsLeaseAndPersistsHeartbeat() { await ResetAsync(); var filePath = await CreateTempFileAsync(); try { var seeded = await SeedImportAsync(filePath); await using var db = fixture.CreateDbContext(); var processor = CreateProcessor( db, [ValidRow(2, """{"name":"only"}""")]); 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.LastHeartbeatAt.Should().NotBeNull(); job.FinishedAt.Should().NotBeNull(); job.LeaseOwner.Should().BeNull(); job.LeaseExpiresAt.Should().BeNull(); } finally { File.Delete(filePath); } } [Fact] public async Task GracefulCancellationRequeuesJobAndKeepsCommittedProgress() { await ResetAsync(); var filePath = await CreateTempFileAsync(); using var cts = new CancellationTokenSource(); try { var seeded = await SeedImportAsync(filePath); var rows = new CancellingRows( [ ValidRow(2, """{"name":"first"}"""), ValidRow(3, """{"name":"second"}"""), ], cancelAtIndex: 1, cts.Cancel); await using var db = fixture.CreateDbContext(); var processor = CreateProcessor(db, rows); var act = () => processor.ProcessAsync( seeded.JobId, seeded.LeaseOwner, TimeSpan.FromMinutes(5), 1, cts.Token); await act.Should().ThrowAsync(); await using var verify = fixture.CreateDbContext(); var job = await verify.BuildFlowImportJobs.SingleAsync(x => x.Id == seeded.JobId); job.Status.Should().Be(BuildFlowImportJobStatus.Pending); job.SuccessRows.Should().Be(1); job.FailedRows.Should().Be(0); job.LeaseOwner.Should().BeNull(); job.LeaseExpiresAt.Should().BeNull(); (await verify.FormData.CountAsync(x => x.FormDefinitionId == seeded.FormDefinitionId)) .Should().Be(1); } finally { File.Delete(filePath); } } 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 SeedImportAsync( string filePath, int successRows = 0) { await using var db = fixture.CreateDbContext(); var formDefinitionId = Guid.NewGuid(); var jobId = Guid.NewGuid(); var leaseOwner = "import-worker"; db.FormDefinitions.Add(new FormDefinition { Id = formDefinitionId, Name = "导入恢复测试表单", Code = $"import-{formDefinitionId:N}", Version = 1, SchemaJson = """{"type":"object","properties":{}}""", Status = FormStatus.Published, }); if (successRows > 0) { db.FormData.Add(new FormData { Id = Guid.NewGuid(), FormDefinitionId = formDefinitionId, InstanceId = Guid.NewGuid(), DataJson = """{"name":"already-committed"}""", }); } db.BuildFlowImportJobs.Add(new BuildFlowImportJob { Id = jobId, FormDefinitionId = formDefinitionId, Status = BuildFlowImportJobStatus.Running, FileUrl = filePath, OriginalFileName = "recovery.xlsx", SuccessRows = successRows, FailedRows = 0, LeaseOwner = leaseOwner, LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), AttemptCount = 1, LastHeartbeatAt = DateTime.UtcNow, StartedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); return new SeededImport(jobId, formDefinitionId, leaseOwner); } private static ExcelParsedRow ValidRow(int rowNumber, string json) => new(rowNumber, json, []); private static ImportJobProcessor CreateProcessor( WorkflowDbContext db, IReadOnlyList rows) { var derivation = new FormValueDerivationService([], [], db); var aggregates = new FormAggregateRefreshService(db); return new ImportJobProcessor( db, new StubExcelService(rows), new ImportBatchWriter(db, derivation, aggregates)); } private static async Task CreateTempFileAsync() { var path = Path.Combine(Path.GetTempPath(), $"buildflow-import-{Guid.NewGuid():N}.xlsx"); await File.WriteAllBytesAsync(path, [1, 2, 3]); return path; } private sealed class StubExcelService(IReadOnlyList rows) : IFormSchemaExcelService { public byte[] BuildTemplate(string schemaJson, string formName) => []; public Task WriteExportAsync( string schemaJson, string formName, IAsyncEnumerable dataJsonRows, Stream output, CancellationToken ct) => Task.CompletedTask; public IEnumerable Parse( string schemaJson, Stream xlsxStream, int maxRows, int maxCellTextLength) => rows; } private sealed class CancellingRows( IReadOnlyList rows, int cancelAtIndex, Action cancel) : IReadOnlyList { public int Count => rows.Count; public ExcelParsedRow this[int index] { get { if (index == cancelAtIndex) cancel(); return rows[index]; } } public IEnumerator GetEnumerator() { for (var index = 0; index < rows.Count; index++) { if (index == cancelAtIndex) cancel(); yield return rows[index]; } } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } private sealed record SeededImport( Guid JobId, Guid FormDefinitionId, string LeaseOwner); }