S8ReviewSourceTests.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. using Admin.NET.Plugin.AiDOP.Service.S8;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  4. /// <summary>
  5. /// S8-RULE-4TAB-1:审核来源<b>只读</b>呈现。
  6. ///
  7. /// <para><b>本批刻意不做的事</b>(产品决策已锁定):不新建 Rule 级 Reviewer Pool、
  8. /// 不改 ApproverType、不动审批流节点、不改 owner/escalate 业务语义。
  9. /// 这些"不做"必须被测试守住 —— 否则下一个人很容易顺手"补全"一个 reviewer_user_ids,
  10. /// 于是同一个问题又出现两套 authority。</para>
  11. ///
  12. /// <para><b>为什么要做这个只读视图</b>:规则页此前把审核人显示为「主管」,
  13. /// 而系统里根本不存在组织主管关系(<c>SysOrg.DirectorId</c> 与
  14. /// <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。管理员据此以为审核链是通的,
  15. /// 实际上 UAT 租户的复核流程审批人在本租户下解析为 0 人、
  16. /// 升级流程三个节点全部指向默认租户的一个演示账号。</para>
  17. /// </summary>
  18. public class S8ReviewSourceTests
  19. {
  20. private static readonly string PluginRoot =
  21. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  22. private static string CodeOnly(string relativePath)
  23. {
  24. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  25. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  26. return string.Join('\n', File.ReadAllLines(full)
  27. .Where(l =>
  28. {
  29. var t = l.TrimStart();
  30. return !t.StartsWith("///", StringComparison.Ordinal)
  31. && !t.StartsWith("//", StringComparison.Ordinal);
  32. }));
  33. }
  34. // ══════════════════════════════════════════════════════════════════════
  35. // 节点解析:宽松读取,认不出就说不知道,绝不猜
  36. // ══════════════════════════════════════════════════════════════════════
  37. /// <summary>真实流程定义(UAT 租户 EXCEPTION_CLOSURE 的实际 JSON 形态)能被解出。</summary>
  38. [Fact]
  39. public void ParseApproverNodes_ReadsRealFlowShape()
  40. {
  41. const string json = """
  42. {"nodes":[{"nodeKey":"n1","nodeName":"负责人确认","approverType":2,"approverIds":"1300000000101,1300000000888"}]}
  43. """;
  44. var nodes = S8ReviewSourceService.ParseApproverNodes(json);
  45. Assert.Single(nodes);
  46. Assert.Equal("负责人确认", nodes[0].NodeName);
  47. Assert.Equal(2, nodes[0].Type); // ApproverTypeEnum.Role
  48. Assert.Equal("1300000000101,1300000000888", nodes[0].Ids);
  49. }
  50. /// <summary>嵌套结构同样能找到节点(流程定义的外层结构由 ApprovalFlow 插件决定,不该写死)。</summary>
  51. [Fact]
  52. public void ParseApproverNodes_WalksNestedStructures()
  53. {
  54. const string json = """
  55. {"flow":{"children":[{"branches":[[{"nodeName":"厂级审批","approverType":1,"approverIds":"789819678195781"}]]}]}}
  56. """;
  57. var nodes = S8ReviewSourceService.ParseApproverNodes(json);
  58. Assert.Single(nodes);
  59. Assert.Equal("厂级审批", nodes[0].NodeName);
  60. Assert.Equal(1, nodes[0].Type); // SpecificUser
  61. }
  62. /// <summary>
  63. /// 空 / 无审批人的定义解出 0 个节点,<b>不抛异常</b>。
  64. /// 上层会显示「本租户没有该流程」,而不是崩在配置页上。
  65. /// </summary>
  66. [Theory]
  67. [InlineData(null)]
  68. [InlineData("")]
  69. [InlineData(" ")]
  70. [InlineData("""{"nodes":[]}""")]
  71. [InlineData("""{"nodes":[{"nodeName":"仅抄送"}]}""")]
  72. public void ParseApproverNodes_HandlesEmptyGracefully(string? json)
  73. {
  74. Assert.Empty(S8ReviewSourceService.ParseApproverNodes(json));
  75. }
  76. /// <summary>认不出的 approverType 一律跳过,不猜成某个默认值。</summary>
  77. [Fact]
  78. public void ParseApproverNodes_SkipsUnrecognizedApproverType()
  79. {
  80. const string json = """
  81. {"nodes":[{"nodeName":"怪节点","approverType":{"weird":true},"approverIds":"x"},
  82. {"nodeName":"正常","approverType":2,"approverIds":"ROLE_X"}]}
  83. """;
  84. var nodes = S8ReviewSourceService.ParseApproverNodes(json);
  85. Assert.Single(nodes);
  86. Assert.Equal("正常", nodes[0].NodeName);
  87. }
  88. /// <summary>字符串形式的 approverType 也接受(不同版本的流程设计器写法不一)。</summary>
  89. [Fact]
  90. public void ParseApproverNodes_AcceptsStringApproverType()
  91. {
  92. const string json = """{"nodes":[{"nodeName":"n","approverType":"Role","approverIds":"ROLE_A"}]}""";
  93. var nodes = S8ReviewSourceService.ParseApproverNodes(json);
  94. Assert.Single(nodes);
  95. Assert.Equal(2, nodes[0].Type);
  96. }
  97. // ══════════════════════════════════════════════════════════════════════
  98. // 只读边界:本批锁定的"不做"必须被守住
  99. // ══════════════════════════════════════════════════════════════════════
  100. /// <summary>
  101. /// 审核来源服务<b>绝不写库</b>,也不新建 reviewer 概念。
  102. ///
  103. /// <para>产品决策:不新建 Rule 级 Reviewer Pool、不改 ApproverType、不动流程节点。
  104. /// 这条守卫防的是下一个人"顺手补全"—— 一旦出现 reviewer_user_ids,
  105. /// 审核人就有了两个 authority,而现有审批流那套还在跑。</para>
  106. /// </summary>
  107. [Fact]
  108. public void ReviewSource_IsStrictlyReadOnly()
  109. {
  110. var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
  111. foreach (var write in new[] { "AsInsertable", "AsUpdateable", "AsDeleteable", "ExecuteCommandAsync", "InsertAsync", "UpdateAsync" })
  112. Assert.DoesNotContain(write, code);
  113. foreach (var invented in new[] { "ReviewerPool", "reviewer_user_ids", "ReviewerUserIds", "IS8ReviewerPool" })
  114. Assert.DoesNotContain(invented, code);
  115. }
  116. /// <summary>
  117. /// 「主管」这类没有真实实体支持的文案不得出现在审核展示里。
  118. ///
  119. /// <para>系统里 <c>SysOrg.DirectorId</c> 与 <c>SysUser.ManagerUserId</c> 填充率均为 0,
  120. /// 显示「主管」等于告诉管理员一个不存在的东西在负责审核。</para>
  121. /// </summary>
  122. [Fact]
  123. public void ReviewSource_DoesNotShowPhantomSupervisor()
  124. {
  125. var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
  126. Assert.DoesNotContain("主管", code);
  127. Assert.DoesNotContain("总监", code);
  128. var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
  129. PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
  130. // 页面里只允许在"说明为什么不用这个词"的注释中出现,可执行模板不得渲染它
  131. var template = page[page.IndexOf("<template>", StringComparison.Ordinal)..];
  132. var templateNoComments = System.Text.RegularExpressions.Regex.Replace(
  133. template, "<!--.*?-->", string.Empty,
  134. System.Text.RegularExpressions.RegexOptions.Singleline);
  135. Assert.DoesNotContain("L2_MANAGER", templateNoComments);
  136. Assert.DoesNotContain("L3_DIRECTOR", templateNoComments);
  137. }
  138. /// <summary>
  139. /// 解析不出人 / 跨租户引用<b>必须显式暴露</b>,不能静默显示成一片空白。
  140. ///
  141. /// <para>这正是这个视图存在的意义:让"审核链其实是断的"这件事在页面上看得见。</para>
  142. /// </summary>
  143. [Fact]
  144. public void ReviewSource_SurfacesBrokenApproverChains()
  145. {
  146. var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
  147. Assert.Contains("ResolvesToNobody", code);
  148. Assert.Contains("CrossTenantRefs", code);
  149. Assert.Contains("解析不出任何审批人", code);
  150. Assert.Contains("属于其它租户", code);
  151. // 解析失败要说"无法确定",不能装作"没有审批人"
  152. Assert.Contains("无法解析", code);
  153. }
  154. /// <summary>
  155. /// 复核环节必须说清楚:真正决定审核人的是提交复核时手选的账号,
  156. /// 审批流节点只同步状态。
  157. ///
  158. /// <para>不说清楚的话,管理员会去改流程配置然后发现毫无效果。</para>
  159. /// </summary>
  160. [Fact]
  161. public void ReviewSource_ExplainsVerifierIsChosenAtSubmitTime()
  162. {
  163. var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
  164. Assert.Contains("verifier_user_id", code);
  165. Assert.Contains("不决定", code);
  166. }
  167. /// <summary>
  168. /// 四页签弹窗<b>不包含</b>「异常操作权限」——它是 S8 全局配置,
  169. /// 放进规则弹窗会让管理员以为改的是这条规则的权限,实际改的是全租户。
  170. /// </summary>
  171. [Fact]
  172. public void RuleDialog_DoesNotEmbedGlobalActionPermission()
  173. {
  174. var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
  175. PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
  176. Assert.DoesNotContain("actionPermissions", page);
  177. Assert.DoesNotContain("action-permissions", page);
  178. // 四个页签齐全。S8-RESPONSIBILITY-POOL-1 把后两个改名为业务语言:
  179. // 「处理与审核」→「责任分配」(谁处理/谁复核/超时找谁),
  180. // 「通知与升级」→「通知设置」(升级责任已在责任分配里配,此处只管提醒开关)。
  181. foreach (var tab in new[] { "规则信息", "运行设置", "责任分配", "通知设置" })
  182. Assert.Contains($"label=\"{tab}\"", page);
  183. Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(page, "<el-tab-pane ").Count);
  184. }
  185. }