S8RuleDefinitionContractTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  5. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  6. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  8. using Xunit;
  9. namespace Admin.NET.Test.S8;
  10. /// <summary>
  11. /// S8-RULE-GOVERNANCE-BATCH1:规则定义的**运行时权威性**契约。
  12. ///
  13. /// <para><b>本文件要证明的不是"我们加了几个 Definition 类"</b>,而是一件具体的事:
  14. /// 规则的业务语义已经不再由数据库和 params_json 决定。
  15. /// 判据是 T5 —— 把库里的 params_json 写成一份恶意的旧格式 JSON
  16. /// (换判定字段、换已完成状态、换去重身份、换异常类型),判定结果必须**逐字不变**。</para>
  17. ///
  18. /// <para>不接 DB、不接 DI:Catalog 只依赖定义源,判定核心是 internal static。</para>
  19. /// </summary>
  20. public class S8RuleDefinitionContractTests
  21. {
  22. private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
  23. private static IS8RuleCatalog Catalog() =>
  24. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() });
  25. /// <summary>一条**语义全错**的历史 params_json:每个 A 类字段都被写成足以改变判定结果的值。</summary>
  26. private const string HostileLegacyParamsJson = """
  27. {
  28. "dueAtField": "evil_field",
  29. "statusField": "evil_status",
  30. "objectIdField": "evil_id",
  31. "objectCodeField": "evil_code",
  32. "completedStates": ["DELAYED"],
  33. "exceptionTypeCode": "EVIL_TYPE",
  34. "graceMinutes": 0
  35. }
  36. """;
  37. private static AdoS8WatchRule Rule(string? paramsJson = null) => new()
  38. {
  39. Id = 1329909460023L,
  40. TenantId = 838257186181189L,
  41. FactoryId = 838257186320453L,
  42. RuleCode = Rule01,
  43. // 投影列刻意写成**错误值**:运行语义若真的来自代码定义,这些值就不该产生任何影响。
  44. RuleType = "OUT_OF_RANGE",
  45. DatasetCode = "SOMETHING_ELSE",
  46. SourceObjectType = "EVIL_OBJECT_TYPE",
  47. WatchObjectType = "EVIL_WATCH_TYPE",
  48. SceneCode = "S1",
  49. Severity = "FOLLOW",
  50. PollIntervalSeconds = 300,
  51. TriggerCountRequired = 1,
  52. RecoverCountRequired = 2,
  53. ParamsJson = paramsJson
  54. };
  55. /// <summary>
  56. /// 一行已超期的采购订单行。
  57. /// 刻意同时带上恶意 JSON 指向的四个列,且让它们指向**完全不同**的值:
  58. /// 若判定真的被 JSON 牵着走,结果会明显不同而不是碰巧一致。
  59. /// </summary>
  60. private static S8MonitoringRowSet OverdueRow(string status = "DELAYED") =>
  61. S8MonitoringRowSet.FromRows(new[]
  62. {
  63. S8MonitoringRow.FromValues(new Dictionary<string, object?>
  64. {
  65. [S8CanonicalColumns.SourceObjectId] = "UATA-PO-001#1",
  66. [S8CanonicalColumns.RelatedObjectCode] = "UATA-PO-001",
  67. [S8CanonicalColumns.DueAt] = new DateTime(2026, 8, 31, 4, 33, 7),
  68. [S8CanonicalColumns.Status] = status,
  69. ["evil_field"] = new DateTime(2030, 1, 1), // 未来时间:若被当作 due_at 就不会命中
  70. ["evil_status"] = "WHATEVER",
  71. ["evil_id"] = "EVIL-ID",
  72. ["evil_code"] = "EVIL-CODE"
  73. })
  74. });
  75. // ───────────────────────── T1:Rule 01 定义 ─────────────────────────
  76. [Fact]
  77. public void T1_Catalog_Defines_Rule01_WithPinnedContract()
  78. {
  79. var d = Catalog().GetRequired(Rule01);
  80. Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, d.DatasetCode);
  81. Assert.Equal(S8TimeoutRuleEvaluator.RuleTypeCode, d.RuleType);
  82. Assert.Equal("DATE", d.RuleMechanism);
  83. Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, d.SourceObjectType);
  84. Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryExceptionTypeCode, d.ExceptionTypeCode);
  85. Assert.Equal("S4", d.SceneCode);
  86. Assert.Equal("S4", d.StageCode);
  87. Assert.Equal("MATERIAL_PURCHASE", d.OrderFlowCode);
  88. // 配置页的「规则名称 / 业务说明」在 DB 里没有对应列,只能来自定义。
  89. Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
  90. Assert.False(string.IsNullOrWhiteSpace(d.Description));
  91. }
  92. [Fact]
  93. public void T1_Rule01_CompletedStates_ArePinned()
  94. {
  95. var timeout = Catalog().GetRequired(Rule01).Timeout;
  96. Assert.NotNull(timeout);
  97. // 三值取自 status 域取证(三个写入方合并)。少一个就会把已完成单判成延误。
  98. Assert.Equal(new[] { "COMPLETED", "CLOSED", "CANCELLED" }, timeout!.CompletedStates);
  99. }
  100. [Fact]
  101. public void T1_Rule01_JudgementColumns_AreCanonical()
  102. {
  103. var t = Catalog().GetRequired(Rule01).Timeout!;
  104. Assert.Equal(S8CanonicalColumns.DueAt, t.DueAtColumn);
  105. Assert.Equal(S8CanonicalColumns.Status, t.StatusColumn);
  106. Assert.Equal(S8CanonicalColumns.SourceObjectId, t.SourceObjectIdColumn);
  107. Assert.Equal(S8CanonicalColumns.RelatedObjectCode, t.RelatedObjectCodeColumn);
  108. }
  109. // ───────────────────────── T2:重复定义 ─────────────────────────
  110. [Fact]
  111. public void T2_DuplicateRuleCode_FailsCatalogConstruction()
  112. {
  113. var ex = Assert.Throws<InvalidOperationException>(() =>
  114. new S8RuleCatalog(new IS8RuleDefinitionSource[]
  115. {
  116. new S8PurchaseDeliveryRuleDefinitions(),
  117. new S8PurchaseDeliveryRuleDefinitions()
  118. }));
  119. Assert.Contains(Rule01, ex.Message);
  120. }
  121. [Theory]
  122. [InlineData("NOT_A_TYPE", "rule_type")]
  123. [InlineData("timeout", "rule_type")] // 大小写:调度器 switch 是 ordinal 敏感的
  124. public void T2_InvalidRuleType_FailsCatalogConstruction(string ruleType, string _)
  125. {
  126. var ex = Assert.Throws<InvalidOperationException>(() =>
  127. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { RuleType = ruleType } }));
  128. Assert.Contains("rule_type", ex.Message);
  129. }
  130. [Fact]
  131. public void T2_TimeoutWithoutSemantics_FailsCatalogConstruction()
  132. {
  133. var ex = Assert.Throws<InvalidOperationException>(() =>
  134. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { DropTimeout = true } }));
  135. Assert.Contains("TIMEOUT 判定语义", ex.Message);
  136. }
  137. [Fact]
  138. public void T2_InvalidScene_FailsCatalogConstruction()
  139. {
  140. var ex = Assert.Throws<InvalidOperationException>(() =>
  141. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { SceneCode = "S2S6_PRODUCTION" } }));
  142. Assert.Contains("scene_code", ex.Message);
  143. }
  144. // ───────────────────────── T3:无定义规则 ─────────────────────────
  145. [Fact]
  146. public void T3_UnknownRuleCode_IsRejectedWithDedicatedReason()
  147. {
  148. var catalog = Catalog();
  149. Assert.Null(catalog.TryGet("RULE_THAT_A_USER_INVENTED"));
  150. Assert.False(catalog.IsDefined("RULE_THAT_A_USER_INVENTED"));
  151. var ex = Assert.Throws<S8BizException>(() => catalog.GetRequired("RULE_THAT_A_USER_INVENTED"));
  152. // 必须是专属 reason,不能是 rule_not_configured / UnsupportedRule / NullReference。
  153. Assert.Contains(S8RuleCatalog.ReasonNotFound, ex.Message);
  154. Assert.DoesNotContain("rule_not_configured", ex.Message);
  155. }
  156. [Theory]
  157. [InlineData(null)]
  158. [InlineData("")]
  159. [InlineData(" ")]
  160. public void T3_BlankRuleCode_IsRejected(string? ruleCode)
  161. {
  162. Assert.Throws<S8BizException>(() => Catalog().GetRequired(ruleCode));
  163. }
  164. // ───────────────────────── T4:params_json = NULL ─────────────────────────
  165. [Fact]
  166. public void T4_NullParamsJson_StillProducesValidRuntimeParameters()
  167. {
  168. // 这正是本地 Rule 01 当前的真实状态:一次 PUT /params {"enabled":false} 把它抹成了 NULL。
  169. var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01));
  170. Assert.Equal(0, effective.Parameters.GraceMinutes); // 回落定义默认值
  171. Assert.Equal(300, effective.Parameters.PollIntervalSeconds); // 来自实体列
  172. Assert.Equal(2, effective.Parameters.RecoverCountRequired);
  173. Assert.Null(effective.Parameters.DefaultOccurrenceDeptId);
  174. }
  175. [Fact]
  176. public void T4_NullParamsJson_StillHits()
  177. {
  178. var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01));
  179. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  180. OverdueRow(), effective, 838257186181189L, 838257186320453L, new DateTime(2026, 9, 6, 13, 4, 13));
  181. var hit = Assert.Single(hits);
  182. Assert.Equal("UATA-PO-001#1", hit.SourceObjectId);
  183. }
  184. [Theory]
  185. [InlineData(null)]
  186. [InlineData("")]
  187. [InlineData("not-a-json")] // 历史脏数据不该把规则打死
  188. [InlineData("[]")] // 合法 JSON 但不是对象
  189. public void T4_BrokenParamsJson_FallsBackToDefaults_WithoutThrowing(string? json)
  190. {
  191. var p = S8RuleRuntimeParameters.Resolve(Rule(json), Catalog().GetRequired(Rule01));
  192. Assert.Equal(0, p.GraceMinutes);
  193. }
  194. // ───────────────────────── T5:定义权威(本文件的核心) ─────────────────────────
  195. [Fact]
  196. public void T5_HostileLegacyParamsJson_CannotRedefineJudgement()
  197. {
  198. var definition = Catalog().GetRequired(Rule01);
  199. var clean = S8EffectiveRule.Resolve(Rule(paramsJson: null), definition);
  200. var hostile = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), definition);
  201. var detectedAt = new DateTime(2026, 9, 6, 13, 4, 13);
  202. var cleanHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), clean, 1, 1, detectedAt);
  203. var hostileHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), hostile, 1, 1, detectedAt);
  204. // 恶意 JSON 把 completedStates 写成 ["DELAYED"] —— 若它还有话语权,这一行就不该命中。
  205. Assert.Single(cleanHits);
  206. Assert.Single(hostileHits);
  207. Assert.Equal(cleanHits[0].SourceObjectId, hostileHits[0].SourceObjectId);
  208. Assert.Equal(cleanHits[0].DedupKey, hostileHits[0].DedupKey);
  209. Assert.Equal(cleanHits[0].ExceptionTypeCode, hostileHits[0].ExceptionTypeCode);
  210. Assert.Equal(cleanHits[0].SourceObjectType, hostileHits[0].SourceObjectType);
  211. Assert.Equal(cleanHits[0].SceneCode, hostileHits[0].SceneCode);
  212. }
  213. [Fact]
  214. public void T5_JudgementIdentity_ComesFromDefinition_NotDbColumnsNorJson()
  215. {
  216. var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
  217. var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
  218. OverdueRow(), effective, 838257186181189L, 838257186320453L, new DateTime(2026, 9, 6, 13, 4, 13)));
  219. // 身份三件套全部来自定义:JSON 写的 EVIL-ID / EVIL_TYPE、DB 列写的 EVIL_OBJECT_TYPE 都不生效。
  220. Assert.Equal("UATA-PO-001#1", hit.SourceObjectId);
  221. Assert.Equal("UATA-PO-001", hit.RelatedObjectCode);
  222. Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, hit.SourceObjectType);
  223. Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryExceptionTypeCode, hit.ExceptionTypeCode);
  224. Assert.Equal("S4", hit.SceneCode);
  225. Assert.Equal(Rule01, hit.SourceRuleCode);
  226. Assert.DoesNotContain("EVIL", hit.SourceObjectId);
  227. Assert.DoesNotContain("EVIL", hit.DedupKey);
  228. Assert.DoesNotContain("EVIL", hit.SourceObjectType);
  229. }
  230. [Fact]
  231. public void T5_CompletedState_StillSkips_WhenDefinitionSaysSo()
  232. {
  233. // 反向对照:定义确实在起作用,而不是"恒命中所以看起来一样"。
  234. var rowSet = OverdueRow(status: "COMPLETED");
  235. var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
  236. Assert.Empty(S8TimeoutRuleEvaluator.EvaluateRows(
  237. rowSet, effective, 1, 1, new DateTime(2026, 9, 6, 13, 4, 13)));
  238. }
  239. [Fact]
  240. public void T5_LegacyGraceMinutes_IsStillHonoured_BecauseItIsARuntimeParameter()
  241. {
  242. // B 类字段必须**继续**生效 —— 否则就是把参数一起锁死了,那是矫枉过正。
  243. var p = S8RuleRuntimeParameters.Resolve(Rule("""{"graceMinutes": 4321}"""), Catalog().GetRequired(Rule01));
  244. Assert.Equal(4321, p.GraceMinutes);
  245. }
  246. [Fact]
  247. public void T5_LegacyDepartmentDefaults_AreStillHonoured()
  248. {
  249. var p = S8RuleRuntimeParameters.Resolve(
  250. Rule("""{"defaultOccurrenceDeptId": 245, "defaultResponsibleDeptId": 246}"""),
  251. Catalog().GetRequired(Rule01));
  252. Assert.Equal(245L, p.DefaultOccurrenceDeptId);
  253. Assert.Equal(246L, p.DefaultResponsibleDeptId);
  254. }
  255. // ───────────────────────── 参数序列化:A 类不回写 ─────────────────────────
  256. [Fact]
  257. public void ToParamsJson_WritesOnlyRuntimeKeys_DroppingLegacyDefinitionFields()
  258. {
  259. var p = S8RuleRuntimeParameters.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
  260. var json = p.ToParamsJson();
  261. // 一次正常保存之后,旧 A 类字段自然消失——无需任何 migration。
  262. foreach (var legacy in new[] { "dueAtField", "statusField", "objectIdField", "objectCodeField", "completedStates", "exceptionTypeCode" })
  263. Assert.DoesNotContain(legacy, json, StringComparison.Ordinal);
  264. Assert.Contains("graceMinutes", json, StringComparison.Ordinal);
  265. }
  266. /// <summary>用于构造"写坏了的定义",验证 Catalog 的 fail-fast。</summary>
  267. private sealed class BadSource : IS8RuleDefinitionSource
  268. {
  269. public string RuleType { get; init; } = S8TimeoutRuleEvaluator.RuleTypeCode;
  270. public string SceneCode { get; init; } = "S4";
  271. public bool DropTimeout { get; init; }
  272. public IEnumerable<S8RuleDefinition> GetDefinitions() => new[]
  273. {
  274. new S8RuleDefinition
  275. {
  276. RuleCode = "RULE_BAD_FOR_TEST",
  277. DisplayName = "坏定义",
  278. Description = "仅用于校验 Catalog 的 fail-fast",
  279. DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
  280. RuleType = RuleType,
  281. RuleMechanism = "DATE",
  282. SourceObjectType = "X",
  283. SceneCode = SceneCode,
  284. StageCode = "S4",
  285. ExceptionTypeCode = "X",
  286. Timeout = DropTimeout ? null : new S8TimeoutSemantics()
  287. }
  288. };
  289. }
  290. }