- Add FormDefinitionVersion with compare/versions endpoints and schema differ - Add Notification entity, endpoints and application features - Add Scheduler (timeout) and WebhookDispatcher services - Add FormDataValidator/FieldPermissionEvaluator/ReactionEvaluator - Add workflow task mark-read, CC support and SystemUserContext - Add EF migrations for form versions and notifications - Add unit tests for form schema, notifications, scheduler and serialization
305 lines
12 KiB
C#
305 lines
12 KiB
C#
using FluentAssertions;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Workflow.Application.Form.FormData.Commands;
|
||
using Workflow.Application.Form.FormData.Queries;
|
||
using Workflow.Domain.Enums;
|
||
using Workflow.Domain.Exceptions;
|
||
using Workflow.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
namespace Workflow.Tests.Form;
|
||
|
||
[Collection("FormTests")]
|
||
public class FormDataTests
|
||
{
|
||
private readonly FormTestFixture _fixture;
|
||
|
||
public FormDataTests(FormTestFixtureClassFixture fixture)
|
||
{
|
||
_fixture = fixture;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 辅助方法:创建一个已发布的表单定义(含 Formily Schema),用于 FormData 测试。
|
||
/// Schema 包含:name (string, required)、age (number)、gender (string, required)、resume (array)。
|
||
/// </summary>
|
||
private static Domain.Entities.FormDefinition CreatePublishedFormDefinition(Guid formId)
|
||
{
|
||
return new Domain.Entities.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" }
|
||
}
|
||
}
|
||
}
|
||
""",
|
||
};
|
||
}
|
||
|
||
// ====================================================================
|
||
// 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 = new SubmitFormDataCommandHandler(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_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 = new SubmitFormDataCommandHandler(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 = new SubmitFormDataCommandHandler(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 = new SubmitFormDataCommandHandler(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 = new SubmitFormDataCommandHandler(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_ThrowsAccurateError()
|
||
{
|
||
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_ThrowsAccurateError));
|
||
|
||
var handler = new SubmitFormDataCommandHandler(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>
|
||
/// 边界:表单状态为 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 = new SubmitFormDataCommandHandler(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 Domain.Entities.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();
|
||
}
|
||
}
|