2026-07-24 12:47:09 +08:00

594 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
/// <summary>
/// 辅助方法:创建一个已发布的表单定义(含 Formily Schema),用于 FormData 测试。
/// Schema 包含:name (string, required)、age (number)、gender (string, required)、resume (array)。
/// </summary>
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<BusinessException>()
.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<BusinessException>()
.WithMessage("*age*类型*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
/// <summary>
/// 边界:表单已被软删除时提交数据,必须给出准确错误(表单已被删除),
/// 而非误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。
/// </summary>
[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<BusinessException>()
.WithMessage("*表单*删除*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
/// <summary>
/// 边界:确实不存在的表单 ID,必须给出准确错误(不存在)。
/// 与软删除场景区分,确保错误信息不会混淆。
/// </summary>
[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<NotFoundException>();
exception.WithMessage("*不存在*");
exception.Which.Code.Should().Be(ErrorCode.NotFound);
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
/// <summary>
/// 边界:表单状态为 Disabled(已停用)时提交数据,必须严格阻断。
/// 产品决策:Disabled = 严格阻断(新提交一律拒绝)。
/// </summary>
[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<BusinessException>()
.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;
}
}
}