using Admin.NET.Plugin.AiDOP.Service.S8;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-RULE-4TAB-1:审核来源只读呈现。
///
/// 本批刻意不做的事(产品决策已锁定):不新建 Rule 级 Reviewer Pool、
/// 不改 ApproverType、不动审批流节点、不改 owner/escalate 业务语义。
/// 这些"不做"必须被测试守住 —— 否则下一个人很容易顺手"补全"一个 reviewer_user_ids,
/// 于是同一个问题又出现两套 authority。
///
/// 为什么要做这个只读视图:规则页此前把审核人显示为「主管」,
/// 而系统里根本不存在组织主管关系(SysOrg.DirectorId 与
/// SysUser.ManagerUserId 实测填充率均为 0)。管理员据此以为审核链是通的,
/// 实际上 UAT 租户的复核流程审批人在本租户下解析为 0 人、
/// 升级流程三个节点全部指向默认租户的一个演示账号。
///
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);
}));
}
// ══════════════════════════════════════════════════════════════════════
// 节点解析:宽松读取,认不出就说不知道,绝不猜
// ══════════════════════════════════════════════════════════════════════
/// 真实流程定义(UAT 租户 EXCEPTION_CLOSURE 的实际 JSON 形态)能被解出。
[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);
}
/// 嵌套结构同样能找到节点(流程定义的外层结构由 ApprovalFlow 插件决定,不该写死)。
[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
}
///
/// 空 / 无审批人的定义解出 0 个节点,不抛异常。
/// 上层会显示「本租户没有该流程」,而不是崩在配置页上。
///
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("""{"nodes":[]}""")]
[InlineData("""{"nodes":[{"nodeName":"仅抄送"}]}""")]
public void ParseApproverNodes_HandlesEmptyGracefully(string? json)
{
Assert.Empty(S8ReviewSourceService.ParseApproverNodes(json));
}
/// 认不出的 approverType 一律跳过,不猜成某个默认值。
[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);
}
/// 字符串形式的 approverType 也接受(不同版本的流程设计器写法不一)。
[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);
}
// ══════════════════════════════════════════════════════════════════════
// 只读边界:本批锁定的"不做"必须被守住
// ══════════════════════════════════════════════════════════════════════
///
/// 审核来源服务绝不写库,也不新建 reviewer 概念。
///
/// 产品决策:不新建 Rule 级 Reviewer Pool、不改 ApproverType、不动流程节点。
/// 这条守卫防的是下一个人"顺手补全"—— 一旦出现 reviewer_user_ids,
/// 审核人就有了两个 authority,而现有审批流那套还在跑。
///
[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);
}
///
/// 「主管」这类没有真实实体支持的文案不得出现在审核展示里。
///
/// 系统里 SysOrg.DirectorId 与 SysUser.ManagerUserId 填充率均为 0,
/// 显示「主管」等于告诉管理员一个不存在的东西在负责审核。
///
[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("", 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);
}
///
/// 解析不出人 / 跨租户引用必须显式暴露,不能静默显示成一片空白。
///
/// 这正是这个视图存在的意义:让"审核链其实是断的"这件事在页面上看得见。
///
[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);
}
///
/// 复核环节必须说清楚:真正决定审核人的是提交复核时手选的账号,
/// 审批流节点只同步状态。
///
/// 不说清楚的话,管理员会去改流程配置然后发现毫无效果。
///
[Fact]
public void ReviewSource_ExplainsVerifierIsChosenAtSubmitTime()
{
var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
Assert.Contains("verifier_user_id", code);
Assert.Contains("不决定", code);
}
///
/// 四页签弹窗不包含「异常操作权限」——它是 S8 全局配置,
/// 放进规则弹窗会让管理员以为改的是这条规则的权限,实际改的是全租户。
///
[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);
// 四个页签齐全
foreach (var tab in new[] { "规则信息", "运行设置", "处理与审核", "通知与升级" })
Assert.Contains($"label=\"{tab}\"", page);
Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(page, "