using FluentAssertions; using Workflow.Application.Form.Schema; using Xunit; namespace Workflow.Tests.Form; public class SchemaValidatorTests { private static readonly HashSet 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 { "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"); } }