using BuildFlow.Shared.Domain.Errors; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Workflow.Application.Form.Aggregates; using Workflow.Application.Form.Derivation; using Workflow.Application.Form.FormData.Commands; using Workflow.Application.Form.FormData.Handlers; using Workflow.Application.Form.FormData.Queries; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Xunit; namespace BuildFlow.Workflow.Tests.Form; [Collection("FormTests")] public class FormDataTests { private readonly FormTestFixture _fixture; public FormDataTests(FormTestFixtureClassFixture fixture) { _fixture = fixture; } private static SubmitFormDataCommandHandler CreateSubmitHandler(WorkflowDbContext db) => new(db, new FormValueDerivationService([], []), new FormAggregateRefreshService(db)); /// /// 辅助方法:创建一个已发布的表单定义(含 Formily Schema),用于 FormData 测试。 /// Schema 包含:name (string, required)、age (number)、gender (string, required)、resume (array)。 /// private static FormDefinition CreatePublishedFormDefinition(Guid formId) { return new FormDefinition { Id = formId, Name = "测试表单", Code = $"TEST_FORM_{formId:N}", Version = 1, Status = FormStatus.Published, SchemaJson = """ { "type": "object", "properties": { "name": { "type": "string", "title": "姓名", "required": true, "x-decorator": "FormItem", "x-component": "Input" }, "age": { "type": "number", "title": "年龄", "x-decorator": "FormItem", "x-component": "InputNumber", "x-component-props": { "min": 0, "max": 150 } }, "gender": { "type": "string", "title": "性别", "required": true, "enum": [ { "label": "男", "value": "male" }, { "label": "女", "value": "female" } ], "x-decorator": "FormItem", "x-component": "Radio.Group" }, "resume": { "type": "array", "title": "简历附件", "x-decorator": "FormItem", "x-component": "Upload", "x-component-props": { "maxSize": 10485760, "accept": ".pdf,.doc,.docx" } } } } """, }; } private const string ComputedExpenseSchema = """ { "type": "object", "properties": { "quantity": { "type": "number", "title": "数量", "x-component": "InputNumber" }, "unitPrice": { "type": "number", "title": "单价", "x-component": "InputNumber" }, "total": { "type": "number", "title": "总价", "x-component": "InputNumber", "x-field-kind": "computed", "x-derived": { "mode": "computed", "writePolicy": "overwrite", "expression": { "operator": "multiply", "left": { "type": "field", "path": "quantity" }, "right": { "type": "field", "path": "unitPrice" } } } } } } """; private const string AggregateMainSchema = """ { "type": "object", "properties": { "requestId": { "type": "string", "title": "申请编号", "x-component": "Input" }, "lineTotal": { "type": "number", "title": "明细总金额", "x-component": "InputNumber", "x-field-kind": "aggregate", "x-derived": { "mode": "aggregate", "source": { "type": "formData", "formDefinitionCode": "expense-line", "relationPath": "requestId", "relationValuePath": "requestId" }, "aggregate": { "function": "sum", "fieldPath": "amount" }, "refresh": { "trigger": "afterSubmit", "scope": "sameInstance" } } } } } """; private const string AggregateLineSchema = """ { "type": "object", "properties": { "requestId": { "type": "string", "title": "申请编号", "x-component": "Input" }, "amount": { "type": "number", "title": "金额", "x-component": "InputNumber" } } } """; // ==================================================================== // SubmitFormData // ==================================================================== [Fact] public async Task SubmitFormData_StoresDataJson() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_StoresDataJson), seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId))); var handler = CreateSubmitHandler(db); var formDataId = await handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), CancellationToken.None); formDataId.Should().NotBe(Guid.Empty); var savedData = await db.FormData.FindAsync(formDataId); savedData.Should().NotBeNull(); savedData!.DataJson.Should().Be(dataJson); } [Fact] public async Task SubmitFormData_WithComputedField_SavesNormalizedData() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_WithComputedField_SavesNormalizedData), seedAction: ctx => { ctx.FormDefinitions.Add(new FormDefinition { Id = formId, Name = "费用表单", Code = "expense-form", Status = FormStatus.Published, Version = 1, SchemaJson = ComputedExpenseSchema }); }); var aggregateService = new RecordingAggregateRefreshService(); var handler = new SubmitFormDataCommandHandler( db, new FormValueDerivationService([], []), aggregateService); var id = await handler.Handle( new SubmitFormDataCommand(formId, instanceId, """{"quantity":2,"unitPrice":12,"total":1}"""), CancellationToken.None); var saved = await db.FormData.FindAsync(id); saved!.DataJson.Should().Be("""{"quantity":2,"unitPrice":12,"total":24}"""); aggregateService.RefreshCalls.Should().ContainSingle(call => call.FormDefinitionId == formId && call.InstanceId == instanceId); aggregateService.RefreshAffectedCalls.Should().ContainSingle(call => call.FormDefinitionId == formId && call.InstanceId == instanceId); } [Fact] public async Task SubmitFormData_LineForm_RefreshesAggregateOnMainForm() { var mainFormId = Guid.NewGuid(); var lineFormId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_LineForm_RefreshesAggregateOnMainForm), seedAction: ctx => { ctx.FormDefinitions.AddRange( new FormDefinition { Id = mainFormId, Name = "费用主表", Code = "expense-main", Status = FormStatus.Published, Version = 1, SchemaJson = AggregateMainSchema, }, new FormDefinition { Id = lineFormId, Name = "费用明细", Code = "expense-line", Status = FormStatus.Published, Version = 1, SchemaJson = AggregateLineSchema, }); ctx.FormData.Add(new FormData { Id = Guid.NewGuid(), FormDefinitionId = mainFormId, InstanceId = instanceId, DataJson = """{"requestId":"REQ-001","lineTotal":0}""", }); }); var aggregateService = new FormAggregateRefreshService(db); var handler = new SubmitFormDataCommandHandler( db, new FormValueDerivationService([], []), aggregateService); await handler.Handle( new SubmitFormDataCommand(lineFormId, instanceId, """{"requestId":"REQ-001","amount":25}"""), CancellationToken.None); var main = await db.FormData.SingleAsync(f => f.FormDefinitionId == mainFormId); main.DataJson.Should().Contain("\"lineTotal\":25"); } [Fact] public async Task SubmitFormData_LineForm_RefreshesAggregateInInstanceVariables() { var mainFormId = Guid.NewGuid(); var lineFormId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_LineForm_RefreshesAggregateInInstanceVariables), seedAction: ctx => { ctx.FormDefinitions.AddRange( new FormDefinition { Id = mainFormId, Name = "费用主表", Code = "expense-main", Status = FormStatus.Published, Version = 1, SchemaJson = AggregateMainSchema, }, new FormDefinition { Id = lineFormId, Name = "费用明细", Code = "expense-line", Status = FormStatus.Published, Version = 1, SchemaJson = AggregateLineSchema, }); ctx.WorkflowInstances.Add(new WorkflowInstance { Id = instanceId, DefinitionId = Guid.NewGuid(), Title = "费用流程", Variables = """{"requestId":"REQ-001","lineTotal":0}""", }); ctx.FormData.Add(new FormData { Id = Guid.NewGuid(), FormDefinitionId = mainFormId, InstanceId = instanceId, DataJson = """{"requestId":"REQ-001","lineTotal":0}""", }); }); var handler = new SubmitFormDataCommandHandler( db, new FormValueDerivationService([], []), new FormAggregateRefreshService(db)); await handler.Handle( new SubmitFormDataCommand(lineFormId, instanceId, """{"requestId":"REQ-001","amount":25}"""), CancellationToken.None); var instance = await db.WorkflowInstances.FindAsync(instanceId); instance!.Variables.Should().Contain("\"lineTotal\":25"); } [Fact] public async Task SubmitFormData_AssociatesWithFormDefinitionAndInstance() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_AssociatesWithFormDefinitionAndInstance), seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId))); var handler = CreateSubmitHandler(db); var formDataId = await handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: """{"name":"李四","age":30,"gender":"female"}"""), CancellationToken.None); var savedData = await db.FormData.FindAsync(formDataId); savedData.Should().NotBeNull(); savedData!.FormDefinitionId.Should().Be(formId); savedData.InstanceId.Should().Be(instanceId); } [Fact] public async Task SubmitFormData_RequiredFieldMissing_ThrowsBusinessException() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var dataJson = """{"age":25,"gender":"male"}"""; await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_RequiredFieldMissing_ThrowsBusinessException), seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId))); var handler = CreateSubmitHandler(db); var act = () => handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*必填*姓名*"); var count = await db.FormData.CountAsync(); count.Should().Be(0); } [Fact] public async Task SubmitFormData_InvalidNumberType_ThrowsBusinessException() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var dataJson = """{"name":"赵六","age":"abc","gender":"male"}"""; await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_InvalidNumberType_ThrowsBusinessException), seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId))); var handler = CreateSubmitHandler(db); var act = () => handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*age*类型*"); var count = await db.FormData.CountAsync(); count.Should().Be(0); } /// /// 边界:表单已被软删除时提交数据,必须给出准确错误(表单已被删除), /// 而非误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。 /// [Fact] public async Task SubmitFormData_WithSoftDeletedFormDefinition_ThrowsAccurateError() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_WithSoftDeletedFormDefinition_ThrowsAccurateError), seedAction: ctx => { var form = CreatePublishedFormDefinition(formId); form.IsDeleted = true; ctx.FormDefinitions.Add(form); }); var handler = CreateSubmitHandler(db); var act = () => handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*表单*删除*"); var count = await db.FormData.CountAsync(); count.Should().Be(0); } /// /// 边界:确实不存在的表单 ID,必须给出准确错误(不存在)。 /// 与软删除场景区分,确保错误信息不会混淆。 /// [Fact] public async Task SubmitFormData_WithTrulyNonExistentFormDefinition_ThrowsNotFound() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; await using var db = _fixture.CreateDbContext( testName: nameof(SubmitFormData_WithTrulyNonExistentFormDefinition_ThrowsNotFound)); var handler = CreateSubmitHandler(db); var act = () => handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), CancellationToken.None); var exception = await act.Should().ThrowAsync(); exception.WithMessage("*不存在*"); exception.Which.Code.Should().Be(ErrorCode.NotFound); var count = await db.FormData.CountAsync(); count.Should().Be(0); } /// /// 边界:表单状态为 Disabled(已停用)时提交数据,必须严格阻断。 /// 产品决策:Disabled = 严格阻断(新提交一律拒绝)。 /// [Fact] public async Task SubmitFormData_WithDisabledForm_ThrowsBusinessException() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(SubmitFormData_WithDisabledForm_ThrowsBusinessException), seedAction: ctx => { var form = CreatePublishedFormDefinition(formId); form.Status = FormStatus.Disabled; ctx.FormDefinitions.Add(form); }); var handler = CreateSubmitHandler(db); var act = () => handler.Handle( new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*表单*停用*"); var count = await db.FormData.CountAsync(); count.Should().Be(0); } // ==================================================================== // GetFormDataByInstance // ==================================================================== [Fact] public async Task GetFormDataByInstance_ReturnsFormData() { var formId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var formDataId = Guid.NewGuid(); var dataJson = """{"name":"钱七","age":35,"gender":"male"}"""; await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(GetFormDataByInstance_ReturnsFormData), seedAction: ctx => { ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId)); ctx.FormData.Add(new FormData { Id = formDataId, FormDefinitionId = formId, InstanceId = instanceId, DataJson = dataJson, }); }); var handler = new GetFormDataByInstanceQueryHandler(db); var result = await handler.Handle(new GetFormDataByInstanceQuery(InstanceId: instanceId), CancellationToken.None); result.Should().NotBeNull(); result!.Id.Should().Be(formDataId); result.DataJson.Should().Be(dataJson); } [Fact] public async Task GetFormDataByInstance_NotFound_ReturnsNull() { await using var db = _fixture.CreateDbContext(testName: nameof(GetFormDataByInstance_NotFound_ReturnsNull)); var handler = new GetFormDataByInstanceQueryHandler(db); var result = await handler.Handle(new GetFormDataByInstanceQuery(InstanceId: Guid.NewGuid()), CancellationToken.None); result.Should().BeNull(); } [Fact] public async Task GetFormDataList_ReturnsPagedRowsForSelectedFormDefinition() { var targetFormId = Guid.NewGuid(); var otherFormId = Guid.NewGuid(); var firstInstanceId = Guid.NewGuid(); var secondInstanceId = Guid.NewGuid(); await using var db = await _fixture.CreateDbContextWithSeedAsync( testName: nameof(GetFormDataList_ReturnsPagedRowsForSelectedFormDefinition), seedAction: ctx => { ctx.FormDefinitions.Add(CreatePublishedFormDefinition(targetFormId)); ctx.FormDefinitions.Add(CreatePublishedFormDefinition(otherFormId)); ctx.FormData.AddRange( new FormData { Id = Guid.NewGuid(), FormDefinitionId = targetFormId, InstanceId = firstInstanceId, DataJson = """{"name":"第一条","age":20,"gender":"male"}""", CreatedAt = DateTime.UtcNow.AddMinutes(-2), }, new FormData { Id = Guid.NewGuid(), FormDefinitionId = otherFormId, InstanceId = Guid.NewGuid(), DataJson = """{"name":"其它表单","age":99,"gender":"female"}""", CreatedAt = DateTime.UtcNow.AddMinutes(-1), }, new FormData { Id = Guid.NewGuid(), FormDefinitionId = targetFormId, InstanceId = secondInstanceId, DataJson = """{"name":"第二条","age":30,"gender":"female"}""", CreatedAt = DateTime.UtcNow, }); }); var handler = new GetFormDataListQueryHandler(db); var result = await handler.Handle( new GetFormDataListQuery(targetFormId, PageIndex: 1, PageSize: 10), CancellationToken.None); result.Total.Should().Be(2); result.Items.Select(x => x.FormDefinitionId).Should().OnlyContain(x => x == targetFormId); result.Items.Select(x => x.InstanceId).Should().Equal(secondInstanceId, firstInstanceId); } private sealed class RecordingAggregateRefreshService : IFormAggregateRefreshService { public List<(Guid FormDefinitionId, Guid InstanceId)> RefreshCalls { get; } = []; public List<(Guid FormDefinitionId, Guid InstanceId)> RefreshAffectedCalls { get; } = []; public Task RefreshAsync(Guid formDefinitionId, Guid instanceId, CancellationToken ct) { RefreshCalls.Add((formDefinitionId, instanceId)); return Task.CompletedTask; } public Task RefreshAffectedAsync(Guid sourceFormDefinitionId, Guid instanceId, CancellationToken ct) { RefreshAffectedCalls.Add((sourceFormDefinitionId, instanceId)); return Task.CompletedTask; } } }