S8ResponsibilityPoolTests.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  4. /// <summary>
  5. /// S8-RESPONSIBILITY-POOL-1:规则三责任池(处理 / 复核 / 升级)。
  6. ///
  7. /// <para><b>本批要挡住的</b>:角色与责任被重新混为一谈、复核人可以是池外的人、
  8. /// 页面选甲而审批任务发给乙、升级回落到某个角色、通知对象与责任池各说各话。
  9. /// 这些都是<b>静默</b>失败 —— 每一层都报成功,只有真实业务跑到那一步才会发现。</para>
  10. /// </summary>
  11. public class S8ResponsibilityPoolTests
  12. {
  13. private static readonly string PluginRoot =
  14. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  15. private static string Read(string relativePath)
  16. {
  17. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  18. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  19. return File.ReadAllText(full);
  20. }
  21. /// <summary>去掉注释行,避免"注释里提到某个词"被当成实现证据。</summary>
  22. private static string CodeOnly(string relativePath) =>
  23. string.Join('\n', Read(relativePath).Split('\n').Where(l =>
  24. {
  25. var t = l.TrimStart();
  26. return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
  27. }));
  28. private static string PoolService() => CodeOnly("Service/S8/S8RuleResponsibilityPoolService.cs");
  29. private static string VerifierEligibility() => CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
  30. private static string ReadinessGate() => CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
  31. private static string TaskFlow() => CodeOnly("Service/S8/S8TaskFlowService.cs");
  32. private static string ClosureHandler() => CodeOnly("Service/S8/ExceptionClosureBizHandler.cs");
  33. private static string Escalation() => CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
  34. private static string NotifyResolver() => CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
  35. // ══════════════════════════════ 目录与存储 ══════════════════════════════
  36. /// <summary>三种责任类型齐备,且每一种都有面向业务的文案(页面不出现技术枚举)。</summary>
  37. [Fact]
  38. public void Catalog_HasThreePoolsWithBusinessWording()
  39. {
  40. Assert.Equal(3, S8ResponsibilityCatalog.All.Count);
  41. Assert.Equal(
  42. new[] { S8ResponsibilityType.Handler, S8ResponsibilityType.Reviewer, S8ResponsibilityType.Escalation },
  43. S8ResponsibilityCatalog.All.OrderBy(x => x.OrderNo).Select(x => x.Code));
  44. foreach (var d in S8ResponsibilityCatalog.All)
  45. {
  46. Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
  47. Assert.False(string.IsNullOrWhiteSpace(d.Description));
  48. // 业务文案里不得出现技术术语
  49. foreach (var banned in new[] { "Role", "ROLE_", "ApproverType", "Pool", "SysUser" })
  50. Assert.DoesNotContain(banned, d.DisplayName + d.Description);
  51. }
  52. }
  53. /// <summary>
  54. /// T1/T2/T3 存储路由:处理池仍走存量表,复核 / 升级走新表。
  55. /// <para>这条断言锁的是「不为了表结构好看去迁已经稳定的处理池」这个决定 ——
  56. /// 迁它要动就绪门禁 / 认领资格 / 可见性收敛 / 通知解析四条已跑通的链路。</para>
  57. /// </summary>
  58. [Fact]
  59. public void Storage_HandlerStaysInLegacyTable_ReviewerAndEscalationInNewTable()
  60. {
  61. Assert.False(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Handler));
  62. Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Reviewer));
  63. Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Escalation));
  64. var code = PoolService();
  65. // HANDLER 读写都路由到存量实现,不在本服务里复制第二份处理池逻辑
  66. Assert.Contains("_handlerReader.GetMemberIdsAsync", code);
  67. Assert.Contains("_handlerWriter.SetMembersAsync", code);
  68. }
  69. /// <summary>T1–T3 写入:三类共用同一个写入口,成员判据同源。</summary>
  70. [Fact]
  71. public void T1_T2_T3_AllThreePools_ShareOneWriteEntryAndOneValidator()
  72. {
  73. var code = PoolService();
  74. Assert.Contains("public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, string responsibilityType", code);
  75. Assert.Contains("_userScope.ValidateUsersAsync", code);
  76. // 未知类型不落库
  77. Assert.Contains("未知的责任类型", Read("Service/S8/S8RuleResponsibilityPoolService.cs"));
  78. }
  79. // ══════════════════════════════ 租户与账号 ══════════════════════════════
  80. /// <summary>
  81. /// T4/T5/T6 跨租户成员必须被拒;T7 停用账号同样被拒。
  82. /// <para>判据统一走 <c>S8UserScopeValidator</c>(同租户 + 启用),
  83. /// 且<b>跨租户与不存在共用同一文案</b>,不泄漏存在性。</para>
  84. /// </summary>
  85. [Fact]
  86. public void T4_T7_CrossTenantAndInactiveMembers_AreRejectedWithSameMessage()
  87. {
  88. var code = PoolService();
  89. Assert.Contains("var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted);", code);
  90. Assert.Contains("throw new S8BizException(\"所选账号不存在或已停用\")", code);
  91. // 池自己不写用户查询 —— 各写一套判据正是"配得进池、运行时又被拒"的来源
  92. Assert.DoesNotContain("Queryable<SysUser>", code);
  93. }
  94. /// <summary>T8 工厂不参与责任池;T9 不出现员工主数据。</summary>
  95. [Theory]
  96. [InlineData("Service/S8/S8RuleResponsibilityPoolService.cs")]
  97. [InlineData("Entity/S8/AdoS8RuleResponsibilityUser.cs")]
  98. [InlineData("Infrastructure/S8/S8VerifierEligibility.cs")]
  99. public void T8_T9_NoFactoryNoEmployee(string relativePath)
  100. {
  101. var code = CodeOnly(relativePath);
  102. Assert.DoesNotContain("FactoryId", code);
  103. Assert.DoesNotContain("factory_id", code);
  104. Assert.DoesNotContain("EmployeeMaster", code);
  105. Assert.DoesNotContain("EmployeeId", code);
  106. }
  107. /// <summary>
  108. /// T26 租户隔离:<b>每一条</b>池查询都带 tenant 谓词。
  109. /// <para>读路径用 <c>tenantId</c> 参数、写路径用 <c>scope.TenantId</c>(服务端盖章),
  110. /// 两种写法都算数;缺任何一条就是跨租户读写。</para>
  111. /// </summary>
  112. [Fact]
  113. public void T26_EveryPoolQuery_CarriesTenantPredicate()
  114. {
  115. var code = PoolService();
  116. var queries = code.Split("AsQueryable()").Skip(1).ToList();
  117. Assert.NotEmpty(queries);
  118. foreach (var q in queries)
  119. {
  120. var head = q[..Math.Min(q.Length, 400)];
  121. Assert.True(head.Contains("x.TenantId == tenantId", StringComparison.Ordinal)
  122. || head.Contains("x.TenantId == scope.TenantId", StringComparison.Ordinal),
  123. $"存在没有租户谓词的池查询:{head[..Math.Min(head.Length, 160)]}");
  124. }
  125. }
  126. // ══════════════════════════════ 就绪门禁 ══════════════════════════════
  127. /// <summary>
  128. /// T10/T11/T12 三条判据由<b>各自的声明</b>驱动,互不捆绑。
  129. ///
  130. /// <para>拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
  131. /// 再叠加复核 / 升级两条判据只会把耦合放大成三重。</para>
  132. /// </summary>
  133. [Fact]
  134. public void T10_T11_T12_EachReadinessCheck_HasItsOwnDeclaration()
  135. {
  136. var code = ReadinessGate();
  137. Assert.Contains("if (policy.RequiresDepartmentDefaultsForEnable)", code);
  138. Assert.Contains("if (policy.RequiresHandlerPoolForEnable)", code);
  139. Assert.Contains("if (policy.RequiresVerification)", code);
  140. Assert.Contains("if (policy.SupportsTimeoutEscalation)", code);
  141. // 各自独立的原因码 —— 管理员必须知道缺的是哪个池
  142. Assert.Contains("HandlerPoolEmpty", code);
  143. Assert.Contains("ReviewerPoolEmpty", code);
  144. Assert.Contains("EscalationPoolEmpty", code);
  145. }
  146. /// <summary>
  147. /// 空池判据是「有没有<b>当前有效</b>的成员」,不是「表里有没有行」。
  148. /// <para>账号停用后那一行还在,但那个人已经不算数了。只数行数会放行一条实际上没人负责的规则。</para>
  149. /// </summary>
  150. [Fact]
  151. public void ReadinessGate_JudgesByValidMembers_NotRowCount()
  152. {
  153. var code = ReadinessGate();
  154. Assert.Contains("members.Any(m => m.Valid)", code);
  155. // 「一个都没配」与「配了但都失效」分开报:管理员的下一步动作完全不同
  156. Assert.Contains("members.Count == 0 ? emptyMessage : allInvalidMessage", code);
  157. }
  158. /// <summary>Rule 01 显式声明三条判据;处理池那条与拆分前行为一致,不顺带改变启用条件。</summary>
  159. [Fact]
  160. public void Rule01_DeclaresAllThreeResponsibilities()
  161. {
  162. var def = Read("Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs");
  163. Assert.Contains("RequiresHandlerPoolForEnable = true", def);
  164. Assert.Contains("RequiresVerification = true", def);
  165. Assert.Contains("SupportsTimeoutEscalation = true", def);
  166. }
  167. /// <summary>
  168. /// T23 空池<b>绝不</b>回落到角色。
  169. /// <para>回落会让「我没给这条规则配人」与「有人在负责」同时成立 —— 最难查的一类错配。</para>
  170. /// </summary>
  171. [Fact]
  172. public void T23_EmptyPool_NeverFallsBackToAnyRole()
  173. {
  174. foreach (var code in new[] { ReadinessGate(), Escalation(), VerifierEligibility() })
  175. {
  176. Assert.DoesNotContain("ROLE_QC", code);
  177. Assert.DoesNotContain("EscalateRoleCode", code);
  178. Assert.DoesNotContain("escalate_role_code", code);
  179. }
  180. }
  181. /// <summary>T24 L1/L2/L3 与虚拟主管层级不得成为当前 authority。</summary>
  182. [Fact]
  183. public void T24_NoVirtualHierarchyAsCurrentAuthority()
  184. {
  185. foreach (var rel in new[]
  186. {
  187. "Service/S8/S8RuleResponsibilityPoolService.cs",
  188. "Infrastructure/S8/S8VerifierEligibility.cs",
  189. "Service/S8/S8TimeoutAutoEscalationService.cs",
  190. "Const/S8/S8ResponsibilityCatalog.cs",
  191. })
  192. {
  193. var code = CodeOnly(rel);
  194. foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "DepartmentLeader", "DirectorId", "ManagerUserId" })
  195. Assert.DoesNotContain(banned, code);
  196. }
  197. }
  198. // ══════════════════════════════ 复核契约 ══════════════════════════════
  199. /// <summary>
  200. /// T13/T14/T15 复核人资格 = 复核池 ∩ 审核权限 ∩ 可用账号,三者缺一不可。
  201. /// </summary>
  202. [Fact]
  203. public void T13_T14_T15_VerifierMustSatisfyAllThreeConditions()
  204. {
  205. var code = VerifierEligibility();
  206. Assert.Contains("_userScope.FindActiveUserAsync(tenantId, verifierUserId)", code);
  207. Assert.Contains("S8ResponsibilityType.Reviewer, verifierUserId", code);
  208. Assert.Contains("S8ExceptionActionCode.Verify", code);
  209. Assert.Contains("NotInReviewerPool", code);
  210. Assert.Contains("NoActionPermission", code);
  211. Assert.Contains("NotUsable", code);
  212. }
  213. /// <summary>
  214. /// 候选列表与提交校验<b>同一份实现</b>。
  215. /// <para>两边各写一套,就会出现「页面能选、提交被拒」或更糟的「页面选甲、后端放行乙」。</para>
  216. /// </summary>
  217. [Fact]
  218. public void VerifierCandidates_AndSubmitCheck_ShareOneImplementation()
  219. {
  220. var eligibility = VerifierEligibility();
  221. Assert.Contains("Task<S8VerifierEligibilityResult> CheckAsync", eligibility);
  222. Assert.Contains("Task<List<S8VerifierCandidateDto>> ListCandidatesAsync", eligibility);
  223. // 提交复核只调这一个门,不再自己拼权限判据
  224. var flow = TaskFlow();
  225. Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", flow);
  226. Assert.DoesNotContain(
  227. "_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", flow);
  228. }
  229. /// <summary>T15 绕过 UI 直接调 API 注入池外 verifier 同样被拒 —— 门在服务端。</summary>
  230. [Fact]
  231. public void T15_ApiInjectedOutsiderVerifier_IsRejectedServerSide()
  232. {
  233. var flow = TaskFlow();
  234. var idx = flow.IndexOf("SubmitVerificationAsync(", StringComparison.Ordinal);
  235. Assert.True(idx > 0);
  236. var body = flow[idx..(idx + 1800)];
  237. // 资格校验必须发生在状态迁移与落库之前
  238. var checkIdx = body.IndexOf("_verifierEligibility.CheckAsync", StringComparison.Ordinal);
  239. var mutateIdx = body.IndexOf("e.VerifierUserId = verifier.UserId", StringComparison.Ordinal);
  240. Assert.True(checkIdx > 0 && mutateIdx > 0);
  241. Assert.True(checkIdx < mutateIdx, "复核人资格必须在写入 verifier_user_id 之前判定");
  242. }
  243. /// <summary>
  244. /// T16 <b>页面选定的检验人 == 审批任务的实际处理人</b>。
  245. ///
  246. /// <para>这是本批修的核心错位:<c>ResolveApprovers</c> 只看流程定义节点,
  247. /// <b>完全不读 BizData</b>,提交复核时传的 <c>verifierUserId</c> 从未参与审批人解析。
  248. /// 当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。</para>
  249. /// </summary>
  250. [Fact]
  251. public void T16_SelectedVerifier_BecomesTheActualFlowTaskAssignee()
  252. {
  253. var code = ClosureHandler();
  254. Assert.Contains("AlignTaskToSelectedVerifierAsync", code);
  255. Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", code);
  256. Assert.Contains("keep.AssigneeId = verifierId;", code);
  257. // 对齐必须发生在 OnFlowStarted 里 —— StartFlow 的顺序是先建任务再回调
  258. var started = code.IndexOf("OnFlowStarted(long bizId", StringComparison.Ordinal);
  259. var align = code.IndexOf("await AlignTaskToSelectedVerifierAsync", StringComparison.Ordinal);
  260. Assert.True(started > 0 && align > started);
  261. // 多余待办必须删除:留着等于池外的人仍能点通过,而 S8 侧校验又会拒绝他
  262. Assert.Contains("AsDeleteable().Where(t => drop.Contains(t.Id))", code);
  263. }
  264. /// <summary>对齐失败不回滚已提交的复核,但必须留下可查的日志 —— 静默失败是本模块的老病。</summary>
  265. [Fact]
  266. public void TaskAlignmentFailure_IsLoggedNotSwallowed()
  267. {
  268. var code = ClosureHandler();
  269. Assert.Contains("s8_verify_task_aligned", code);
  270. Assert.Contains("s8_verify_task_align_failed", code);
  271. }
  272. /// <summary>不改共用的 FlowEngine —— 它是 S1/S5/S6/S7 共用的平台组件。</summary>
  273. [Fact]
  274. public void FlowEngine_IsNotModifiedByThisBatch()
  275. {
  276. var engine = Path.GetFullPath(Path.Combine(
  277. PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
  278. Assert.True(File.Exists(engine));
  279. var code = File.ReadAllText(engine);
  280. // 没有为 S8 加 BizData 动态审批人分支
  281. Assert.DoesNotContain("verifierUserId", code);
  282. Assert.DoesNotContain("S8ResponsibilityType", code);
  283. }
  284. // ══════════════════════════════ 升级契约 ══════════════════════════════
  285. /// <summary>
  286. /// T19 超时升级按<b>该规则的升级账号池</b>解析,不再用 exception_type.escalate_role_code。
  287. ///
  288. /// <para>旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上
  289. /// (原实现注释已自陈)。责任池天然带 tenant_id,不存在这条越界路径。</para>
  290. /// </summary>
  291. [Fact]
  292. public void T19_Escalation_ResolvesExactlyTheRuleEscalationPool()
  293. {
  294. var code = Escalation();
  295. Assert.Contains("S8ResponsibilityType.Escalation", code);
  296. Assert.Contains("_pools.GetValidMemberIdsAsync", code);
  297. // 旧 authority 已彻底移除(含类型仓储)
  298. Assert.DoesNotContain("EscalateRoleCode", code);
  299. Assert.DoesNotContain("AdoS8ExceptionType", code);
  300. Assert.DoesNotContain("_typeRep", code);
  301. }
  302. /// <summary>空升级池 fail closed:跳过并落日志,不回落、不猜。</summary>
  303. [Fact]
  304. public void EmptyEscalationPool_SkipsLoudlyInsteadOfGuessing()
  305. {
  306. var code = Escalation();
  307. Assert.Contains("reason=escalation_pool_empty", code);
  308. Assert.Contains("if (escalationPool.Count == 0)", code);
  309. // 人工提报没有来源规则,不参与规则级自动升级
  310. Assert.Contains("reason=no_source_rule", code);
  311. }
  312. // ══════════════════════════════ 通知推导 ══════════════════════════════
  313. /// <summary>
  314. /// T20/T21/T22 通知对象由责任关系<b>推导</b>,业务用户不选技术枚举。
  315. /// </summary>
  316. [Fact]
  317. public void T20_T21_T22_RecipientsAreDerivedFromResponsibilities()
  318. {
  319. Assert.Equal(S8RecipientType.HandlerPool,
  320. S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionCreated));
  321. Assert.Equal(S8RecipientType.Reviewer,
  322. S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.VerificationSubmitted));
  323. Assert.Equal(S8RecipientType.EscalationPool,
  324. S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.EscalationTriggered));
  325. Assert.Equal(S8RecipientType.Assignee,
  326. S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionClaimed));
  327. // 转派后 assignee_user_id 已经是新处理人,不需要额外的收件人类型
  328. Assert.Equal(S8RecipientType.Assignee,
  329. S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionTransferred));
  330. }
  331. /// <summary>
  332. /// <b>不制造不存在的事件</b>:目录里每个事件都必须有真实派发点,
  333. /// 且每个事件都必须有默认收件人推导。
  334. /// </summary>
  335. [Fact]
  336. public void EveryCatalogEvent_HasBothADispatchSiteAndADerivedRecipient()
  337. {
  338. var serviceDir = Path.Combine(PluginRoot, "Service", "S8");
  339. var allCode = string.Join('\n', Directory.GetFiles(serviceDir, "*.cs", SearchOption.AllDirectories)
  340. .Select(File.ReadAllText));
  341. foreach (var e in S8NotificationCatalog.Events)
  342. {
  343. var member = e.Code.Split('_')
  344. .Select(p => char.ToUpperInvariant(p[0]) + p[1..].ToLowerInvariant())
  345. .Aggregate(string.Empty, (a, b) => a + b);
  346. Assert.True(allCode.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
  347. $"事件 {e.Code} 在 Service/S8 下没有任何派发点 —— 配了却永远不触发的通知比没有通知更难排查");
  348. Assert.NotNull(S8NotificationCatalog.ResolveDefaultRecipientType(e.Code));
  349. }
  350. }
  351. /// <summary>「复核通过 / 复核退回」刻意缺席 —— 代码里没有这两个派发点。</summary>
  352. [Fact]
  353. public void NonExistentVerificationEvents_AreNotInvented()
  354. {
  355. Assert.DoesNotContain("VERIFICATION_APPROVED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
  356. Assert.DoesNotContain("VERIFICATION_REJECTED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
  357. }
  358. /// <summary>
  359. /// 通知里的 REVIEWER 不再解析审批流节点。
  360. /// <para>审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
  361. /// 且会让通知链随流程节点结构悄悄变化。</para>
  362. /// </summary>
  363. [Fact]
  364. public void NotificationReviewer_NoLongerParsesApprovalFlowNodes()
  365. {
  366. var code = NotifyResolver();
  367. Assert.DoesNotContain("ResolveFlowApproversAsync", code);
  368. Assert.DoesNotContain("_reviewSource", code);
  369. Assert.Contains("S8ResponsibilityType.Reviewer", code);
  370. Assert.Contains("S8ResponsibilityType.Escalation", code);
  371. }
  372. /// <summary>通知开关写入口只接布尔,收件人类型由目录推导 —— 页面不选技术枚举。</summary>
  373. [Fact]
  374. public void NotificationToggle_TakesOnlyABooleanFromTheUi()
  375. {
  376. var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
  377. Assert.Contains("SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled)", svc);
  378. Assert.Contains("S8NotificationCatalog.ResolveDefaultRecipientType(eventCode)", svc);
  379. var dto = Read("Controllers/S8/AdoS8ConfigWatchRulesController.cs");
  380. Assert.Contains("class AdoS8NotifyEventToggleDto", dto);
  381. }
  382. // ══════════════════════════════ 回归 ══════════════════════════════
  383. /// <summary>T25 First Claim Wins 未被本批改动:仍是一条带前置条件的原子 UPDATE。</summary>
  384. [Fact]
  385. public void T25_FirstClaimWins_IsUnchanged()
  386. {
  387. var code = TaskFlow();
  388. Assert.Contains("x.Status == fromStatus", code);
  389. Assert.Contains("x.AssigneeUserId == null", code);
  390. Assert.Contains("该异常已被其他人员认领", code);
  391. // 认领资格仍是 动作权限 ∩ 处理池
  392. Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", code);
  393. }
  394. /// <summary>迁移不绑定任何具体账号或角色 —— 部署后三个池初始为空,由管理员显式配置。</summary>
  395. [Fact]
  396. public void Migration_BindsNoConcreteAccountsOrRoles()
  397. {
  398. var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
  399. PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql")));
  400. Assert.DoesNotContain("INSERT INTO `ado_s8_rule_responsibility_user`", sql);
  401. Assert.DoesNotContain("ROLE_", sql);
  402. Assert.DoesNotContain("DELIMITER", sql); // AutoVersionUpdate 会整份拒绝含该词的脚本
  403. // 唯一索引断言按**列成员**判定,不是只看索引名是否存在(1.0.496 踩过这个坑)
  404. Assert.Contains("COLUMN_NAME IN ('tenant_id','rule_code','responsibility_type','user_id')", sql);
  405. Assert.Contains("s8_503_BLOCKED_cross_tenant_member", sql);
  406. Assert.Contains("s8_503_BLOCKED_unknown_responsibility_type", sql);
  407. }
  408. }