S8RuleParameterContractTests.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. using System.Reflection;
  2. using Microsoft.AspNetCore.Mvc;
  3. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure;
  6. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8;
  8. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  9. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  10. using Xunit;
  11. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  12. /// <summary>
  13. /// S8-RULE-GOVERNANCE-BATCH1:运行参数写入契约 + <b>G1 回归</b>。
  14. ///
  15. /// <para><b>G1 是什么</b>(本地实测,非推演):旧的 <c>PUT /{id}/params</c> 是"整块覆盖 +
  16. /// 缺字段即置 NULL"。本地 Rule 01 在 13:34:13 还在正常命中 28 条,
  17. /// 13:40 一次 <c>{"enabled":false}</c> 之后 <c>params_json</c> 变成 NULL —— 规则的全部业务配置消失,
  18. /// 而旧 evaluator 在 params_json 为空时抛 <c>rule_not_configured</c>,等于这条规则被一次关开关打死。</para>
  19. ///
  20. /// <para>本文件用**行为测试**钉住修复:参数叠加是纯函数 <c>S8WatchRuleService.ApplyParameters</c>,
  21. /// 可以不接数据库逐字段断言"未提供 = 不变"。结构性约束(端点分离、Definition 不可写)
  22. /// 才用反射守卫。</para>
  23. /// </summary>
  24. public class S8RuleParameterContractTests
  25. {
  26. private static S8RuleDefinition Rule01() =>
  27. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
  28. .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
  29. /// <summary>一条参数**齐全**的规则行,模拟 G1 发生前的真实状态。</summary>
  30. private static AdoS8WatchRule FullyConfiguredRule() => new()
  31. {
  32. Id = 1329909460023L,
  33. TenantId = 838257186181189L,
  34. FactoryId = 838257186320453L,
  35. RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode,
  36. RuleType = "TIMEOUT",
  37. DatasetCode = "PURCHASE_DELIVERY",
  38. SourceObjectType = "PURCHASE_ORDER_LINE",
  39. SceneCode = "S4",
  40. Severity = S8SeverityCode.Serious,
  41. PollIntervalSeconds = 600,
  42. TriggerCountRequired = 3,
  43. RecoverCountRequired = 2,
  44. Enabled = true,
  45. ParamsJson = """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}"""
  46. };
  47. private static S8RuleRuntimeParameters Apply(S8RuleParametersPayload payload) =>
  48. S8WatchRuleService.ApplyParameters(FullyConfiguredRule(), payload, Rule01());
  49. // ───────────────────────── T6:参数白名单 · 单字段隔离 ─────────────────────────
  50. [Fact]
  51. public void T6_ChangingGraceMinutes_LeavesEveryOtherParameterUntouched()
  52. {
  53. var next = Apply(new S8RuleParametersPayload { GraceMinutes = 15 });
  54. Assert.Equal(15, next.GraceMinutes); // 改了的
  55. Assert.Equal(600, next.PollIntervalSeconds); // 没改的,逐个确认
  56. Assert.Equal(3, next.TriggerCountRequired);
  57. Assert.Equal(2, next.RecoverCountRequired);
  58. Assert.Equal(S8SeverityCode.Serious, next.Severity);
  59. Assert.Equal(245L, next.DefaultOccurrenceDeptId);
  60. Assert.Equal(246L, next.DefaultResponsibleDeptId);
  61. }
  62. [Fact]
  63. public void T6_EmptyPayload_ChangesNothing()
  64. {
  65. var before = S8RuleRuntimeParameters.Resolve(FullyConfiguredRule(), Rule01());
  66. var after = Apply(new S8RuleParametersPayload());
  67. Assert.Equal(before.PollIntervalSeconds, after.PollIntervalSeconds);
  68. Assert.Equal(before.TriggerCountRequired, after.TriggerCountRequired);
  69. Assert.Equal(before.RecoverCountRequired, after.RecoverCountRequired);
  70. Assert.Equal(before.GraceMinutes, after.GraceMinutes);
  71. Assert.Equal(before.Severity, after.Severity);
  72. Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId);
  73. Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId);
  74. }
  75. [Fact]
  76. public void T6_ChangingSeverity_LeavesGraceAndScheduleUntouched()
  77. {
  78. var next = Apply(new S8RuleParametersPayload { Severity = S8SeverityCode.Follow });
  79. Assert.Equal(S8SeverityCode.Follow, next.Severity);
  80. Assert.Equal(45, next.GraceMinutes);
  81. Assert.Equal(600, next.PollIntervalSeconds);
  82. }
  83. [Theory]
  84. [InlineData(59)] // < min
  85. [InlineData(86401)] // > max
  86. public void T6_OutOfRangePollInterval_IsRejected(int value)
  87. {
  88. var ex = Assert.Throws<S8BizException>(() =>
  89. Apply(new S8RuleParametersPayload { PollIntervalSeconds = value }));
  90. Assert.Contains("轮询间隔", ex.Message);
  91. }
  92. [Theory]
  93. [InlineData(0)]
  94. [InlineData(11)]
  95. public void T6_OutOfRangeAntiflapCounts_AreRejected(int value)
  96. {
  97. Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { TriggerCountRequired = value }));
  98. Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { RecoverCountRequired = value }));
  99. }
  100. [Fact]
  101. public void T6_NegativeGraceMinutes_IsRejected()
  102. {
  103. Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { GraceMinutes = -1 }));
  104. }
  105. /// <summary>
  106. /// severity 越权值必须**报错**,不能像 <c>S8SeverityCode.Normalize</c> 那样静默降级成 FOLLOW。
  107. /// 库里 severity='HIGH' 的那一行当年正是被静默降级放进来的。
  108. /// </summary>
  109. [Theory]
  110. [InlineData("HIGH")]
  111. [InlineData("CRITICAL")]
  112. [InlineData("bogus")]
  113. public void T6_NonWhitelistedSeverity_IsRejected(string severity)
  114. {
  115. var ex = Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { Severity = severity }));
  116. Assert.Contains(severity, ex.Message);
  117. }
  118. // ───────────────────────── T7:G1 —— 启停不得破坏参数 ─────────────────────────
  119. /// <summary>
  120. /// G1 的结构性修复:<c>enabled</c> 已经**不在**参数载荷里,因此"关开关顺手清空配置"
  121. /// 这条路径在类型层面就不存在了。
  122. /// </summary>
  123. [Fact]
  124. public void T7_ParametersPayload_HasNoEnabledWriteCapability()
  125. {
  126. var payload = new S8RuleParametersPayload { Enabled = false };
  127. // Enabled 属性仍然存在,但它是**毒丸**:出现即拒绝,绝不静默忽略。
  128. Assert.Contains("enabled", payload.RejectedDefinitionFields);
  129. var ex = Assert.Throws<S8BizException>(() => Apply(payload));
  130. Assert.Contains("/enable", ex.Message);
  131. }
  132. /// <summary>
  133. /// G1 正面回归:旧写法 <c>{"enabled":false}</c> 现在既不会成功、更不会清空 params_json。
  134. /// 与之对照,一次合法的参数更新必须**完整保留**其余全部参数。
  135. /// </summary>
  136. [Fact]
  137. public void T7_LegacyDisablePayload_CannotWipeParamsJson()
  138. {
  139. var rule = FullyConfiguredRule();
  140. var before = S8RuleRuntimeParameters.Resolve(rule, Rule01());
  141. Assert.Throws<S8BizException>(() =>
  142. S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload { Enabled = false }, Rule01()));
  143. // 拒绝路径不得产生任何副作用:实体上的 params_json 逐字未变。
  144. Assert.Equal(
  145. """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}""",
  146. rule.ParamsJson);
  147. // 且合法路径写回的 params_json 语义等价(键顺序可不同,值必须一致)。
  148. var after = S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload(), Rule01());
  149. Assert.Equal(before.GraceMinutes, after.GraceMinutes);
  150. Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId);
  151. Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId);
  152. }
  153. // ───────────────────────── T8 / T9:启停端点独立且幂等 ─────────────────────────
  154. [Fact]
  155. public void T8_EnableAndDisable_AreDedicatedEndpoints_SeparateFromParams()
  156. {
  157. var controller = typeof(AdoS8ConfigWatchRulesController);
  158. var enable = Assert.Single(Actions(controller, "EnableAsync"));
  159. var disable = Assert.Single(Actions(controller, "DisableAsync"));
  160. Assert.Equal("{id:long}/enable", Assert.Single(enable.GetCustomAttributes<HttpPostAttribute>()).Template);
  161. Assert.Equal("{id:long}/disable", Assert.Single(disable.GetCustomAttributes<HttpPostAttribute>()).Template);
  162. // 两者都不接 body:启停没有任何可配置项,接 body 就会重新打开"顺手改点别的"的口子。
  163. Assert.Empty(enable.GetParameters().Where(p => p.ParameterType != typeof(long)));
  164. Assert.Empty(disable.GetParameters().Where(p => p.ParameterType != typeof(long)));
  165. foreach (var action in new[] { enable, disable })
  166. Assert.NotEmpty(action.GetCustomAttributes()
  167. .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
  168. }
  169. [Fact]
  170. public void T8_ParamsEndpoint_TakesOnlyTheNarrowParametersPayload()
  171. {
  172. var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "UpdateParamsAsync"));
  173. var body = Assert.Single(action.GetParameters().Where(p => p.ParameterType != typeof(long)));
  174. Assert.Equal(typeof(S8RuleParametersPayload), body.ParameterType);
  175. }
  176. [Fact]
  177. public void T9_ServiceExposes_EnableAndDisable_WithTrustedScope()
  178. {
  179. var t = typeof(S8WatchRuleService);
  180. Assert.NotNull(t.GetMethod("EnableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
  181. Assert.NotNull(t.GetMethod("DisableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
  182. // 旧的整块参数入口必须消失,否则 G1 的路径还在。
  183. Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));
  184. Assert.NotNull(t.GetMethod("UpdateParametersAsync",
  185. new[] { typeof(long), typeof(S8RuleParametersPayload), typeof(S8TrustedScope) }));
  186. }
  187. // ───────────────────────── T10:Definition 字段一律不可写 ─────────────────────────
  188. [Theory]
  189. [InlineData("datasetCode")]
  190. [InlineData("ruleType")]
  191. [InlineData("sourceObjectType")]
  192. [InlineData("exceptionTypeCode")]
  193. [InlineData("dueAtField")]
  194. [InlineData("statusField")]
  195. [InlineData("objectIdField")]
  196. [InlineData("objectCodeField")]
  197. [InlineData("completedStates")]
  198. [InlineData("sceneCode")]
  199. [InlineData("stageCode")]
  200. [InlineData("orderFlowCode")]
  201. [InlineData("ruleMechanism")]
  202. [InlineData("paramsJson")]
  203. public void T10_DefinitionFields_AreRejected_NotSilentlyIgnored(string field)
  204. {
  205. var payload = new S8RuleParametersPayload();
  206. switch (field)
  207. {
  208. case "datasetCode": payload.DatasetCode = "X"; break;
  209. case "ruleType": payload.RuleType = "X"; break;
  210. case "sourceObjectType": payload.SourceObjectType = "X"; break;
  211. case "exceptionTypeCode": payload.ExceptionTypeCode = "X"; break;
  212. case "dueAtField": payload.DueAtField = "X"; break;
  213. case "statusField": payload.StatusField = "X"; break;
  214. case "objectIdField": payload.ObjectIdField = "X"; break;
  215. case "objectCodeField": payload.ObjectCodeField = "X"; break;
  216. case "completedStates": payload.CompletedStates = new[] { "X" }; break;
  217. case "sceneCode": payload.SceneCode = "X"; break;
  218. case "stageCode": payload.StageCode = "X"; break;
  219. case "orderFlowCode": payload.OrderFlowCode = "X"; break;
  220. case "ruleMechanism": payload.RuleMechanism = "X"; break;
  221. case "paramsJson": payload.ParamsJson = "{}"; break;
  222. }
  223. Assert.Contains(field, payload.RejectedDefinitionFields);
  224. var ex = Assert.Throws<S8BizException>(() => Apply(payload));
  225. Assert.Contains(field, ex.Message);
  226. }
  227. [Fact]
  228. public void T10_CleanPayload_ReportsNoRejectedFields()
  229. {
  230. // 反向对照:拒绝逻辑不是"全部拒绝"。
  231. var payload = new S8RuleParametersPayload
  232. {
  233. PollIntervalSeconds = 300,
  234. TriggerCountRequired = 1,
  235. RecoverCountRequired = 2,
  236. GraceMinutes = 0,
  237. Severity = S8SeverityCode.Serious,
  238. DefaultOccurrenceDeptId = 1,
  239. DefaultResponsibleDeptId = 2
  240. };
  241. Assert.Empty(payload.RejectedDefinitionFields);
  242. var next = Apply(payload);
  243. Assert.Equal(300, next.PollIntervalSeconds);
  244. Assert.Equal(0, next.GraceMinutes);
  245. }
  246. /// <summary>
  247. /// 白名单是**闭集**:新增可写属性必须是有意识的决定。
  248. /// 若有人往载荷上加一个新的可写字段却忘了想清楚它是 A 类还是 B 类,本用例会失败。
  249. /// </summary>
  250. [Fact]
  251. public void T10_PayloadShape_IsPinned()
  252. {
  253. var writable = typeof(S8RuleParametersPayload)
  254. .GetProperties(BindingFlags.Public | BindingFlags.Instance)
  255. .Where(p => p.CanWrite)
  256. .Select(p => p.Name)
  257. .OrderBy(n => n, StringComparer.Ordinal)
  258. .ToArray();
  259. var expected = new[]
  260. {
  261. // 白名单(B 类)
  262. "DefaultOccurrenceDeptId", "DefaultResponsibleDeptId", "GraceMinutes",
  263. "PollIntervalSeconds", "RecoverCountRequired", "Severity", "TriggerCountRequired",
  264. // 毒丸(A 类 + 已迁走的 enabled):存在只为显式报错
  265. "CompletedStates", "DatasetCode", "DueAtField", "Enabled", "ExceptionTypeCode",
  266. "ObjectCodeField", "ObjectIdField", "OrderFlowCode", "ParamsJson", "RuleMechanism",
  267. "RuleType", "SceneCode", "SourceObjectType", "StageCode", "StatusField"
  268. }.OrderBy(n => n, StringComparer.Ordinal).ToArray();
  269. Assert.Equal(expected, writable);
  270. }
  271. private static IEnumerable<MethodInfo> Actions(Type controller, string name) =>
  272. controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  273. .Where(m => m.Name == name);
  274. }