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