- 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
415 lines
12 KiB
C#
415 lines
12 KiB
C#
using FluentAssertions;
|
|
using Workflow.Application.Form.Schema;
|
|
using Xunit;
|
|
|
|
namespace Workflow.Tests.Form;
|
|
|
|
public class SchemaValidatorTests
|
|
{
|
|
private static readonly HashSet<string> AllowedComponents =
|
|
["Input", "InputNumber", "Select", "Radio.Group", "Checkbox.Group", "FormGrid", "Card"];
|
|
|
|
[Fact]
|
|
public void Validate_NormalizesInputNumberAliasAndExtractsFieldSummary()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"amount": {
|
|
"type": "number",
|
|
"title": "金额",
|
|
"required": true,
|
|
"x-component": "Input.Number",
|
|
"x-validator": [
|
|
{ "type": "min", "value": 0, "message": "不能小于 0" },
|
|
{ "type": "max", "value": 9999, "message": "不能大于 9999" }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
result.Fields.Should().ContainSingle();
|
|
var field = result.Fields.Single();
|
|
field.Path.Should().Be("amount");
|
|
field.JsonType.Should().Be("number");
|
|
field.Component.Should().Be("InputNumber");
|
|
field.Required.Should().BeTrue();
|
|
field.Validators.Select(v => v.Type).Should().Contain(["min", "max"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_AcceptsInputNumberWhenAllowedComponentsUseLegacyAlias()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"amount": {
|
|
"type": "number",
|
|
"title": "金额",
|
|
"x-component": "InputNumber"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
var allowedComponents = new HashSet<string> { "Input.Number" };
|
|
|
|
var result = SchemaValidator.Validate(schema, allowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
result.Fields.Single().Component.Should().Be("InputNumber");
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_RejectsInvalidFieldKey()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"123 bad": {
|
|
"type": "string",
|
|
"title": "坏字段",
|
|
"x-component": "Input"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("字段名") && e.Contains("123 bad"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_ExtractsStaticDataSourceOptions()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"gender": {
|
|
"type": "string",
|
|
"title": "性别",
|
|
"required": true,
|
|
"x-component": "Select",
|
|
"x-data-source": {
|
|
"type": "static",
|
|
"options": [
|
|
{ "label": "男", "value": "male" },
|
|
{ "label": "女", "value": "female", "disabled": true }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
var field = result.Fields.Single();
|
|
field.Options.Should().HaveCount(2);
|
|
field.Options[0].Label.Should().Be("男");
|
|
field.Options[0].Value.GetString().Should().Be("male");
|
|
field.Options[1].Disabled.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_ConvertsLegacyTopLevelRequiredIntoFieldSummary()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"required": ["name"],
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"title": "姓名",
|
|
"x-component": "Input"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
result.Fields.Single().Required.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_AcceptsValidConditionReaction()
|
|
{
|
|
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": "annual" },
|
|
"action": "visible"
|
|
}
|
|
]
|
|
},
|
|
"reason": {
|
|
"type": "string",
|
|
"title": "原因",
|
|
"x-component": "Input"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_AcceptsValidDataReactionWithLiteralOperand()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"price": {
|
|
"type": "number",
|
|
"title": "单价",
|
|
"x-component": "InputNumber",
|
|
"x-reactions": [
|
|
{
|
|
"type": "data",
|
|
"target": "total",
|
|
"expression": { "left": "price", "operator": "multiply", "right": "8" }
|
|
}
|
|
]
|
|
},
|
|
"total": {
|
|
"type": "number",
|
|
"title": "总价",
|
|
"x-component": "InputNumber"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_RejectsReactionTargetFieldNotFound()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"leaveType": {
|
|
"type": "string",
|
|
"title": "请假类型",
|
|
"x-component": "Select",
|
|
"x-reactions": [
|
|
{
|
|
"type": "condition",
|
|
"target": "nonExist",
|
|
"when": { "source": "leaveType", "operator": "eq", "value": "annual" },
|
|
"action": "visible"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("target") && e.Contains("nonExist"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_RejectsInvalidReactionOperator()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"leaveType": {
|
|
"type": "string",
|
|
"title": "请假类型",
|
|
"x-component": "Select",
|
|
"x-reactions": [
|
|
{
|
|
"type": "condition",
|
|
"target": "reason",
|
|
"when": { "source": "leaveType", "operator": "equals", "value": "annual" },
|
|
"action": "visible"
|
|
}
|
|
]
|
|
},
|
|
"reason": {
|
|
"type": "string",
|
|
"title": "原因",
|
|
"x-component": "Input"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("when.operator") && e.Contains("equals"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_AcceptsRelativeRemoteDataSource()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"user": {
|
|
"type": "string",
|
|
"title": "用户",
|
|
"x-component": "Select",
|
|
"x-data-source": {
|
|
"type": "remote",
|
|
"url": "/forms",
|
|
"method": "GET",
|
|
"labelField": "name",
|
|
"valueField": "id"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_RejectsAbsoluteRemoteUrl()
|
|
{
|
|
foreach (var url in new[] { "http://evil.com/x", "https://evil.com/x", "//evil.com/x", "ftp://x/y" })
|
|
{
|
|
var schema = "{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\",\"title\":\"用户\",\"x-component\":\"Select\",\"x-data-source\":{\"type\":\"remote\",\"url\":\"" + url + "\",\"method\":\"GET\"}}}}";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeFalse($"url={url} should be rejected");
|
|
result.Errors.Should().Contain(e => e.Contains("url") && e.Contains("相对路径"));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_RejectsRemoteDataSourceWithoutUrl()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"user": {
|
|
"type": "string",
|
|
"title": "用户",
|
|
"x-component": "Select",
|
|
"x-data-source": {
|
|
"type": "remote"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("remote") && e.Contains("url"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_FieldPermission_WithValidActions_Passes()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"salary": {
|
|
"type": "number", "title": "薪资", "x-component": "InputNumber",
|
|
"x-field-permission": {
|
|
"直属主管审批": "visible",
|
|
"HR审批": "readonly",
|
|
"财务审批": "hidden",
|
|
"__default__": "hidden"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_FieldPermission_WithInvalidAction_Fails()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"salary": {
|
|
"type": "number", "x-component": "InputNumber",
|
|
"x-field-permission": { "HR审批": "invisible" }
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("字段权限") && e.Contains("invisible"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_ExtractsFieldPermissionIntoFieldSummary()
|
|
{
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"salary": {
|
|
"type": "number", "x-component": "InputNumber",
|
|
"x-field-permission": { "HR审批": "hidden" }
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = SchemaValidator.Validate(schema, AllowedComponents);
|
|
|
|
result.IsValid.Should().BeTrue();
|
|
var salary = result.Fields.Should().ContainSingle(f => f.Path == "salary").Subject;
|
|
salary.FieldPermission.Should().NotBeNull();
|
|
salary.FieldPermission!["HR审批"].Should().Be("hidden");
|
|
}
|
|
}
|