using System.Reflection; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-HANDLER-POOL-1:规则处理账号池与认领资格。 /// /// 三个正交维度:动作权限(角色能做什么)· 规则责任(账号负责哪条规则)· /// 状态机(单据现在能不能做)。最终允许 = 三者交集。把它们混在一处判断是本仓踩过的坑: /// 某一维被"顺手"放宽后,另外两维看起来仍然正常,缺陷只在特定组合下出现。 /// public class S8RuleHandlerPoolTests { private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; 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); })); } // ══════════════════════════════════════════════════════════════════════════ // 存储形态 // ══════════════════════════════════════════════════════════════════════════ /// /// 身份是 (租户, 规则编码, 账号)。 /// /// 必须是 rule_code 而不是 rule_id:provisioning 的身份口径就是 /// (TenantId, RuleCode),而 watch_rule 的行会被重建 —— 1.0.496 的 clean reset /// 之后 id 全部变化。绑 id 会让人员池在那一刻集体失联,而且不报错: /// 规则照常跑,只是谁都不再"负责"它。 /// [Fact] public void Storage_IdentityIsTenantRuleCodeUser() { var t = typeof(AdoS8RuleHandlerUser); Assert.NotNull(t.GetProperty("TenantId")); Assert.NotNull(t.GetProperty("RuleCode")); Assert.NotNull(t.GetProperty("UserId")); Assert.Null(t.GetProperty("RuleId")); // 绑 id 会在规则行重建时集体失联 Assert.Null(t.GetProperty("FactoryId")); // 工厂不是隔离维度 Assert.Null(t.GetProperty("EmployeeId")); // S8 的人只有系统账号 } /// 人员池不得回流进 params_json —— 那个字段有被整块抹成 NULL 的事故先例。 [Fact] public void HandlerPool_IsNotStoredInParamsJson() { var code = CodeOnly("Service/S8/Rules/S8RuleRuntimeParameters.cs"); Assert.DoesNotContain("HandlerUser", code); Assert.DoesNotContain("handlerUserIds", code); // params_json 的 B 类键仍然只有那三项 Assert.Contains("KeyGraceMinutes", code); Assert.Contains("KeyDefaultOccurrenceDeptId", code); Assert.Contains("KeyDefaultResponsibleDeptId", code); } // ══════════════════════════════════════════════════════════════════════════ // 成员合法性 // ══════════════════════════════════════════════════════════════════════════ /// /// 成员合法性复用 ,不另写一套判据。 /// 池成员与认领人必须用同一把尺子,否则会出现「配得进池、认领时又被拒」—— /// 这种状态管理员完全无法自查。 /// [Fact] public void Membership_ReusesUserScopeValidator() { var code = CodeOnly("Service/S8/S8RuleHandlerPoolService.cs"); Assert.Contains("_userScope.ValidateUsersAsync", code); Assert.Contains("_userScope.FindActiveUserAsync", code); // 不得绕过 validator 自己拼 SysUser 查询 Assert.DoesNotContain("Queryable", code); Assert.DoesNotContain("StatusEnum.Enable", code); // 跨租户与不存在共用同一文案,不泄漏存在性 Assert.Contains("所选账号不存在或已停用", code); } /// /// 失效成员必须返回并标记,不能静默过滤。 /// 账号被停用之后池子无声地少一个人,管理员既看不见也不知道要补。 /// [Fact] public void InvalidMembers_AreSurfacedNotSilentlyDropped() { var code = CodeOnly("Service/S8/S8RuleHandlerPoolService.cs"); Assert.Contains("Valid = false", code); Assert.Contains("账号已停用或不在本租户", code); } /// 责任池链路上不得出现工厂或员工。 [Theory] [InlineData("Service/S8/S8RuleHandlerPoolService.cs")] [InlineData("Infrastructure/S8/S8ClaimEligibility.cs")] public void HandlerPoolChain_HasNoFactoryOrEmployee(string relativePath) { var code = CodeOnly(relativePath); Assert.DoesNotContain("FactoryId", code); Assert.DoesNotContain("FactoryRefId", code); Assert.DoesNotContain("EmployeeMaster", code); } // ══════════════════════════════════════════════════════════════════════════ // 认领资格:三维正交 // ══════════════════════════════════════════════════════════════════════════ /// /// 认领资格 = 动作权限 ∩ 规则责任,且两个拒绝原因可分辨。 /// /// 「没权限」与「不负责这条规则」是两种完全不同的处境,给用户同一句话 /// 会让他去找错人 —— 前者要找管理员配角色,后者要找管理员加责任池。 /// [Fact] public void ClaimEligibility_CombinesPermissionAndResponsibility() { var code = CodeOnly("Infrastructure/S8/S8ClaimEligibility.cs"); Assert.Contains("_authorizer.AuthorizeAsync(tenantId, userId, S8ExceptionActionCode.Claim)", code); Assert.Contains("_handlerPool.IsMemberAsync", code); Assert.Contains(S8ClaimDenyReason.NoActionPermission, code); Assert.Contains(S8ClaimDenyReason.NotInHandlerPool, code); Assert.NotEqual(S8ClaimDenyReason.NoActionPermission, S8ClaimDenyReason.NotInHandlerPool); } /// /// 人工提报无来源规则,不受责任池约束。 /// 若一刀切要求入池,人工提报的异常将永远无人可认领。 /// [Fact] public void ManualReport_IsNotConstrainedByHandlerPool() { var code = CodeOnly("Infrastructure/S8/S8ClaimEligibility.cs"); Assert.Contains("string.IsNullOrWhiteSpace(ruleCode)", code); Assert.Contains("return S8ClaimEligibilityResult.Pass();", code); } /// /// 状态机与并发那一维不在资格判定里重复实现 —— /// 仍由 S8TaskFlowService 的原子 UPDATE 负责,First Claim Wins 不受本批影响。 /// [Fact] public void ClaimEligibility_DoesNotDuplicateStateOrConcurrency() { var elig = CodeOnly("Infrastructure/S8/S8ClaimEligibility.cs"); Assert.DoesNotContain("S8StatusRules", elig); Assert.DoesNotContain("AssigneeUserId", elig); var flow = CodeOnly("Service/S8/S8TaskFlowService.cs"); Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", flow); // 原子谓词与竞争败者文案仍在 Assert.Contains("x.AssigneeUserId == null", flow); Assert.Contains("该异常已被其他人员认领", flow); } /// /// 转派目标同样受责任池约束 —— 不留「转给池外账号」的后门。 /// /// 留后门等于绕过责任池本身:池子写着 A、B 负责,实际单据却落到 C 手里, /// 而池配置看起来仍然正确。 /// [Fact] public void Transfer_TargetMustBeInHandlerPool() { var flow = CodeOnly("Service/S8/S8TaskFlowService.cs"); Assert.Contains("_handlerPool.IsMemberAsync(tenantId, e.SourceRuleCode!, target.UserId)", flow); Assert.Contains("不是该规则的处理人员,无法转派", flow); // 人工提报同样豁免(与认领同口径) Assert.Contains("!string.IsNullOrWhiteSpace(e.SourceRuleCode)", flow); } /// 认领资格必须只有一份实现。 [Fact] public void ClaimEligibility_HasSingleImplementation() { var impls = typeof(IS8ClaimEligibility).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IS8ClaimEligibility).IsAssignableFrom(t)) .ToList(); Assert.Single(impls); Assert.Equal(typeof(S8ClaimEligibility), impls[0]); } // ══════════════════════════════════════════════════════════════════════════ // 启用门禁 // ══════════════════════════════════════════════════════════════════════════ /// /// 启用要求至少一名当前有效的处理账号。 /// /// 判据不能是"表里有没有行":账号被停用之后那行还在,但人已经不能认领了。 /// 只数行数会让一条实际上没人能处理的规则通过启用检查 —— 那正是本门禁存在的意义。 /// [Fact] public void Enable_RequiresAtLeastOneValidHandler() { var code = CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs"); Assert.Contains(S8RuleReadinessReasonCode.HandlerPoolEmpty, code); // S8-RESPONSIBILITY-POOL-1:三类责任池走统一 reader,处理池由 responsibilityType 指定。 Assert.Contains("_pools.GetMembersAsync(tenantId, ruleCode, responsibilityType)", code); Assert.Contains("S8ResponsibilityType.Handler", code); // 有效性判据:不是 Count > 0,而是"存在一个 Valid 的" Assert.Contains("members.Any(m => m.Valid)", code); Assert.DoesNotContain("members.Count > 0)", code); // 两种失败要给不同提示:没配过 vs 配过但都失效 Assert.Contains("请先配置至少一名处理账号", code); Assert.Contains("均已停用或不在当前租户", code); } /// /// Preview 不受责任池约束(只读试算,与人员池正确性无关)。 /// /// 预演在独立的 S8WatchRulePreviewService 里,它整个不碰就绪门禁 —— /// 管理员本来就该「先预演看命中,再配人配部门,再启用」。若预演也要求先配人, /// 管理员将无法在配置前判断这条规则到底有没有用。 /// [Fact] public void Preview_IsNotGatedByHandlerPool() { var preview = CodeOnly("Service/S8/S8WatchRulePreviewService.cs"); Assert.Contains("PreviewAsync", preview); Assert.DoesNotContain("EnsureReadyAsync", preview); Assert.DoesNotContain("_handlerPool", preview); Assert.DoesNotContain("IS8RuleHandlerPoolReader", preview); // 启用路径必须仍有门禁 —— 否则这条断言会在"门禁被整个删掉"时假通过。 var ruleSvc = CodeOnly("Service/S8/S8WatchRuleService.cs"); Assert.Contains("EnsureReadyAsync", ruleSvc); } // ══════════════════════════════════════════════════════════════════════════ // 可见性 // ══════════════════════════════════════════════════════════════════════════ /// /// View 与 View All 分离:没有 EXCEPTION_VIEW_ALL 的账号只看得到 /// 自己负责的规则产出的异常、人工提报、以及与自己直接相关的单子。 /// /// 最后一支是必要的:单据可能在他认领之后被移出责任池, /// 少了它他会看不见自己手上正在处理的单子。 /// [Fact] public void Visibility_SeparatesViewFromViewAll() { var code = CodeOnly("Service/S8/S8ExceptionService.cs"); Assert.Contains("S8ExceptionActionCode.ViewAll", code); Assert.Contains("myRuleCodes.Contains(e.SourceRuleCode)", code); Assert.Contains("SqlFunc.IsNullOrEmpty(e.SourceRuleCode)", code); Assert.Contains("e.AssigneeUserId == myUserId", code); Assert.Contains("e.VerifierUserId == myUserId", code); Assert.Contains("e.ReporterUserId == myUserId", code); } /// /// 无登录上下文(看板聚合 / 后台作业)与超管不做可见性收敛。 /// 给它们加限制只会让统计数字凭空少掉,而且没人会想到去查这里。 /// [Fact] public void Visibility_SkipsSuperAdminAndBackgroundCallers() { var code = CodeOnly("Service/S8/S8ExceptionService.cs"); Assert.Contains("if (_userManager.SuperAdmin) return null;", code); Assert.Contains("if (userId <= 0 || tenantId <= 0) return null;", code); } /// /// EXCEPTION_VIEW_ALL 在 P1-B 建立时没有消费方,本批必须闭环 —— /// 否则它就是一个配了也不起作用的开关。 /// [Fact] public void ViewAll_HasARealConsumer() { Assert.Contains(S8ExceptionActionCatalog.All, d => d.Code == S8ExceptionActionCode.ViewAll); var code = CodeOnly("Service/S8/S8ExceptionService.cs"); Assert.Contains("allowed.Contains(S8ExceptionActionCode.ViewAll)", code); } // ══════════════════════════════════════════════════════════════════════════ // 迁移 // ══════════════════════════════════════════════════════════════════════════ /// 迁移建唯一索引 + 反查索引,并断言不存在跨租户池成员。 [Fact] public void Migration_GuardsUniquenessAndTenantBoundary() { var sql = File.ReadAllText(Path.GetFullPath(Path.Combine( PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.498.sql"))); Assert.Contains("uk_s8_rule_handler", sql); Assert.Contains("idx_s8_rule_handler_user", sql); Assert.Contains("s8_498_BLOCKED_unique_index_missing", sql); Assert.Contains("s8_498_BLOCKED_cross_tenant_handler", sql); // 不写任何成员数据:成员是 per-tenant 人工配置,写死既不适配新租户也会覆盖管理员选择 Assert.DoesNotContain("INSERT INTO `ado_s8_rule_handler_user`", sql); Assert.DoesNotContain("DELIMITER", sql); } }