- 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
325 lines
11 KiB
C#
325 lines
11 KiB
C#
using FluentAssertions;
|
||
using Workflow.Application.Form.Schema;
|
||
using Xunit;
|
||
|
||
namespace Workflow.Tests.Form;
|
||
|
||
public class FormDataValidatorTests
|
||
{
|
||
private const string Schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"title": "姓名",
|
||
"required": true,
|
||
"x-component": "Input",
|
||
"x-validator": [
|
||
{ "type": "minLength", "value": 2, "message": "姓名至少 2 个字符" }
|
||
]
|
||
},
|
||
"age": {
|
||
"type": "number",
|
||
"title": "年龄",
|
||
"x-component": "InputNumber",
|
||
"x-validator": [
|
||
{ "type": "min", "value": 0, "message": "年龄不能小于 0" },
|
||
{ "type": "max", "value": 150, "message": "年龄不能大于 150" }
|
||
]
|
||
},
|
||
"gender": {
|
||
"type": "string",
|
||
"title": "性别",
|
||
"required": true,
|
||
"x-component": "Select",
|
||
"x-data-source": {
|
||
"type": "static",
|
||
"options": [
|
||
{ "label": "男", "value": "male" },
|
||
{ "label": "女", "value": "female" }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
private static readonly HashSet<string> AllowedComponents = ["Input", "InputNumber", "Select"];
|
||
|
||
[Fact]
|
||
public void Validate_WithValidData_ReturnsValid()
|
||
{
|
||
var result = FormDataValidator.Validate(Schema, """{"name":"张三","age":25,"gender":"male"}""", AllowedComponents);
|
||
|
||
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
||
result.Errors.Should().BeEmpty();
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_WithMissingRequiredField_ReturnsFieldError()
|
||
{
|
||
var result = FormDataValidator.Validate(Schema, """{"age":25,"gender":"male"}""", AllowedComponents);
|
||
|
||
result.IsValid.Should().BeFalse();
|
||
result.Errors.Should().Contain(e => e.Contains("姓名") && e.Contains("必填"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_WithEnumMismatch_ReturnsFieldError()
|
||
{
|
||
var result = FormDataValidator.Validate(Schema, """{"name":"张三","age":25,"gender":"other"}""", AllowedComponents);
|
||
|
||
result.IsValid.Should().BeFalse();
|
||
result.Errors.Should().Contain(e => e.Contains("gender") && e.Contains("允许范围"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_WithLengthAndRangeViolations_ReturnsAllErrors()
|
||
{
|
||
var result = FormDataValidator.Validate(Schema, """{"name":"李","age":151,"gender":"male"}""", AllowedComponents);
|
||
|
||
result.IsValid.Should().BeFalse();
|
||
result.Errors.Should().Contain(e => e.Contains("姓名至少 2 个字符"));
|
||
result.Errors.Should().Contain(e => e.Contains("年龄不能大于 150"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_HiddenFieldByReaction_SkipsRequiredCheck()
|
||
{
|
||
// leaveType != sick 时(visible 规则未命中),reason 被联动隐藏。
|
||
// reason 即使必填缺失也不应报错。
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"leaveType": {
|
||
"type": "string",
|
||
"title": "请假类型",
|
||
"x-component": "Select",
|
||
"x-reactions": [
|
||
{
|
||
"type": "condition",
|
||
"target": "reason",
|
||
"when": { "source": "leaveType", "operator": "eq", "value": "sick" },
|
||
"action": "visible"
|
||
}
|
||
]
|
||
},
|
||
"reason": {
|
||
"type": "string",
|
||
"title": "原因",
|
||
"required": true,
|
||
"x-component": "Input"
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
// leaveType=annual 未命中 visible 规则 → reason 隐藏 → 跳过必填
|
||
var result = FormDataValidator.Validate(
|
||
schema,
|
||
"""{"leaveType":"annual"}""",
|
||
["Input", "Select"]);
|
||
|
||
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_NestedFieldInContainer_ReadsValueByDottedPath()
|
||
{
|
||
// FormGrid/FormLayout 等容器下的嵌套字段:field.Path 为点分路径(dateRange.startDate),
|
||
// 提交数据是嵌套对象 {dateRange:{startDate:...}}。校验器必须按点分路径取值,
|
||
// 否则嵌套必填字段会被误判为缺失,导致带容器的表单无法通过校验。
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"leaveType": {
|
||
"type": "string",
|
||
"title": "请假类型",
|
||
"required": true,
|
||
"x-component": "Select"
|
||
},
|
||
"dateRange": {
|
||
"type": "void",
|
||
"title": "日期范围",
|
||
"x-component": "FormGrid",
|
||
"properties": {
|
||
"startDate": {
|
||
"type": "string",
|
||
"title": "开始日期",
|
||
"required": true,
|
||
"x-component": "DatePicker"
|
||
},
|
||
"endDate": {
|
||
"type": "string",
|
||
"title": "结束日期",
|
||
"required": true,
|
||
"x-component": "DatePicker"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
var result = FormDataValidator.Validate(
|
||
schema,
|
||
"""{"leaveType":"annual","dateRange":{"startDate":"2026-06-15","endDate":"2026-06-16"}}""",
|
||
["Input", "Select", "DatePicker", "FormGrid"]);
|
||
|
||
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_NestedFieldMissing_ReportsRequiredError()
|
||
{
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"dateRange": {
|
||
"type": "void",
|
||
"x-component": "FormGrid",
|
||
"properties": {
|
||
"startDate": {
|
||
"type": "string",
|
||
"title": "开始日期",
|
||
"required": true,
|
||
"x-component": "DatePicker"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
var result = FormDataValidator.Validate(
|
||
schema,
|
||
"""{"dateRange":{}}""",
|
||
["DatePicker", "FormGrid"]);
|
||
|
||
result.IsValid.Should().BeFalse();
|
||
result.Errors.Should().Contain(e => e.Contains("开始日期") && e.Contains("必填"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_VisibleRequiredField_StillEnforced()
|
||
{
|
||
// leaveType=sick 时(visible 规则命中),reason 可见且必填,缺失应报错
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"leaveType": {
|
||
"type": "string",
|
||
"title": "请假类型",
|
||
"x-component": "Select",
|
||
"x-reactions": [
|
||
{
|
||
"type": "condition",
|
||
"target": "reason",
|
||
"when": { "source": "leaveType", "operator": "eq", "value": "sick" },
|
||
"action": "visible"
|
||
}
|
||
]
|
||
},
|
||
"reason": {
|
||
"type": "string",
|
||
"title": "原因",
|
||
"required": true,
|
||
"x-component": "Input"
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
// leaveType=sick 命中 visible 规则 → reason 显示 → 必填校验生效
|
||
var result = FormDataValidator.Validate(
|
||
schema,
|
||
"""{"leaveType":"sick"}""",
|
||
["Input", "Select"]);
|
||
|
||
result.IsValid.Should().BeFalse();
|
||
result.Errors.Should().Contain(e => e.Contains("原因") && e.Contains("必填"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_FieldHiddenByPermissionForCurrentNode_SkipsRequiredCheck()
|
||
{
|
||
// 字段 salary 配置了在"HR审批"节点 hidden。提交时若按 HR审批 节点校验,salary 缺失应跳过必填。
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"name": { "type": "string", "title": "姓名", "required": true, "x-component": "Input" },
|
||
"salary": {
|
||
"type": "number", "title": "薪资", "required": true, "x-component": "InputNumber",
|
||
"x-field-permission": { "HR审批": "hidden" }
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
// 不传节点:salary 是必填,缺失应报错
|
||
var noNode = FormDataValidator.Validate(schema, """{"name":"张三"}""", ["Input", "InputNumber"]);
|
||
noNode.IsValid.Should().BeFalse("无节点上下文时不做权限过滤,salary 必填缺失应报错");
|
||
|
||
// 传 HR审批 节点:salary 在该节点 hidden,应跳过必填校验 → 通过
|
||
var hrNode = FormDataValidator.Validate(
|
||
schema, """{"name":"张三"}""", ["Input", "InputNumber"], currentNodeKey: "HR审批");
|
||
hrNode.IsValid.Should().BeTrue(string.Join("; ", hrNode.Errors));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_FieldVisibleForCurrentNode_StillEnforcesRequired()
|
||
{
|
||
// salary 在"直属主管审批"节点 visible(可见可编辑),缺失应报错
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"salary": {
|
||
"type": "number", "title": "薪资", "required": true, "x-component": "InputNumber",
|
||
"x-field-permission": { "直属主管审批": "visible", "HR审批": "hidden" }
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
var result = FormDataValidator.Validate(
|
||
schema, """{}""", ["InputNumber"], currentNodeKey: "直属主管审批");
|
||
|
||
result.IsValid.Should().BeFalse();
|
||
result.Errors.Should().Contain(e => e.Contains("薪资") && e.Contains("必填"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_PermissionDefaultFallback_AppliesWhenNodeNotExplicitlyConfigured()
|
||
{
|
||
// salary 配了 __default__: hidden。当前节点"未知节点"未显式配置 → 走 default → 隐藏 → 跳过必填
|
||
const string schema = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"salary": {
|
||
"type": "number", "title": "薪资", "required": true, "x-component": "InputNumber",
|
||
"x-field-permission": { "__default__": "hidden", "直属主管审批": "visible" }
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
// 直属主管审批:显式 visible → 必填生效 → 缺失报错
|
||
var manager = FormDataValidator.Validate(
|
||
schema, """{}""", ["InputNumber"], currentNodeKey: "直属主管审批");
|
||
manager.IsValid.Should().BeFalse("直属主管审批节点 salary 可见,必填缺失应报错");
|
||
|
||
// 未知节点:走 __default__ = hidden → 跳过必填 → 通过
|
||
var unknown = FormDataValidator.Validate(
|
||
schema, """{}""", ["InputNumber"], currentNodeKey: "未知节点");
|
||
unknown.IsValid.Should().BeTrue("未知节点走 __default__ hidden,应跳过必填");
|
||
}
|
||
}
|