S8LegacyNotifyDecommissionTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  4. /// <summary>
  5. /// S8-LEGACY-NOTIFY-DECOMMISSION-1:旧通知分层的 Runtime 退役证明。
  6. ///
  7. /// <para><b>本文件要钉死的一件事</b>:通知收件人从此<b>只有一个来源</b> ——
  8. /// <c>ado_s8_notification_recipient</c>(Rule + Event + RecipientType)。
  9. /// 没配置就是不发;<b>不会</b>有第二套 (scene, severity, level) 语义在背后接管。</para>
  10. ///
  11. /// <para><b>为什么这值得一整个文件的守卫</b>:双 Authority 是本模块最难自查的故障形态。
  12. /// 管理员在规则弹窗里把收件人删干净,通知却照发 —— 因为旧表还在兜底,
  13. /// 而那批人在新页面上根本看不见。退役之后,缺口会以「0 收件人 + Warning」暴露出来,
  14. /// 而不是被悄悄补上。<b>后来者若想恢复 fallback「让测试变绿」,请先读这段。</b></para>
  15. /// </summary>
  16. public class S8LegacyNotifyDecommissionTests
  17. {
  18. private static readonly string PluginRoot =
  19. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  20. private static string Read(string relative)
  21. {
  22. var full = Path.Combine(PluginRoot, relative.Replace('/', Path.DirectorySeparatorChar));
  23. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  24. return File.ReadAllText(full);
  25. }
  26. /// <summary>去注释 —— 注释里提到某个词不等于代码在用它。</summary>
  27. private static string CodeOnly(string relative) =>
  28. string.Join('\n', Read(relative).Split('\n').Where(l =>
  29. {
  30. var t = l.TrimStart();
  31. return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
  32. }));
  33. private const string Dispatcher = "Service/S8/S8NotificationLayerResolver.cs";
  34. private const string Recipient = "Service/S8/S8NotificationRecipientResolver.cs";
  35. private const string LegacySeed = "SeedData/S8NotificationLayerSeedData.cs";
  36. // ══════════════════════ T1–T4 · 退役核心 ══════════════════════
  37. /// <summary>T1:NEW 命中 → 正常解析并发送。</summary>
  38. [Fact]
  39. public void T1_NewRecipientHit_IsDispatched()
  40. {
  41. var code = CodeOnly(Dispatcher);
  42. Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code);
  43. Assert.Contains("await DispatchToUsersAsync(input, resolution);", code);
  44. }
  45. /// <summary>
  46. /// T2:NEW 不存在 → <b>不得调用 Legacy resolver</b>,直接返回。
  47. /// <para>这是退役的核心断言:miss 之后没有第二条路。</para>
  48. /// </summary>
  49. [Fact]
  50. public void T2_NewRecipientMiss_DoesNotCallLegacy()
  51. {
  52. var code = CodeOnly(Dispatcher);
  53. Assert.Contains("if (resolution.Source == \"NONE\")", code);
  54. // NONE 分支之后到方法结束之间,不得出现任何旧表读取
  55. var idx = code.IndexOf("if (resolution.Source == \"NONE\")", StringComparison.Ordinal);
  56. Assert.True(idx > 0);
  57. var tail = code[idx..];
  58. foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "layers" })
  59. Assert.DoesNotContain(legacy, tail);
  60. }
  61. /// <summary>
  62. /// T3:NEW 配了但解析出 0 人 → 保持既有 fail-loud(Warning + return),<b>不回落</b>。
  63. /// <para>本批刻意<b>不重构</b>这一点:0 收件人必须可观测,而不是被旧配置补上。</para>
  64. /// </summary>
  65. [Fact]
  66. public void T3_ConfiguredButEmpty_StaysFailLoudWithoutFallback()
  67. {
  68. var code = CodeOnly(Dispatcher);
  69. Assert.Contains("resolution.UserIds.Count == 0", code);
  70. Assert.Contains("S8RecipientDispatch: resolved 0 recipients", code);
  71. // ConfiguredButEmpty 语义在收件人解析侧同样保留
  72. var recipient = CodeOnly(Recipient);
  73. Assert.Contains("ConfiguredButEmpty", recipient);
  74. Assert.Contains("s8_notify_recipients_empty", recipient);
  75. }
  76. /// <summary>
  77. /// T4:<b>本批最关键的退役证明</b> —— 即便旧表里躺着能匹配的数据,
  78. /// NEW miss 时也绝不会去用它。
  79. ///
  80. /// <para>判据是「派发入口对旧表零引用」而不是「查询条件变了」:
  81. /// 只要还留着一行查询,将来某次「顺手加个开关」就能把它接回来。</para>
  82. /// </summary>
  83. [Fact]
  84. public void T4_LegacyRowsAreNeverConsultedEvenWhenTheyWouldMatch()
  85. {
  86. var code = CodeOnly(Dispatcher);
  87. foreach (var legacy in new[]
  88. {
  89. "AdoS8NotificationLayer", "S8NotificationLayerMerge",
  90. "_layerRep", "_roleResolver", "SceneCode == input.SceneCode",
  91. "x.Severity == severity", "S8ConfigScope.GlobalTenantId",
  92. })
  93. Assert.DoesNotContain(legacy, code);
  94. }
  95. /// <summary>T5:Legacy Seed 不再生成任何分层数据。</summary>
  96. [Fact]
  97. public void T5_LegacySeed_ProducesNothing()
  98. {
  99. var seed = CodeOnly(LegacySeed);
  100. Assert.Contains("HasData() => Array.Empty<AdoS8NotificationLayer>()", seed);
  101. // 原来的 scene→role 映射必须整体消失,否则等于给恢复留了入口
  102. foreach (var gone in new[]
  103. {
  104. "ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_QC",
  105. "ROLE_WH_INBOUND", "ROLE_PURCHASER", "L1_OPERATOR", "sceneRoleMap",
  106. })
  107. Assert.DoesNotContain(gone, seed);
  108. }
  109. // ══════════════════════ T6–T9 · 新链路不得回归 ══════════════════════
  110. /// <summary>T6–T9:五种 RecipientType 的解析全部保留。</summary>
  111. [Theory]
  112. [InlineData("HandlerPool", "_handlerPool.GetMemberIdsAsync")]
  113. [InlineData("Assignee", "e.AssigneeUserId is > 0")]
  114. [InlineData("Reviewer", "e.VerifierUserId is > 0")]
  115. [InlineData("EscalationPool", "S8ResponsibilityType.Escalation")]
  116. [InlineData("SpecificUser", "S8RoleResolver.SplitTokens(row.RecipientUserIds)")]
  117. public void T6_T9_AllRecipientTypesStillResolve(string type, string marker)
  118. {
  119. var code = CodeOnly(Recipient);
  120. Assert.Contains($"case S8RecipientType.{type}:", code);
  121. Assert.Contains(marker, code);
  122. }
  123. /// <summary>HANDLER_POOL / ESCALATION_POOL 仍从 Rule 责任池解析,未被改成别的来源。</summary>
  124. [Fact]
  125. public void ResponsibilityPools_RemainTheSourceForPoolRecipients()
  126. {
  127. var code = CodeOnly(Recipient);
  128. Assert.Contains("IS8RuleHandlerPoolReader", code);
  129. Assert.Contains("IS8RuleResponsibilityReader", code);
  130. Assert.Contains("_pools.GetMemberIdsAsync(", code);
  131. // 绝不从旧 target_role / L1-L3 推责任
  132. foreach (var banned in new[] { "TargetRoleIds", "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" })
  133. Assert.DoesNotContain(banned, code);
  134. }
  135. /// <summary>T10:租户隔离不回归 —— 收件人最终仍过一遍同租户 + 启用校验。</summary>
  136. [Fact]
  137. public void T10_TenantIsolationNotRegressed()
  138. {
  139. var code = CodeOnly(Recipient);
  140. Assert.Contains("_userScope.ValidateUsersAsync(tenantId, ids)", code);
  141. Assert.Contains("x.TenantId == tenantId", code);
  142. var dispatcher = CodeOnly(Dispatcher);
  143. Assert.Contains("input.TenantId", dispatcher);
  144. }
  145. /// <summary>
  146. /// T11:legacy backend 定义<b>仍在</b>(P1-C3 才清),但 Runtime 消费者 = 0。
  147. /// </summary>
  148. [Fact]
  149. public void T11_LegacyBackendDefinitionsKept_ButZeroRuntimeConsumer()
  150. {
  151. foreach (var kept in new[]
  152. {
  153. "Entity/S8/AdoS8NotificationLayer.cs",
  154. "Service/S8/S8NotificationLayerService.cs",
  155. "Service/S8/S8NotificationLayerMerge.cs",
  156. "Controllers/S8/AdoS8ConfigNotificationLayersController.cs",
  157. "SeedData/S8NotificationLayerSeedData.cs",
  158. })
  159. Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
  160. $"legacy backend 定义被误删(应留给 P1-C3):{kept}");
  161. // 唯一的通知派发入口对旧表零引用
  162. Assert.DoesNotContain("AdoS8NotificationLayer", CodeOnly(Dispatcher));
  163. }
  164. /// <summary>
  165. /// T12:<b>人工提报未被擅自改成 '*' 方案</b>。
  166. ///
  167. /// <para>已知缺口(本批明确不修):主动提报的异常 <c>SourceRuleCode</c> 为 NULL,
  168. /// 新模型会以 <c>rule_code='*'</c> 为键查找,而 <c>'*'</c> 目前<b>没有任何业务写入路径</b>。
  169. /// fallback 切断后,这类异常的后续事件通知暂时无收件人来源。</para>
  170. ///
  171. /// <para>该缺口由「主动提报通知契约」独立梳理。本守卫防的是两种走偏:
  172. /// ① 为了补缺口而改 Manual Report 去塞一个假 RuleCode;
  173. /// ② 为了让测试变绿而把 legacy fallback 接回来。</para>
  174. /// </summary>
  175. [Fact]
  176. public void T12_ManualReportContract_IsUntouchedAndGapIsAcknowledged()
  177. {
  178. var manual = CodeOnly("Service/S8/S8ManualReportService.cs");
  179. // 手工建单仍不写 SourceRuleCode —— 没有被塞进一个假规则码
  180. var createIdx = manual.IndexOf("SourceType = \"MANUAL\"", StringComparison.Ordinal);
  181. Assert.True(createIdx > 0, "未找到手工提报的建单分支");
  182. var block = manual[Math.Max(0, createIdx - 1500)..Math.Min(manual.Length, createIdx + 1500)];
  183. Assert.DoesNotContain("SourceRuleCode =", block);
  184. // 也没有借机新增 '*' 的写入能力
  185. var configSvc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
  186. Assert.DoesNotContain("TenantDefaultRuleCode", configSvc);
  187. // '*' 仍然只有读侧引用(resolver 内),没有任何写入者
  188. Assert.Contains("TenantDefaultRuleCode", CodeOnly(Recipient));
  189. }
  190. /// <summary>
  191. /// 目录仍是 PROVISIONING 定位,没有被悄悄升格成 Runtime Authority。
  192. /// <para>判据:派发链路(入口 + 收件人解析)都不查 <c>DefaultRecipientByEvent</c>。</para>
  193. /// </summary>
  194. [Fact]
  195. public void DefaultRecipientCatalog_StaysProvisioningOnly()
  196. {
  197. foreach (var runtime in new[] { Dispatcher, Recipient })
  198. Assert.DoesNotContain("DefaultRecipientByEvent", CodeOnly(runtime));
  199. // 目录本身与写入侧仍在
  200. Assert.Contains("DefaultRecipientByEvent", Read("Const/S8/S8NotificationCatalog.cs"));
  201. Assert.Contains("ResolveDefaultRecipientType", Read("Service/S8/S8NotificationRecipientConfigService.cs"));
  202. Assert.Equal(7, S8NotificationCatalog.Events.Count);
  203. }
  204. }