| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- using Admin.NET.Plugin.AiDOP.Service.S8;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-RULE-4TAB-1:审核来源<b>只读</b>呈现。
- ///
- /// <para><b>本批刻意不做的事</b>(产品决策已锁定):不新建 Rule 级 Reviewer Pool、
- /// 不改 ApproverType、不动审批流节点、不改 owner/escalate 业务语义。
- /// 这些"不做"必须被测试守住 —— 否则下一个人很容易顺手"补全"一个 reviewer_user_ids,
- /// 于是同一个问题又出现两套 authority。</para>
- ///
- /// <para><b>为什么要做这个只读视图</b>:规则页此前把审核人显示为「主管」,
- /// 而系统里根本不存在组织主管关系(<c>SysOrg.DirectorId</c> 与
- /// <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。管理员据此以为审核链是通的,
- /// 实际上 UAT 租户的复核流程审批人在本租户下解析为 0 人、
- /// 升级流程三个节点全部指向默认租户的一个演示账号。</para>
- /// </summary>
- public class S8ReviewSourceTests
- {
- private static readonly string PluginRoot =
- Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
- private static string CodeOnly(string relativePath)
- {
- var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
- Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
- return string.Join('\n', File.ReadAllLines(full)
- .Where(l =>
- {
- var t = l.TrimStart();
- return !t.StartsWith("///", StringComparison.Ordinal)
- && !t.StartsWith("//", StringComparison.Ordinal);
- }));
- }
- // ══════════════════════════════════════════════════════════════════════
- // 节点解析:宽松读取,认不出就说不知道,绝不猜
- // ══════════════════════════════════════════════════════════════════════
- /// <summary>真实流程定义(UAT 租户 EXCEPTION_CLOSURE 的实际 JSON 形态)能被解出。</summary>
- [Fact]
- public void ParseApproverNodes_ReadsRealFlowShape()
- {
- const string json = """
- {"nodes":[{"nodeKey":"n1","nodeName":"负责人确认","approverType":2,"approverIds":"1300000000101,1300000000888"}]}
- """;
- var nodes = S8ReviewSourceService.ParseApproverNodes(json);
- Assert.Single(nodes);
- Assert.Equal("负责人确认", nodes[0].NodeName);
- Assert.Equal(2, nodes[0].Type); // ApproverTypeEnum.Role
- Assert.Equal("1300000000101,1300000000888", nodes[0].Ids);
- }
- /// <summary>嵌套结构同样能找到节点(流程定义的外层结构由 ApprovalFlow 插件决定,不该写死)。</summary>
- [Fact]
- public void ParseApproverNodes_WalksNestedStructures()
- {
- const string json = """
- {"flow":{"children":[{"branches":[[{"nodeName":"厂级审批","approverType":1,"approverIds":"789819678195781"}]]}]}}
- """;
- var nodes = S8ReviewSourceService.ParseApproverNodes(json);
- Assert.Single(nodes);
- Assert.Equal("厂级审批", nodes[0].NodeName);
- Assert.Equal(1, nodes[0].Type); // SpecificUser
- }
- /// <summary>
- /// 空 / 无审批人的定义解出 0 个节点,<b>不抛异常</b>。
- /// 上层会显示「本租户没有该流程」,而不是崩在配置页上。
- /// </summary>
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData(" ")]
- [InlineData("""{"nodes":[]}""")]
- [InlineData("""{"nodes":[{"nodeName":"仅抄送"}]}""")]
- public void ParseApproverNodes_HandlesEmptyGracefully(string? json)
- {
- Assert.Empty(S8ReviewSourceService.ParseApproverNodes(json));
- }
- /// <summary>认不出的 approverType 一律跳过,不猜成某个默认值。</summary>
- [Fact]
- public void ParseApproverNodes_SkipsUnrecognizedApproverType()
- {
- const string json = """
- {"nodes":[{"nodeName":"怪节点","approverType":{"weird":true},"approverIds":"x"},
- {"nodeName":"正常","approverType":2,"approverIds":"ROLE_X"}]}
- """;
- var nodes = S8ReviewSourceService.ParseApproverNodes(json);
- Assert.Single(nodes);
- Assert.Equal("正常", nodes[0].NodeName);
- }
- /// <summary>字符串形式的 approverType 也接受(不同版本的流程设计器写法不一)。</summary>
- [Fact]
- public void ParseApproverNodes_AcceptsStringApproverType()
- {
- const string json = """{"nodes":[{"nodeName":"n","approverType":"Role","approverIds":"ROLE_A"}]}""";
- var nodes = S8ReviewSourceService.ParseApproverNodes(json);
- Assert.Single(nodes);
- Assert.Equal(2, nodes[0].Type);
- }
- // ══════════════════════════════════════════════════════════════════════
- // 只读边界:本批锁定的"不做"必须被守住
- // ══════════════════════════════════════════════════════════════════════
- /// <summary>
- /// 审核来源服务<b>绝不写库</b>,也不新建 reviewer 概念。
- ///
- /// <para>产品决策:不新建 Rule 级 Reviewer Pool、不改 ApproverType、不动流程节点。
- /// 这条守卫防的是下一个人"顺手补全"—— 一旦出现 reviewer_user_ids,
- /// 审核人就有了两个 authority,而现有审批流那套还在跑。</para>
- /// </summary>
- [Fact]
- public void ReviewSource_IsStrictlyReadOnly()
- {
- var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
- foreach (var write in new[] { "AsInsertable", "AsUpdateable", "AsDeleteable", "ExecuteCommandAsync", "InsertAsync", "UpdateAsync" })
- Assert.DoesNotContain(write, code);
- foreach (var invented in new[] { "ReviewerPool", "reviewer_user_ids", "ReviewerUserIds", "IS8ReviewerPool" })
- Assert.DoesNotContain(invented, code);
- }
- /// <summary>
- /// 「主管」这类没有真实实体支持的文案不得出现在审核展示里。
- ///
- /// <para>系统里 <c>SysOrg.DirectorId</c> 与 <c>SysUser.ManagerUserId</c> 填充率均为 0,
- /// 显示「主管」等于告诉管理员一个不存在的东西在负责审核。</para>
- /// </summary>
- [Fact]
- public void ReviewSource_DoesNotShowPhantomSupervisor()
- {
- var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
- Assert.DoesNotContain("主管", code);
- Assert.DoesNotContain("总监", code);
- var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
- PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
- // 页面里只允许在"说明为什么不用这个词"的注释中出现,可执行模板不得渲染它
- var template = page[page.IndexOf("<template>", StringComparison.Ordinal)..];
- var templateNoComments = System.Text.RegularExpressions.Regex.Replace(
- template, "<!--.*?-->", string.Empty,
- System.Text.RegularExpressions.RegexOptions.Singleline);
- Assert.DoesNotContain("L2_MANAGER", templateNoComments);
- Assert.DoesNotContain("L3_DIRECTOR", templateNoComments);
- }
- /// <summary>
- /// 解析不出人 / 跨租户引用<b>必须显式暴露</b>,不能静默显示成一片空白。
- ///
- /// <para>这正是这个视图存在的意义:让"审核链其实是断的"这件事在页面上看得见。</para>
- /// </summary>
- [Fact]
- public void ReviewSource_SurfacesBrokenApproverChains()
- {
- var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
- Assert.Contains("ResolvesToNobody", code);
- Assert.Contains("CrossTenantRefs", code);
- Assert.Contains("解析不出任何审批人", code);
- Assert.Contains("属于其它租户", code);
- // 解析失败要说"无法确定",不能装作"没有审批人"
- Assert.Contains("无法解析", code);
- }
- /// <summary>
- /// 复核环节必须说清楚:真正决定审核人的是提交复核时手选的账号,
- /// 审批流节点只同步状态。
- ///
- /// <para>不说清楚的话,管理员会去改流程配置然后发现毫无效果。</para>
- /// </summary>
- [Fact]
- public void ReviewSource_ExplainsVerifierIsChosenAtSubmitTime()
- {
- var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
- Assert.Contains("verifier_user_id", code);
- Assert.Contains("不决定", code);
- }
- /// <summary>
- /// 四页签弹窗<b>不包含</b>「异常操作权限」——它是 S8 全局配置,
- /// 放进规则弹窗会让管理员以为改的是这条规则的权限,实际改的是全租户。
- /// </summary>
- [Fact]
- public void RuleDialog_DoesNotEmbedGlobalActionPermission()
- {
- var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
- PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
- Assert.DoesNotContain("actionPermissions", page);
- Assert.DoesNotContain("action-permissions", page);
- // 四个页签齐全。S8-RESPONSIBILITY-POOL-1 把后两个改名为业务语言:
- // 「处理与审核」→「责任分配」(谁处理/谁复核/超时找谁),
- // 「通知与升级」→「通知设置」(升级责任已在责任分配里配,此处只管提醒开关)。
- foreach (var tab in new[] { "规则信息", "运行设置", "责任分配", "通知设置" })
- Assert.Contains($"label=\"{tab}\"", page);
- Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(page, "<el-tab-pane ").Count);
- }
- }
|