using System.Reflection; using Microsoft.AspNetCore.Mvc; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RULE-GOVERNANCE-BATCH1:运行参数写入契约 + G1 回归。 /// /// G1 是什么(本地实测,非推演):旧的 PUT /{id}/params 是"整块覆盖 + /// 缺字段即置 NULL"。本地 Rule 01 在 13:34:13 还在正常命中 28 条, /// 13:40 一次 {"enabled":false} 之后 params_json 变成 NULL —— 规则的全部业务配置消失, /// 而旧 evaluator 在 params_json 为空时抛 rule_not_configured,等于这条规则被一次关开关打死。 /// /// 本文件用**行为测试**钉住修复:参数叠加是纯函数 S8WatchRuleService.ApplyParameters, /// 可以不接数据库逐字段断言"未提供 = 不变"。结构性约束(端点分离、Definition 不可写) /// 才用反射守卫。 /// public class S8RuleParameterContractTests { private static S8RuleDefinition Rule01() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode); /// 一条参数**齐全**的规则行,模拟 G1 发生前的真实状态。 private static AdoS8WatchRule FullyConfiguredRule() => new() { Id = 1329909460023L, TenantId = 838257186181189L, FactoryId = 838257186320453L, RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode, RuleType = "TIMEOUT", DatasetCode = "PURCHASE_DELIVERY", SourceObjectType = "PURCHASE_ORDER_LINE", SceneCode = "S4", Severity = S8SeverityCode.Serious, PollIntervalSeconds = 600, TriggerCountRequired = 3, RecoverCountRequired = 2, Enabled = true, ParamsJson = """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}""" }; private static S8RuleRuntimeParameters Apply(S8RuleParametersPayload payload) => S8WatchRuleService.ApplyParameters(FullyConfiguredRule(), payload, Rule01()); // ───────────────────────── T6:参数白名单 · 单字段隔离 ───────────────────────── [Fact] public void T6_ChangingGraceMinutes_LeavesEveryOtherParameterUntouched() { var next = Apply(new S8RuleParametersPayload { GraceMinutes = 15 }); Assert.Equal(15, next.GraceMinutes); // 改了的 Assert.Equal(600, next.PollIntervalSeconds); // 没改的,逐个确认 Assert.Equal(3, next.TriggerCountRequired); Assert.Equal(2, next.RecoverCountRequired); Assert.Equal(S8SeverityCode.Serious, next.Severity); Assert.Equal(245L, next.DefaultOccurrenceDeptId); Assert.Equal(246L, next.DefaultResponsibleDeptId); } [Fact] public void T6_EmptyPayload_ChangesNothing() { var before = S8RuleRuntimeParameters.Resolve(FullyConfiguredRule(), Rule01()); var after = Apply(new S8RuleParametersPayload()); Assert.Equal(before.PollIntervalSeconds, after.PollIntervalSeconds); Assert.Equal(before.TriggerCountRequired, after.TriggerCountRequired); Assert.Equal(before.RecoverCountRequired, after.RecoverCountRequired); Assert.Equal(before.GraceMinutes, after.GraceMinutes); Assert.Equal(before.Severity, after.Severity); Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId); Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId); } [Fact] public void T6_ChangingSeverity_LeavesGraceAndScheduleUntouched() { var next = Apply(new S8RuleParametersPayload { Severity = S8SeverityCode.Follow }); Assert.Equal(S8SeverityCode.Follow, next.Severity); Assert.Equal(45, next.GraceMinutes); Assert.Equal(600, next.PollIntervalSeconds); } [Theory] [InlineData(59)] // < min [InlineData(86401)] // > max public void T6_OutOfRangePollInterval_IsRejected(int value) { var ex = Assert.Throws(() => Apply(new S8RuleParametersPayload { PollIntervalSeconds = value })); Assert.Contains("轮询间隔", ex.Message); } [Theory] [InlineData(0)] [InlineData(11)] public void T6_OutOfRangeAntiflapCounts_AreRejected(int value) { Assert.Throws(() => Apply(new S8RuleParametersPayload { TriggerCountRequired = value })); Assert.Throws(() => Apply(new S8RuleParametersPayload { RecoverCountRequired = value })); } [Fact] public void T6_NegativeGraceMinutes_IsRejected() { Assert.Throws(() => Apply(new S8RuleParametersPayload { GraceMinutes = -1 })); } /// /// severity 越权值必须**报错**,不能像 S8SeverityCode.Normalize 那样静默降级成 FOLLOW。 /// 库里 severity='HIGH' 的那一行当年正是被静默降级放进来的。 /// [Theory] [InlineData("HIGH")] [InlineData("CRITICAL")] [InlineData("bogus")] public void T6_NonWhitelistedSeverity_IsRejected(string severity) { var ex = Assert.Throws(() => Apply(new S8RuleParametersPayload { Severity = severity })); Assert.Contains(severity, ex.Message); } // ───────────────────────── T7:G1 —— 启停不得破坏参数 ───────────────────────── /// /// G1 的结构性修复:enabled 已经**不在**参数载荷里,因此"关开关顺手清空配置" /// 这条路径在类型层面就不存在了。 /// [Fact] public void T7_ParametersPayload_HasNoEnabledWriteCapability() { var payload = new S8RuleParametersPayload { Enabled = false }; // Enabled 属性仍然存在,但它是**毒丸**:出现即拒绝,绝不静默忽略。 Assert.Contains("enabled", payload.RejectedDefinitionFields); var ex = Assert.Throws(() => Apply(payload)); Assert.Contains("/enable", ex.Message); } /// /// G1 正面回归:旧写法 {"enabled":false} 现在既不会成功、更不会清空 params_json。 /// 与之对照,一次合法的参数更新必须**完整保留**其余全部参数。 /// [Fact] public void T7_LegacyDisablePayload_CannotWipeParamsJson() { var rule = FullyConfiguredRule(); var before = S8RuleRuntimeParameters.Resolve(rule, Rule01()); Assert.Throws(() => S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload { Enabled = false }, Rule01())); // 拒绝路径不得产生任何副作用:实体上的 params_json 逐字未变。 Assert.Equal( """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}""", rule.ParamsJson); // 且合法路径写回的 params_json 语义等价(键顺序可不同,值必须一致)。 var after = S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload(), Rule01()); Assert.Equal(before.GraceMinutes, after.GraceMinutes); Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId); Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId); } // ───────────────────────── T8 / T9:启停端点独立且幂等 ───────────────────────── [Fact] public void T8_EnableAndDisable_AreDedicatedEndpoints_SeparateFromParams() { var controller = typeof(AdoS8ConfigWatchRulesController); var enable = Assert.Single(Actions(controller, "EnableAsync")); var disable = Assert.Single(Actions(controller, "DisableAsync")); Assert.Equal("{id:long}/enable", Assert.Single(enable.GetCustomAttributes()).Template); Assert.Equal("{id:long}/disable", Assert.Single(disable.GetCustomAttributes()).Template); // 两者都不接 body:启停没有任何可配置项,接 body 就会重新打开"顺手改点别的"的口子。 Assert.Empty(enable.GetParameters().Where(p => p.ParameterType != typeof(long))); Assert.Empty(disable.GetParameters().Where(p => p.ParameterType != typeof(long))); foreach (var action in new[] { enable, disable }) Assert.NotEmpty(action.GetCustomAttributes() .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal))); } [Fact] public void T8_ParamsEndpoint_TakesOnlyTheNarrowParametersPayload() { var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "UpdateParamsAsync")); var body = Assert.Single(action.GetParameters().Where(p => p.ParameterType != typeof(long))); Assert.Equal(typeof(S8RuleParametersPayload), body.ParameterType); } [Fact] public void T9_ServiceExposes_EnableAndDisable_WithTrustedScope() { var t = typeof(S8WatchRuleService); Assert.NotNull(t.GetMethod("EnableAsync", new[] { typeof(long), typeof(S8TrustedScope) })); Assert.NotNull(t.GetMethod("DisableAsync", new[] { typeof(long), typeof(S8TrustedScope) })); // 旧的整块参数入口必须消失,否则 G1 的路径还在。 Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync")); Assert.NotNull(t.GetMethod("UpdateParametersAsync", new[] { typeof(long), typeof(S8RuleParametersPayload), typeof(S8TrustedScope) })); } // ───────────────────────── T10:Definition 字段一律不可写 ───────────────────────── [Theory] [InlineData("datasetCode")] [InlineData("ruleType")] [InlineData("sourceObjectType")] [InlineData("exceptionTypeCode")] [InlineData("dueAtField")] [InlineData("statusField")] [InlineData("objectIdField")] [InlineData("objectCodeField")] [InlineData("completedStates")] [InlineData("sceneCode")] [InlineData("stageCode")] [InlineData("orderFlowCode")] [InlineData("ruleMechanism")] [InlineData("paramsJson")] public void T10_DefinitionFields_AreRejected_NotSilentlyIgnored(string field) { var payload = new S8RuleParametersPayload(); switch (field) { case "datasetCode": payload.DatasetCode = "X"; break; case "ruleType": payload.RuleType = "X"; break; case "sourceObjectType": payload.SourceObjectType = "X"; break; case "exceptionTypeCode": payload.ExceptionTypeCode = "X"; break; case "dueAtField": payload.DueAtField = "X"; break; case "statusField": payload.StatusField = "X"; break; case "objectIdField": payload.ObjectIdField = "X"; break; case "objectCodeField": payload.ObjectCodeField = "X"; break; case "completedStates": payload.CompletedStates = new[] { "X" }; break; case "sceneCode": payload.SceneCode = "X"; break; case "stageCode": payload.StageCode = "X"; break; case "orderFlowCode": payload.OrderFlowCode = "X"; break; case "ruleMechanism": payload.RuleMechanism = "X"; break; case "paramsJson": payload.ParamsJson = "{}"; break; } Assert.Contains(field, payload.RejectedDefinitionFields); var ex = Assert.Throws(() => Apply(payload)); Assert.Contains(field, ex.Message); } [Fact] public void T10_CleanPayload_ReportsNoRejectedFields() { // 反向对照:拒绝逻辑不是"全部拒绝"。 var payload = new S8RuleParametersPayload { PollIntervalSeconds = 300, TriggerCountRequired = 1, RecoverCountRequired = 2, GraceMinutes = 0, Severity = S8SeverityCode.Serious, DefaultOccurrenceDeptId = 1, DefaultResponsibleDeptId = 2 }; Assert.Empty(payload.RejectedDefinitionFields); var next = Apply(payload); Assert.Equal(300, next.PollIntervalSeconds); Assert.Equal(0, next.GraceMinutes); } /// /// 白名单是**闭集**:新增可写属性必须是有意识的决定。 /// 若有人往载荷上加一个新的可写字段却忘了想清楚它是 A 类还是 B 类,本用例会失败。 /// [Fact] public void T10_PayloadShape_IsPinned() { var writable = typeof(S8RuleParametersPayload) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanWrite) .Select(p => p.Name) .OrderBy(n => n, StringComparer.Ordinal) .ToArray(); var expected = new[] { // 白名单(B 类) "DefaultOccurrenceDeptId", "DefaultResponsibleDeptId", "GraceMinutes", "PollIntervalSeconds", "RecoverCountRequired", "Severity", "TriggerCountRequired", // 毒丸(A 类 + 已迁走的 enabled):存在只为显式报错 "CompletedStates", "DatasetCode", "DueAtField", "Enabled", "ExceptionTypeCode", "ObjectCodeField", "ObjectIdField", "OrderFlowCode", "ParamsJson", "RuleMechanism", "RuleType", "SceneCode", "SourceObjectType", "StageCode", "StatusField" }.OrderBy(n => n, StringComparer.Ordinal).ToArray(); Assert.Equal(expected, writable); } private static IEnumerable Actions(Type controller, string name) => controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.Name == name); }