using System.Text.Json; using BuildFlow.Shared.Domain.Errors; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Workflow.Application.BuildFlow.Common.Services; using Workflow.Application.BuildFlow.Imports.Commands; using Workflow.Application.BuildFlow.Imports.Handlers; using Workflow.Application.Reliability; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Xunit; namespace BuildFlow.Workflow.Tests.Performance; /// Excel 导入上传与 OpenXML 流式解析的资源边界测试。 public class OpenXmlImportBoundaryTests { [Fact] public async Task OversizedUploadIsRejectedBeforeReadingStream() { await using var db = await CreateDbContextWithFormAsync(); var formId = await db.FormDefinitions.Select(form => form.Id).SingleAsync(); using var stream = new TrackingReadStream(new MemoryStream([1, 2, 3])); var handler = CreateUploadHandler(db, maxFileBytes: 10); var act = () => handler.Handle( new CreateImportJobCommand(formId, stream, 11, "oversized.xlsx"), CancellationToken.None); var exception = await act.Should() .ThrowAsync(); exception.Which.Code.Should().Be(ErrorCode.TooLarge); stream.BytesRead.Should().Be(0); stream.ReadCalls.Should().Be(0); db.BuildFlowImportJobs.Should().BeEmpty(); } [Fact] public async Task UploadCopiesStreamToTemporaryFileWithBoundedBuffer() { await using var db = await CreateDbContextWithFormAsync(); var formId = await db.FormDefinitions.Select(form => form.Id).SingleAsync(); var payload = Enumerable.Range(0, 200_000) .Select(index => (byte)(index % 251)) .ToArray(); using var stream = new TrackingReadStream(new MemoryStream(payload)); var handler = CreateUploadHandler(db, maxFileBytes: 500_000); string? filePath = null; try { await handler.Handle( new CreateImportJobCommand(formId, stream, payload.Length, "data.xlsx"), CancellationToken.None); var job = await db.BuildFlowImportJobs.SingleAsync(); filePath = job.FileUrl; (await System.IO.File.ReadAllBytesAsync(filePath)).Should().Equal(payload); stream.BytesRead.Should().Be(payload.Length); stream.ReadCalls.Should().BeGreaterThan(1); stream.MaxRequestedReadSize.Should().BeLessThanOrEqualTo(81_920); } finally { if (filePath is not null) { System.IO.File.Delete(filePath); } } } [Fact] public async Task MissingFormIsRejectedWithoutReadingStream() { await using var db = TestDbFactory.Create(); using var stream = new TrackingReadStream(new MemoryStream([1])); var handler = CreateUploadHandler(db, maxFileBytes: 10); var act = () => handler.Handle( new CreateImportJobCommand(Guid.NewGuid(), stream, 1, "data.xlsx"), CancellationToken.None); var exception = await act.Should().ThrowAsync(); exception.Which.Code.Should().Be(ErrorCode.NotFound); stream.BytesRead.Should().Be(0); } [Fact] public void ParserRejectsRowBeyondConfiguredLimit() { using var stream = CreateWorkbook( [ HeaderRow("名称"), InlineRow(2, "first"), InlineRow(3, "second"), InlineRow(4, "third"), ]); var service = new FormSchemaExcelService(); var act = () => service.Parse(SingleTextFieldSchema, stream, 2, 100).ToList(); var exception = act.Should().Throw(); exception.Which.Code.Should().Be(ErrorCode.TooLarge); } [Fact] public void ParserRejectsCellTextBeyondConfiguredLimit() { using var stream = CreateWorkbook( [HeaderRow("名称"), InlineRow(2, "123456")]); var service = new FormSchemaExcelService(); var act = () => service.Parse(SingleTextFieldSchema, stream, 10, 5).ToList(); var exception = act.Should().Throw(); exception.Which.Code.Should().Be(ErrorCode.TooLarge); } [Fact] public void ParserReadsSharedInlineNumberBooleanAndBlankCells() { using var stream = CreateTypedWorkbook(); var service = new FormSchemaExcelService(); var row = service.Parse(TypedSchema, stream, 10, 100).Single(); using var json = JsonDocument.Parse(row.JsonObject); var root = json.RootElement; root.GetProperty("shared").GetString().Should().Be("shared-value"); root.GetProperty("inline").GetString().Should().Be("inline-value"); root.GetProperty("amount").GetDecimal().Should().Be(42.5m); root.GetProperty("approved").GetBoolean().Should().BeTrue(); root.TryGetProperty("blank", out _).Should().BeFalse(); } private static CreateImportJobCommandHandler CreateUploadHandler( WorkflowDbContext db, long maxFileBytes) => new( db, Options.Create(new WorkflowJobOptions { Import = new WorkflowJobOptions.ImportJobSection { MaxFileBytes = maxFileBytes, }, })); private static async Task CreateDbContextWithFormAsync() { var db = TestDbFactory.Create(); db.FormDefinitions.Add(new FormDefinition { Id = Guid.NewGuid(), Name = "导入边界测试", Code = $"import-boundary-{Guid.NewGuid():N}", Version = 1, SchemaJson = SingleTextFieldSchema, Status = FormStatus.Published, }); await db.SaveChangesAsync(); return db; } private static MemoryStream CreateTypedWorkbook() { var stream = new MemoryStream(); using (var document = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook, true)) { var workbookPart = document.AddWorkbookPart(); workbookPart.Workbook = new Workbook(); var sharedStringsPart = workbookPart.AddNewPart(); sharedStringsPart.SharedStringTable = new SharedStringTable( new SharedStringItem(new Text("shared-value"))); var worksheetPart = workbookPart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(new SheetData( HeaderRow("Shared", "Inline", "Number", "Boolean", "Blank"), new Row( new Cell { CellReference = "A2", DataType = CellValues.SharedString, CellValue = new CellValue("0") }, InlineCell("B2", "inline-value"), new Cell { CellReference = "C2", DataType = CellValues.Number, CellValue = new CellValue("42.5") }, new Cell { CellReference = "D2", DataType = CellValues.Boolean, CellValue = new CellValue("1") }, new Cell { CellReference = "E2" }) { RowIndex = 2, })); AppendSheet(workbookPart, worksheetPart); } stream.Position = 0; return stream; } private static MemoryStream CreateWorkbook(IEnumerable rows) { var stream = new MemoryStream(); using (var document = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook, true)) { var workbookPart = document.AddWorkbookPart(); workbookPart.Workbook = new Workbook(); var worksheetPart = workbookPart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(new SheetData(rows)); AppendSheet(workbookPart, worksheetPart); } stream.Position = 0; return stream; } private static void AppendSheet(WorkbookPart workbookPart, WorksheetPart worksheetPart) { var sheets = workbookPart.Workbook.AppendChild(new Sheets()); sheets.Append(new Sheet { Id = workbookPart.GetIdOfPart(worksheetPart), SheetId = 1, Name = "Sheet1", }); workbookPart.Workbook.Save(); } private static Row HeaderRow(params string[] headers) => new(headers.Select((header, index) => InlineCell($"{ColumnName(index + 1)}1", header))) { RowIndex = 1, }; private static Row InlineRow(uint rowIndex, string value) => new(InlineCell($"A{rowIndex}", value)) { RowIndex = rowIndex, }; private static Cell InlineCell(string reference, string value) => new(new InlineString(new Text(value))) { CellReference = reference, DataType = CellValues.InlineString, }; private static string ColumnName(int index) { var name = string.Empty; while (index > 0) { index--; name = (char)('A' + index % 26) + name; index /= 26; } return name; } private const string SingleTextFieldSchema = """ { "type": "object", "properties": { "name": { "type": "string", "title": "名称", "required": true, "x-component": "Input" } } } """; private const string TypedSchema = """ { "type": "object", "properties": { "shared": { "type": "string", "title": "Shared", "x-component": "Input" }, "inline": { "type": "string", "title": "Inline", "x-component": "Input" }, "amount": { "type": "number", "title": "Number", "x-component": "InputNumber" }, "approved": { "type": "boolean", "title": "Boolean", "x-component": "Switch" }, "blank": { "type": "string", "title": "Blank", "x-component": "Input" } } } """; }