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" }
}
}
""";
}