S8WatchRulePreviewContractTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. using System.Reflection;
  2. using Microsoft.AspNetCore.Mvc;
  3. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  4. using Admin.NET.Plugin.AiDOP.Service.S8;
  5. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  6. using Xunit;
  7. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  8. /// <summary>
  9. /// S8-RULE01-INTEGRATION-PREVIEW-1:预演端点的**零副作用**契约。
  10. ///
  11. /// 预演的全部价值建立在一条保证上:<b>它什么都不写</b>。
  12. /// 一旦有人给它加一个"顺便把命中落库"的分支,它就从"启用前的安全窗口"
  13. /// 变成"另一条没人审过的建单入口" —— 而且因为名字叫 preview,没人会去审它。
  14. /// 因此本文件用结构性断言把这条钉死,而不是依赖注释和自觉。
  15. ///
  16. /// 不接 DB、不接 DI:全部断言基于类型结构与依赖形态。
  17. /// </summary>
  18. public class S8WatchRulePreviewContractTests
  19. {
  20. private static readonly Type Service = typeof(S8WatchRulePreviewService);
  21. /// <summary>
  22. /// 预演服务只允许持有「取数 + 判定 + 读既有异常」所需的依赖。
  23. /// 出现任何建单 / 通知 / 流转类服务都意味着它可能产生副作用。
  24. /// </summary>
  25. [Fact]
  26. public void PreviewService_HoldsNoWriteCapableDependency()
  27. {
  28. var deps = Assert.Single(Service.GetConstructors())
  29. .GetParameters().Select(p => p.ParameterType.Name).ToArray();
  30. foreach (var forbidden in new[]
  31. {
  32. "S8ManualReportService", // 建单
  33. "S8TaskFlowService", // 状态流转
  34. "S8NotificationLayerResolver",// 通知
  35. "S8WatchSchedulerService", // 会写 detection / 推进租约
  36. "S8ExceptionService" // 异常写入口
  37. })
  38. Assert.DoesNotContain(forbidden, deps);
  39. // 正向:取数网关与两个只读仓储必须在,否则它没法走生产同款链路。
  40. Assert.Contains("S8MonitoringDataGateway", deps);
  41. }
  42. /// <summary>
  43. /// 预演服务不得出现任何写方法。命名约定在本仓是稳定的
  44. /// (Create/Update/Delete/Save/Insert/Write/Execute…),逐个排除比读注释可靠。
  45. /// </summary>
  46. [Fact]
  47. public void PreviewService_ExposesOnlyPreview()
  48. {
  49. var publicMethods = Service
  50. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  51. .Select(m => m.Name)
  52. .ToArray();
  53. Assert.Equal(new[] { "PreviewAsync" }, publicMethods);
  54. foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Write", "Run", "Execute" })
  55. Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal));
  56. }
  57. /// <summary>
  58. /// 端点必须是 GET。用 POST 会让调用方以为"点一下会发生点什么",
  59. /// 也会让它在审计与网关限流里被归入写操作那一类。
  60. /// </summary>
  61. [Fact]
  62. public void PreviewEndpoint_IsHttpGet_AndScoped()
  63. {
  64. var action = Assert.Single(
  65. typeof(AdoS8ConfigWatchRulesController)
  66. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  67. .Where(m => m.Name == "PreviewAsync"));
  68. var get = Assert.Single(action.GetCustomAttributes<HttpGetAttribute>());
  69. Assert.Equal("{id:long}/preview", get.Template);
  70. Assert.Empty(action.GetCustomAttributes<HttpPostAttribute>());
  71. Assert.Empty(action.GetCustomAttributes<HttpPutAttribute>());
  72. Assert.Empty(action.GetCustomAttributes<HttpDeleteAttribute>());
  73. // 必须带权限;未登记路由会退回平台的「默认放行」。
  74. Assert.NotEmpty(action.GetCustomAttributes()
  75. .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
  76. }
  77. /// <summary>
  78. /// 预演必须能对 enabled=false 的规则工作 —— 这正是它存在的理由:
  79. /// 先看清会命中什么,再决定启不启用。若签名或结果里带上"仅启用规则"的约束,
  80. /// 它就退化成了事后观察工具。
  81. /// </summary>
  82. [Fact]
  83. public void PreviewResult_ReportsEnabledState_WithoutRequiringIt()
  84. {
  85. var enabled = typeof(S8WatchRulePreviewResult).GetProperty("Enabled");
  86. Assert.NotNull(enabled);
  87. Assert.Equal(typeof(bool), enabled!.PropertyType);
  88. // 结果必须能回答"会新建多少 / 会命中既有多少"——只给总命中数无法评估污染规模。
  89. foreach (var required in new[]
  90. { "CandidateRows", "HitCount", "NoHitRows", "WouldCreateCount", "WouldMatchExistingCount" })
  91. Assert.NotNull(typeof(S8WatchRulePreviewResult).GetProperty(required));
  92. }
  93. /// <summary>
  94. /// 单条命中必须带 DedupKey 与既有异常 id:
  95. /// 没有这两项就无法回答"真跑一次会不会重复建单",而那正是启用前最该确认的事。
  96. /// </summary>
  97. [Fact]
  98. public void PreviewHit_CarriesIdentityAndDedupContext()
  99. {
  100. foreach (var required in new[]
  101. {
  102. "SourceObjectType", "SourceObjectId", "RelatedObjectCode",
  103. "ExceptionTypeCode", "Severity", "DedupKey", "ExistingActiveExceptionId"
  104. })
  105. Assert.NotNull(typeof(S8WatchRulePreviewHit).GetProperty(required));
  106. Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType);
  107. }
  108. // ============================================================
  109. // S8-RULE-LIFECYCLE-CREATE-GATE-1:预演与首次立案资格的口径对齐
  110. //
  111. // 修的是一个已在本地 Runtime 实测到的分叉:
  112. // 预演 命中 3 · 将新建 3
  113. // 真跑 hits=3 · created=2 (1 条 create_not_eligible)
  114. // 差的那一条是已过客户交期、风险已经兑现的历史行 —— 它是真实命中
  115. // (因而保护既有异常不被误判恢复),但不该再开一个新案子。
  116. // 管理员正是拿「将新建」这个数字决定要不要启用,报大了比不报更糟。
  117. // ============================================================
  118. private static S8RuleHit Hit(string dedupKey, bool createEligible) => new()
  119. {
  120. SourceRuleCode = "RULE_UNDER_TEST",
  121. SourceObjectType = "SALES_ORDER_LINE",
  122. SourceObjectId = dedupKey,
  123. RelatedObjectCode = dedupKey,
  124. DedupKey = dedupKey,
  125. CreateEligible = createEligible
  126. };
  127. private static IReadOnlyDictionary<string, long> Active(params string[] dedupKeys) =>
  128. dedupKeys.ToDictionary(k => k, k => (long)k.GetHashCode(), StringComparer.Ordinal);
  129. private static readonly IReadOnlyDictionary<string, long> NoneActive =
  130. new Dictionary<string, long>(StringComparer.Ordinal);
  131. /// <summary>P1:CreateEligible=false 的命中<b>仍然是命中</b>。</summary>
  132. [Fact]
  133. public void P1_IneligibleHit_StillCountsAsHit()
  134. {
  135. var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) };
  136. // HitCount 直接取 hits.Count(见 RunAsync)——这里锁的是"不得在命中侧做任何过滤"。
  137. // 一旦有人为了让数字好看而把 ineligible 从 hits 里剔掉,恢复判定会立刻失去保护,
  138. // 假恢复原地复活。
  139. Assert.Equal(3, hits.Length);
  140. Assert.Contains(hits, h => !h.CreateEligible);
  141. }
  142. /// <summary>P2:CreateEligible=false 不计入 WouldCreate。</summary>
  143. [Fact]
  144. public void P2_IneligibleHit_IsNotCountedAsWouldCreate()
  145. {
  146. var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) };
  147. Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
  148. }
  149. /// <summary>P3:CreateEligible=true 仍按原有去重口径计入。</summary>
  150. [Fact]
  151. public void P3_EligibleHits_KeepExistingDedupSemantics()
  152. {
  153. var hits = new[] { Hit("A", true), Hit("B", true) };
  154. Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
  155. // A 已有活动异常 → 它会被刷新而不是新建(既有语义,未改动)。
  156. Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(hits, Active("A")));
  157. Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("A")));
  158. }
  159. /// <summary>
  160. /// P4:未声明闸门的规则(Rule 01 即是)行为逐字不变。
  161. /// <c>CreateEligible</c> 默认 true,因此新过滤对它恒真、不改变任何计数。
  162. /// </summary>
  163. [Fact]
  164. public void P4_RulesWithoutTheGate_AreUnaffected()
  165. {
  166. Assert.True(new S8RuleHit().CreateEligible);
  167. var rule01Style = new[] { new S8RuleHit { DedupKey = "P1" }, new S8RuleHit { DedupKey = "P2" } };
  168. Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(rule01Style, NoneActive));
  169. Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(rule01Style, Active("P1")));
  170. Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(rule01Style, Active("P1")));
  171. }
  172. /// <summary>
  173. /// P5:预演仍然零写入。本批只改了两个计数表达式,不得因此引入任何写能力。
  174. /// (与本文件开头的依赖/方法名断言互补:那两条从依赖面证明,这条从源码面证明。)
  175. /// </summary>
  176. [Fact]
  177. public void P5_PreviewRemainsWriteFree()
  178. {
  179. var code = PreviewSource();
  180. foreach (var write in new[]
  181. { "Insertable", "Updateable", "Deleteable", "ExecuteCommand", "SaveChanges" })
  182. Assert.DoesNotContain(write, code, StringComparison.Ordinal);
  183. }
  184. /// <summary>P6:不得出现 RuleCode 特判 —— 闸门是通用能力,不是 Rule 02 的特例。</summary>
  185. [Fact]
  186. public void P6_NoRuleCodeSpecialCasing()
  187. {
  188. var code = PreviewSource();
  189. Assert.DoesNotContain("RULE_S1_ORDER_DELIVERY_DELAY_WARNING", code, StringComparison.Ordinal);
  190. Assert.DoesNotContain("RULE_S4_PURCHASE_DELIVERY", code, StringComparison.Ordinal);
  191. Assert.DoesNotContain("CreateOnlyBeforeDueAt", code, StringComparison.Ordinal);
  192. }
  193. /// <summary>
  194. /// P7:Rule 02 本地实测形态 —— 2 条未到期 + 1 条已过交期,均无既有异常。
  195. /// 期望 Hits=3 / WouldCreate=2,与真跑的 <c>hits=3 created=2</c> 逐字一致。
  196. /// </summary>
  197. [Fact]
  198. public void P7_Rule02LocalShape_HitsThree_WouldCreateTwo()
  199. {
  200. var hits = new[]
  201. {
  202. Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030003#1", true), // 未到期
  203. Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030004#1", true), // 未到期
  204. Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202608240001#1", false) // 已过交期
  205. };
  206. Assert.Equal(3, hits.Length);
  207. Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
  208. Assert.Equal(0, S8WatchRulePreviewService.CountWouldMatchExisting(hits, NoneActive));
  209. }
  210. /// <summary>
  211. /// P8:跨过交期的既有预警必须仍报「会刷新」。
  212. /// 刷新分支<b>不</b>受闸门影响 —— 闸门在调度器里位于刷新之后。
  213. /// 若这里也过滤,预演会把「保持 active」误报成「什么都不会发生」。
  214. /// </summary>
  215. [Fact]
  216. public void P8_IneligibleHitWithExistingException_StillReportsRefresh()
  217. {
  218. var hits = new[] { Hit("C", false) };
  219. Assert.Equal(0, S8WatchRulePreviewService.CountWouldCreate(hits, Active("C")));
  220. Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("C")));
  221. }
  222. private static string PreviewSource()
  223. {
  224. var path = Path.GetFullPath(Path.Combine(
  225. AppContext.BaseDirectory,
  226. "../../../../Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs"));
  227. Assert.True(File.Exists(path), $"预演服务源码路径需同步更新:{path}");
  228. return string.Join('\n', File.ReadAllLines(path)
  229. .Where(l =>
  230. {
  231. var t = l.TrimStart();
  232. return !t.StartsWith("///", StringComparison.Ordinal)
  233. && !t.StartsWith("//", StringComparison.Ordinal);
  234. }));
  235. }
  236. }