using ClosedXML.Excel; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Workflow.Application.BuildFlow.Common.Services; using Workflow.Application.BuildFlow.Exports.Handlers; using Workflow.Application.BuildFlow.Exports.Queries; using Workflow.Application.Reliability; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Xunit; namespace BuildFlow.Workflow.Tests.Performance; /// Excel 导出的 keyset 分页、顺序写入和临时文件清理边界测试。 [Collection(Reliability.PostgresWorkflowCollection.Name)] public class OpenXmlExportBoundaryTests(Reliability.PostgresWorkflowFixture fixture) { [Fact] public async Task TwelveHundredOneRowsUseThreePagesAndKeepStableOrder() { await ResetAsync(); var formId = await SeedFormAsync(); await SeedRowsAsync(formId, 1_201); var interceptor = new CountingExportPageInterceptor(); await using var db = fixture.CreateDbContext(interceptor); var handler = CreateHandler(db, new FormSchemaExcelService(), exportPageSize: 500); string? filePath = null; try { var result = await handler.Handle( new ExportFormDataQuery(formId), CancellationToken.None); filePath = result.FilePath; interceptor.PageSelectCount.Should().Be(3); System.IO.File.Exists(filePath).Should().BeTrue(); typeof(ExportFormDataResult).GetProperties() .Should().NotContain(property => property.PropertyType == typeof(byte[])); using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheets.First(); worksheet.LastRowUsed()!.RowNumber().Should().Be(1_202); for (var index = 0; index < 1_201; index++) { worksheet.Cell(index + 2, 1).GetString() .Should().Be($"row-{index:D4}"); } } finally { if (filePath is not null) { System.IO.File.Delete(filePath); } } } [Fact] public async Task CancellationDeletesTemporaryExportFile() { await ResetAsync(); var formId = await SeedFormAsync(); using var cts = new CancellationTokenSource(); var excel = new CancellingExportExcelService(cts); await using var db = fixture.CreateDbContext(); var handler = CreateHandler(db, excel, exportPageSize: 500); var act = () => handler.Handle(new ExportFormDataQuery(formId), cts.Token); await act.Should().ThrowAsync(); excel.OutputPath.Should().NotBeNull(); System.IO.File.Exists(excel.OutputPath).Should().BeFalse(); } [Fact] public async Task WriterFailureDeletesTemporaryExportFile() { await ResetAsync(); var formId = await SeedFormAsync(); var excel = new CancellingExportExcelService( exception: new InvalidOperationException("writer failed")); await using var db = fixture.CreateDbContext(); var handler = CreateHandler(db, excel, exportPageSize: 500); var act = () => handler.Handle(new ExportFormDataQuery(formId), CancellationToken.None); await act.Should().ThrowAsync(); excel.OutputPath.Should().NotBeNull(); System.IO.File.Exists(excel.OutputPath).Should().BeFalse(); } private static ExportFormDataQueryHandler CreateHandler( WorkflowDbContext db, IFormSchemaExcelService excelService, int exportPageSize) => new( db, excelService, Options.Create(new WorkflowJobOptions { Import = new WorkflowJobOptions.ImportJobSection { ExportPageSize = exportPageSize, }, })); private async Task ResetAsync() { await using var db = fixture.CreateDbContext(); await db.FormData.ExecuteDeleteAsync(); await db.FormDefinitions.ExecuteDeleteAsync(); } private async Task SeedFormAsync() { await using var db = fixture.CreateDbContext(); var formId = Guid.NewGuid(); db.FormDefinitions.Add(new FormDefinition { Id = formId, Name = "分页导出测试", Code = $"export-{formId:N}", Version = 1, SchemaJson = Schema, Status = FormStatus.Published, }); await db.SaveChangesAsync(); return formId; } private async Task SeedRowsAsync(Guid formId, int count) { await using var db = fixture.CreateDbContext(); var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); db.FormData.AddRange(Enumerable.Range(0, count).Select(index => new FormData { Id = Guid.NewGuid(), FormDefinitionId = formId, InstanceId = Guid.NewGuid(), DataJson = $$"""{"name":"row-{{index:D4}}"}""", CreatedAt = start.AddMilliseconds(index), UpdatedAt = start.AddMilliseconds(index), })); await db.SaveChangesAsync(); } private const string Schema = """ { "type": "object", "properties": { "name": { "type": "string", "title": "名称", "x-component": "Input" } } } """; }