using Admin.NET.Plugin.AiDOP.Const.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RESPONSIBILITY-POOL-1:规则三责任池(处理 / 复核 / 升级)。 /// /// 本批要挡住的:角色与责任被重新混为一谈、复核人可以是池外的人、 /// 页面选甲而审批任务发给乙、升级回落到某个角色、通知对象与责任池各说各话。 /// 这些都是静默失败 —— 每一层都报成功,只有真实业务跑到那一步才会发现。 /// 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); } /// 去掉注释行,避免"注释里提到某个词"被当成实现证据。 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"); // ══════════════════════════════ 目录与存储 ══════════════════════════════ /// 三种责任类型齐备,且每一种都有面向业务的文案(页面不出现技术枚举)。 [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); } } /// /// T1/T2/T3 存储路由:处理池仍走存量表,复核 / 升级走新表。 /// 这条断言锁的是「不为了表结构好看去迁已经稳定的处理池」这个决定 —— /// 迁它要动就绪门禁 / 认领资格 / 可见性收敛 / 通知解析四条已跑通的链路。 /// [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); } /// T1–T3 写入:三类共用同一个写入口,成员判据同源。 [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")); } // ══════════════════════════════ 租户与账号 ══════════════════════════════ /// /// T4/T5/T6 跨租户成员必须被拒;T7 停用账号同样被拒。 /// 判据统一走 S8UserScopeValidator(同租户 + 启用), /// 且跨租户与不存在共用同一文案,不泄漏存在性。 /// [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", code); } /// T8 工厂不参与责任池;T9 不出现员工主数据。 [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); } /// /// T26 租户隔离:每一条池查询都带 tenant 谓词。 /// 读路径用 tenantId 参数、写路径用 scope.TenantId(服务端盖章), /// 两种写法都算数;缺任何一条就是跨租户读写。 /// [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)]}"); } } // ══════════════════════════════ 就绪门禁 ══════════════════════════════ /// /// T10/T11/T12 三条判据由各自的声明驱动,互不捆绑。 /// /// 拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。 /// 再叠加复核 / 升级两条判据只会把耦合放大成三重。 /// [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); } /// /// 空池判据是「有没有当前有效的成员」,不是「表里有没有行」。 /// 账号停用后那一行还在,但那个人已经不算数了。只数行数会放行一条实际上没人负责的规则。 /// [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); } /// Rule 01 显式声明三条判据;处理池那条与拆分前行为一致,不顺带改变启用条件。 [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); } /// /// T23 空池绝不回落到角色。 /// 回落会让「我没给这条规则配人」与「有人在负责」同时成立 —— 最难查的一类错配。 /// [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); } } /// T24 L1/L2/L3 与虚拟主管层级不得成为当前 authority。 [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); } } // ══════════════════════════════ 复核契约 ══════════════════════════════ /// /// T13/T14/T15 复核人资格 = 复核池 ∩ 审核权限 ∩ 可用账号,三者缺一不可。 /// [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); } /// /// 候选列表与提交校验同一份实现。 /// 两边各写一套,就会出现「页面能选、提交被拒」或更糟的「页面选甲、后端放行乙」。 /// [Fact] public void VerifierCandidates_AndSubmitCheck_ShareOneImplementation() { var eligibility = VerifierEligibility(); Assert.Contains("Task CheckAsync", eligibility); Assert.Contains("Task> ListCandidatesAsync", eligibility); // 提交复核只调这一个门,不再自己拼权限判据 var flow = TaskFlow(); Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", flow); Assert.DoesNotContain( "_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", flow); } /// T15 绕过 UI 直接调 API 注入池外 verifier 同样被拒 —— 门在服务端。 [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 之前判定"); } /// /// T16 页面选定的检验人 == 审批任务的实际处理人。 /// /// 这是本批修的核心错位:ResolveApprovers 只看流程定义节点, /// 完全不读 BizData,提交复核时传的 verifierUserId 从未参与审批人解析。 /// 当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。 /// [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); } /// 对齐失败不回滚已提交的复核,但必须留下可查的日志 —— 静默失败是本模块的老病。 [Fact] public void TaskAlignmentFailure_IsLoggedNotSwallowed() { var code = ClosureHandler(); Assert.Contains("s8_verify_task_aligned", code); Assert.Contains("s8_verify_task_align_failed", code); } /// 不改共用的 FlowEngine —— 它是 S1/S5/S6/S7 共用的平台组件。 [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); } // ══════════════════════════════ 升级契约 ══════════════════════════════ /// /// T19 超时升级按该规则的升级账号池解析,不再用 exception_type.escalate_role_code。 /// /// 旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上 /// (原实现注释已自陈)。责任池天然带 tenant_id,不存在这条越界路径。 /// [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); } /// 空升级池 fail closed:跳过并落日志,不回落、不猜。 [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); } // ══════════════════════════════ 通知推导 ══════════════════════════════ /// /// T20/T21/T22 通知对象由责任关系推导,业务用户不选技术枚举。 /// [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)); } /// /// 不制造不存在的事件:目录里每个事件都必须有真实派发点, /// 且每个事件都必须有默认收件人推导。 /// [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)); } } /// 「复核通过 / 复核退回」刻意缺席 —— 代码里没有这两个派发点。 [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))); } /// /// 通知里的 REVIEWER 不再解析审批流节点。 /// 审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题, /// 且会让通知链随流程节点结构悄悄变化。 /// [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); } /// 通知开关写入口只接布尔,收件人类型由目录推导 —— 页面不选技术枚举。 [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); } // ══════════════════════════════ 回归 ══════════════════════════════ /// T25 First Claim Wins 未被本批改动:仍是一条带前置条件的原子 UPDATE。 [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); } /// 迁移不绑定任何具体账号或角色 —— 部署后三个池初始为空,由管理员显式配置。 [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); } }