work-flow/tests/Workflow.Tests/Form/SchemaValidatorTests.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

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");
}
}