work-flow/tests/Workflow.Tests/Form/FormDataTests.cs
向宁 9f878286e7 feat: form versioning, notification center, scheduler and webhooks
- 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
2026-06-14 15:03:11 +08:00

305 lines
12 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 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();
}
}