|
|
@@ -0,0 +1,462 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Const.S8;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-RESPONSIBILITY-POOL-1:规则三责任池(处理 / 复核 / 升级)。
|
|
|
+///
|
|
|
+/// <para><b>本批要挡住的</b>:角色与责任被重新混为一谈、复核人可以是池外的人、
|
|
|
+/// 页面选甲而审批任务发给乙、升级回落到某个角色、通知对象与责任池各说各话。
|
|
|
+/// 这些都是<b>静默</b>失败 —— 每一层都报成功,只有真实业务跑到那一步才会发现。</para>
|
|
|
+/// </summary>
|
|
|
+public class S8ResponsibilityPoolTests
|
|
|
+{
|
|
|
+ private static readonly string PluginRoot =
|
|
|
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
|
|
|
+
|
|
|
+ private static string Read(string relativePath)
|
|
|
+ {
|
|
|
+ var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
|
+ Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
|
|
|
+ return File.ReadAllText(full);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>去掉注释行,避免"注释里提到某个词"被当成实现证据。</summary>
|
|
|
+ private static string CodeOnly(string relativePath) =>
|
|
|
+ string.Join('\n', Read(relativePath).Split('\n').Where(l =>
|
|
|
+ {
|
|
|
+ var t = l.TrimStart();
|
|
|
+ return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
|
|
|
+ }));
|
|
|
+
|
|
|
+ private static string PoolService() => CodeOnly("Service/S8/S8RuleResponsibilityPoolService.cs");
|
|
|
+ private static string VerifierEligibility() => CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
|
|
|
+ private static string ReadinessGate() => CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
|
|
|
+ private static string TaskFlow() => CodeOnly("Service/S8/S8TaskFlowService.cs");
|
|
|
+ private static string ClosureHandler() => CodeOnly("Service/S8/ExceptionClosureBizHandler.cs");
|
|
|
+ private static string Escalation() => CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
|
|
|
+ private static string NotifyResolver() => CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
|
|
|
+
|
|
|
+ // ══════════════════════════════ 目录与存储 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>三种责任类型齐备,且每一种都有面向业务的文案(页面不出现技术枚举)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void Catalog_HasThreePoolsWithBusinessWording()
|
|
|
+ {
|
|
|
+ Assert.Equal(3, S8ResponsibilityCatalog.All.Count);
|
|
|
+ Assert.Equal(
|
|
|
+ new[] { S8ResponsibilityType.Handler, S8ResponsibilityType.Reviewer, S8ResponsibilityType.Escalation },
|
|
|
+ S8ResponsibilityCatalog.All.OrderBy(x => x.OrderNo).Select(x => x.Code));
|
|
|
+
|
|
|
+ foreach (var d in S8ResponsibilityCatalog.All)
|
|
|
+ {
|
|
|
+ Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
|
|
|
+ Assert.False(string.IsNullOrWhiteSpace(d.Description));
|
|
|
+ // 业务文案里不得出现技术术语
|
|
|
+ foreach (var banned in new[] { "Role", "ROLE_", "ApproverType", "Pool", "SysUser" })
|
|
|
+ Assert.DoesNotContain(banned, d.DisplayName + d.Description);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T1/T2/T3 存储路由:处理池仍走存量表,复核 / 升级走新表。
|
|
|
+ /// <para>这条断言锁的是「不为了表结构好看去迁已经稳定的处理池」这个决定 ——
|
|
|
+ /// 迁它要动就绪门禁 / 认领资格 / 可见性收敛 / 通知解析四条已跑通的链路。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void Storage_HandlerStaysInLegacyTable_ReviewerAndEscalationInNewTable()
|
|
|
+ {
|
|
|
+ Assert.False(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Handler));
|
|
|
+ Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Reviewer));
|
|
|
+ Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Escalation));
|
|
|
+
|
|
|
+ var code = PoolService();
|
|
|
+ // HANDLER 读写都路由到存量实现,不在本服务里复制第二份处理池逻辑
|
|
|
+ Assert.Contains("_handlerReader.GetMemberIdsAsync", code);
|
|
|
+ Assert.Contains("_handlerWriter.SetMembersAsync", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T1–T3 写入:三类共用同一个写入口,成员判据同源。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T1_T2_T3_AllThreePools_ShareOneWriteEntryAndOneValidator()
|
|
|
+ {
|
|
|
+ var code = PoolService();
|
|
|
+ Assert.Contains("public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, string responsibilityType", code);
|
|
|
+ Assert.Contains("_userScope.ValidateUsersAsync", code);
|
|
|
+ // 未知类型不落库
|
|
|
+ Assert.Contains("未知的责任类型", Read("Service/S8/S8RuleResponsibilityPoolService.cs"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════════════════════ 租户与账号 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T4/T5/T6 跨租户成员必须被拒;T7 停用账号同样被拒。
|
|
|
+ /// <para>判据统一走 <c>S8UserScopeValidator</c>(同租户 + 启用),
|
|
|
+ /// 且<b>跨租户与不存在共用同一文案</b>,不泄漏存在性。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T4_T7_CrossTenantAndInactiveMembers_AreRejectedWithSameMessage()
|
|
|
+ {
|
|
|
+ var code = PoolService();
|
|
|
+ Assert.Contains("var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted);", code);
|
|
|
+ Assert.Contains("throw new S8BizException(\"所选账号不存在或已停用\")", code);
|
|
|
+
|
|
|
+ // 池自己不写用户查询 —— 各写一套判据正是"配得进池、运行时又被拒"的来源
|
|
|
+ Assert.DoesNotContain("Queryable<SysUser>", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T8 工厂不参与责任池;T9 不出现员工主数据。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("Service/S8/S8RuleResponsibilityPoolService.cs")]
|
|
|
+ [InlineData("Entity/S8/AdoS8RuleResponsibilityUser.cs")]
|
|
|
+ [InlineData("Infrastructure/S8/S8VerifierEligibility.cs")]
|
|
|
+ public void T8_T9_NoFactoryNoEmployee(string relativePath)
|
|
|
+ {
|
|
|
+ var code = CodeOnly(relativePath);
|
|
|
+ Assert.DoesNotContain("FactoryId", code);
|
|
|
+ Assert.DoesNotContain("factory_id", code);
|
|
|
+ Assert.DoesNotContain("EmployeeMaster", code);
|
|
|
+ Assert.DoesNotContain("EmployeeId", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T26 租户隔离:<b>每一条</b>池查询都带 tenant 谓词。
|
|
|
+ /// <para>读路径用 <c>tenantId</c> 参数、写路径用 <c>scope.TenantId</c>(服务端盖章),
|
|
|
+ /// 两种写法都算数;缺任何一条就是跨租户读写。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T26_EveryPoolQuery_CarriesTenantPredicate()
|
|
|
+ {
|
|
|
+ var code = PoolService();
|
|
|
+ var queries = code.Split("AsQueryable()").Skip(1).ToList();
|
|
|
+ Assert.NotEmpty(queries);
|
|
|
+ foreach (var q in queries)
|
|
|
+ {
|
|
|
+ var head = q[..Math.Min(q.Length, 400)];
|
|
|
+ Assert.True(head.Contains("x.TenantId == tenantId", StringComparison.Ordinal)
|
|
|
+ || head.Contains("x.TenantId == scope.TenantId", StringComparison.Ordinal),
|
|
|
+ $"存在没有租户谓词的池查询:{head[..Math.Min(head.Length, 160)]}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════════════════════ 就绪门禁 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T10/T11/T12 三条判据由<b>各自的声明</b>驱动,互不捆绑。
|
|
|
+ ///
|
|
|
+ /// <para>拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
|
|
|
+ /// 再叠加复核 / 升级两条判据只会把耦合放大成三重。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T10_T11_T12_EachReadinessCheck_HasItsOwnDeclaration()
|
|
|
+ {
|
|
|
+ var code = ReadinessGate();
|
|
|
+ Assert.Contains("if (policy.RequiresDepartmentDefaultsForEnable)", code);
|
|
|
+ Assert.Contains("if (policy.RequiresHandlerPoolForEnable)", code);
|
|
|
+ Assert.Contains("if (policy.RequiresVerification)", code);
|
|
|
+ Assert.Contains("if (policy.SupportsTimeoutEscalation)", code);
|
|
|
+
|
|
|
+ // 各自独立的原因码 —— 管理员必须知道缺的是哪个池
|
|
|
+ Assert.Contains("HandlerPoolEmpty", code);
|
|
|
+ Assert.Contains("ReviewerPoolEmpty", code);
|
|
|
+ Assert.Contains("EscalationPoolEmpty", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 空池判据是「有没有<b>当前有效</b>的成员」,不是「表里有没有行」。
|
|
|
+ /// <para>账号停用后那一行还在,但那个人已经不算数了。只数行数会放行一条实际上没人负责的规则。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void ReadinessGate_JudgesByValidMembers_NotRowCount()
|
|
|
+ {
|
|
|
+ var code = ReadinessGate();
|
|
|
+ Assert.Contains("members.Any(m => m.Valid)", code);
|
|
|
+ // 「一个都没配」与「配了但都失效」分开报:管理员的下一步动作完全不同
|
|
|
+ Assert.Contains("members.Count == 0 ? emptyMessage : allInvalidMessage", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>Rule 01 显式声明三条判据;处理池那条与拆分前行为一致,不顺带改变启用条件。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void Rule01_DeclaresAllThreeResponsibilities()
|
|
|
+ {
|
|
|
+ var def = Read("Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs");
|
|
|
+ Assert.Contains("RequiresHandlerPoolForEnable = true", def);
|
|
|
+ Assert.Contains("RequiresVerification = true", def);
|
|
|
+ Assert.Contains("SupportsTimeoutEscalation = true", def);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T23 空池<b>绝不</b>回落到角色。
|
|
|
+ /// <para>回落会让「我没给这条规则配人」与「有人在负责」同时成立 —— 最难查的一类错配。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T23_EmptyPool_NeverFallsBackToAnyRole()
|
|
|
+ {
|
|
|
+ foreach (var code in new[] { ReadinessGate(), Escalation(), VerifierEligibility() })
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain("ROLE_QC", code);
|
|
|
+ Assert.DoesNotContain("EscalateRoleCode", code);
|
|
|
+ Assert.DoesNotContain("escalate_role_code", code);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T24 L1/L2/L3 与虚拟主管层级不得成为当前 authority。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T24_NoVirtualHierarchyAsCurrentAuthority()
|
|
|
+ {
|
|
|
+ foreach (var rel in new[]
|
|
|
+ {
|
|
|
+ "Service/S8/S8RuleResponsibilityPoolService.cs",
|
|
|
+ "Infrastructure/S8/S8VerifierEligibility.cs",
|
|
|
+ "Service/S8/S8TimeoutAutoEscalationService.cs",
|
|
|
+ "Const/S8/S8ResponsibilityCatalog.cs",
|
|
|
+ })
|
|
|
+ {
|
|
|
+ var code = CodeOnly(rel);
|
|
|
+ foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "DepartmentLeader", "DirectorId", "ManagerUserId" })
|
|
|
+ Assert.DoesNotContain(banned, code);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════════════════════ 复核契约 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T13/T14/T15 复核人资格 = 复核池 ∩ 审核权限 ∩ 可用账号,三者缺一不可。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T13_T14_T15_VerifierMustSatisfyAllThreeConditions()
|
|
|
+ {
|
|
|
+ var code = VerifierEligibility();
|
|
|
+ Assert.Contains("_userScope.FindActiveUserAsync(tenantId, verifierUserId)", code);
|
|
|
+ Assert.Contains("S8ResponsibilityType.Reviewer, verifierUserId", code);
|
|
|
+ Assert.Contains("S8ExceptionActionCode.Verify", code);
|
|
|
+
|
|
|
+ Assert.Contains("NotInReviewerPool", code);
|
|
|
+ Assert.Contains("NoActionPermission", code);
|
|
|
+ Assert.Contains("NotUsable", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 候选列表与提交校验<b>同一份实现</b>。
|
|
|
+ /// <para>两边各写一套,就会出现「页面能选、提交被拒」或更糟的「页面选甲、后端放行乙」。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void VerifierCandidates_AndSubmitCheck_ShareOneImplementation()
|
|
|
+ {
|
|
|
+ var eligibility = VerifierEligibility();
|
|
|
+ Assert.Contains("Task<S8VerifierEligibilityResult> CheckAsync", eligibility);
|
|
|
+ Assert.Contains("Task<List<S8VerifierCandidateDto>> ListCandidatesAsync", eligibility);
|
|
|
+
|
|
|
+ // 提交复核只调这一个门,不再自己拼权限判据
|
|
|
+ var flow = TaskFlow();
|
|
|
+ Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", flow);
|
|
|
+ Assert.DoesNotContain(
|
|
|
+ "_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", flow);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T15 绕过 UI 直接调 API 注入池外 verifier 同样被拒 —— 门在服务端。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T15_ApiInjectedOutsiderVerifier_IsRejectedServerSide()
|
|
|
+ {
|
|
|
+ var flow = TaskFlow();
|
|
|
+ var idx = flow.IndexOf("SubmitVerificationAsync(", StringComparison.Ordinal);
|
|
|
+ Assert.True(idx > 0);
|
|
|
+ var body = flow[idx..(idx + 1800)];
|
|
|
+
|
|
|
+ // 资格校验必须发生在状态迁移与落库之前
|
|
|
+ var checkIdx = body.IndexOf("_verifierEligibility.CheckAsync", StringComparison.Ordinal);
|
|
|
+ var mutateIdx = body.IndexOf("e.VerifierUserId = verifier.UserId", StringComparison.Ordinal);
|
|
|
+ Assert.True(checkIdx > 0 && mutateIdx > 0);
|
|
|
+ Assert.True(checkIdx < mutateIdx, "复核人资格必须在写入 verifier_user_id 之前判定");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T16 <b>页面选定的检验人 == 审批任务的实际处理人</b>。
|
|
|
+ ///
|
|
|
+ /// <para>这是本批修的核心错位:<c>ResolveApprovers</c> 只看流程定义节点,
|
|
|
+ /// <b>完全不读 BizData</b>,提交复核时传的 <c>verifierUserId</c> 从未参与审批人解析。
|
|
|
+ /// 当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T16_SelectedVerifier_BecomesTheActualFlowTaskAssignee()
|
|
|
+ {
|
|
|
+ var code = ClosureHandler();
|
|
|
+ Assert.Contains("AlignTaskToSelectedVerifierAsync", code);
|
|
|
+ Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", code);
|
|
|
+ Assert.Contains("keep.AssigneeId = verifierId;", code);
|
|
|
+
|
|
|
+ // 对齐必须发生在 OnFlowStarted 里 —— StartFlow 的顺序是先建任务再回调
|
|
|
+ var started = code.IndexOf("OnFlowStarted(long bizId", StringComparison.Ordinal);
|
|
|
+ var align = code.IndexOf("await AlignTaskToSelectedVerifierAsync", StringComparison.Ordinal);
|
|
|
+ Assert.True(started > 0 && align > started);
|
|
|
+
|
|
|
+ // 多余待办必须删除:留着等于池外的人仍能点通过,而 S8 侧校验又会拒绝他
|
|
|
+ Assert.Contains("AsDeleteable().Where(t => drop.Contains(t.Id))", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>对齐失败不回滚已提交的复核,但必须留下可查的日志 —— 静默失败是本模块的老病。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void TaskAlignmentFailure_IsLoggedNotSwallowed()
|
|
|
+ {
|
|
|
+ var code = ClosureHandler();
|
|
|
+ Assert.Contains("s8_verify_task_aligned", code);
|
|
|
+ Assert.Contains("s8_verify_task_align_failed", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>不改共用的 FlowEngine —— 它是 S1/S5/S6/S7 共用的平台组件。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void FlowEngine_IsNotModifiedByThisBatch()
|
|
|
+ {
|
|
|
+ var engine = Path.GetFullPath(Path.Combine(
|
|
|
+ PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
|
|
|
+ Assert.True(File.Exists(engine));
|
|
|
+ var code = File.ReadAllText(engine);
|
|
|
+
|
|
|
+ // 没有为 S8 加 BizData 动态审批人分支
|
|
|
+ Assert.DoesNotContain("verifierUserId", code);
|
|
|
+ Assert.DoesNotContain("S8ResponsibilityType", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════════════════════ 升级契约 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T19 超时升级按<b>该规则的升级账号池</b>解析,不再用 exception_type.escalate_role_code。
|
|
|
+ ///
|
|
|
+ /// <para>旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上
|
|
|
+ /// (原实现注释已自陈)。责任池天然带 tenant_id,不存在这条越界路径。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T19_Escalation_ResolvesExactlyTheRuleEscalationPool()
|
|
|
+ {
|
|
|
+ var code = Escalation();
|
|
|
+ Assert.Contains("S8ResponsibilityType.Escalation", code);
|
|
|
+ Assert.Contains("_pools.GetValidMemberIdsAsync", code);
|
|
|
+
|
|
|
+ // 旧 authority 已彻底移除(含类型仓储)
|
|
|
+ Assert.DoesNotContain("EscalateRoleCode", code);
|
|
|
+ Assert.DoesNotContain("AdoS8ExceptionType", code);
|
|
|
+ Assert.DoesNotContain("_typeRep", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>空升级池 fail closed:跳过并落日志,不回落、不猜。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void EmptyEscalationPool_SkipsLoudlyInsteadOfGuessing()
|
|
|
+ {
|
|
|
+ var code = Escalation();
|
|
|
+ Assert.Contains("reason=escalation_pool_empty", code);
|
|
|
+ Assert.Contains("if (escalationPool.Count == 0)", code);
|
|
|
+ // 人工提报没有来源规则,不参与规则级自动升级
|
|
|
+ Assert.Contains("reason=no_source_rule", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════════════════════ 通知推导 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T20/T21/T22 通知对象由责任关系<b>推导</b>,业务用户不选技术枚举。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T20_T21_T22_RecipientsAreDerivedFromResponsibilities()
|
|
|
+ {
|
|
|
+ Assert.Equal(S8RecipientType.HandlerPool,
|
|
|
+ S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionCreated));
|
|
|
+ Assert.Equal(S8RecipientType.Reviewer,
|
|
|
+ S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.VerificationSubmitted));
|
|
|
+ Assert.Equal(S8RecipientType.EscalationPool,
|
|
|
+ S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.EscalationTriggered));
|
|
|
+ Assert.Equal(S8RecipientType.Assignee,
|
|
|
+ S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionClaimed));
|
|
|
+ // 转派后 assignee_user_id 已经是新处理人,不需要额外的收件人类型
|
|
|
+ Assert.Equal(S8RecipientType.Assignee,
|
|
|
+ S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionTransferred));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// <b>不制造不存在的事件</b>:目录里每个事件都必须有真实派发点,
|
|
|
+ /// 且每个事件都必须有默认收件人推导。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void EveryCatalogEvent_HasBothADispatchSiteAndADerivedRecipient()
|
|
|
+ {
|
|
|
+ var serviceDir = Path.Combine(PluginRoot, "Service", "S8");
|
|
|
+ var allCode = string.Join('\n', Directory.GetFiles(serviceDir, "*.cs", SearchOption.AllDirectories)
|
|
|
+ .Select(File.ReadAllText));
|
|
|
+
|
|
|
+ foreach (var e in S8NotificationCatalog.Events)
|
|
|
+ {
|
|
|
+ var member = e.Code.Split('_')
|
|
|
+ .Select(p => char.ToUpperInvariant(p[0]) + p[1..].ToLowerInvariant())
|
|
|
+ .Aggregate(string.Empty, (a, b) => a + b);
|
|
|
+ Assert.True(allCode.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
|
|
|
+ $"事件 {e.Code} 在 Service/S8 下没有任何派发点 —— 配了却永远不触发的通知比没有通知更难排查");
|
|
|
+
|
|
|
+ Assert.NotNull(S8NotificationCatalog.ResolveDefaultRecipientType(e.Code));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>「复核通过 / 复核退回」刻意缺席 —— 代码里没有这两个派发点。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void NonExistentVerificationEvents_AreNotInvented()
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain("VERIFICATION_APPROVED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
|
|
|
+ Assert.DoesNotContain("VERIFICATION_REJECTED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 通知里的 REVIEWER 不再解析审批流节点。
|
|
|
+ /// <para>审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
|
|
|
+ /// 且会让通知链随流程节点结构悄悄变化。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void NotificationReviewer_NoLongerParsesApprovalFlowNodes()
|
|
|
+ {
|
|
|
+ var code = NotifyResolver();
|
|
|
+ Assert.DoesNotContain("ResolveFlowApproversAsync", code);
|
|
|
+ Assert.DoesNotContain("_reviewSource", code);
|
|
|
+ Assert.Contains("S8ResponsibilityType.Reviewer", code);
|
|
|
+ Assert.Contains("S8ResponsibilityType.Escalation", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>通知开关写入口只接布尔,收件人类型由目录推导 —— 页面不选技术枚举。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void NotificationToggle_TakesOnlyABooleanFromTheUi()
|
|
|
+ {
|
|
|
+ var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
|
|
|
+ Assert.Contains("SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled)", svc);
|
|
|
+ Assert.Contains("S8NotificationCatalog.ResolveDefaultRecipientType(eventCode)", svc);
|
|
|
+
|
|
|
+ var dto = Read("Controllers/S8/AdoS8ConfigWatchRulesController.cs");
|
|
|
+ Assert.Contains("class AdoS8NotifyEventToggleDto", dto);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════════════════════ 回归 ══════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>T25 First Claim Wins 未被本批改动:仍是一条带前置条件的原子 UPDATE。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T25_FirstClaimWins_IsUnchanged()
|
|
|
+ {
|
|
|
+ var code = TaskFlow();
|
|
|
+ Assert.Contains("x.Status == fromStatus", code);
|
|
|
+ Assert.Contains("x.AssigneeUserId == null", code);
|
|
|
+ Assert.Contains("该异常已被其他人员认领", code);
|
|
|
+ // 认领资格仍是 动作权限 ∩ 处理池
|
|
|
+ Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>迁移不绑定任何具体账号或角色 —— 部署后三个池初始为空,由管理员显式配置。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void Migration_BindsNoConcreteAccountsOrRoles()
|
|
|
+ {
|
|
|
+ var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
|
|
|
+ PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql")));
|
|
|
+
|
|
|
+ Assert.DoesNotContain("INSERT INTO `ado_s8_rule_responsibility_user`", sql);
|
|
|
+ Assert.DoesNotContain("ROLE_", sql);
|
|
|
+ Assert.DoesNotContain("DELIMITER", sql); // AutoVersionUpdate 会整份拒绝含该词的脚本
|
|
|
+
|
|
|
+ // 唯一索引断言按**列成员**判定,不是只看索引名是否存在(1.0.496 踩过这个坑)
|
|
|
+ Assert.Contains("COLUMN_NAME IN ('tenant_id','rule_code','responsibility_type','user_id')", sql);
|
|
|
+ Assert.Contains("s8_503_BLOCKED_cross_tenant_member", sql);
|
|
|
+ Assert.Contains("s8_503_BLOCKED_unknown_responsibility_type", sql);
|
|
|
+ }
|
|
|
+}
|