using System.Reflection; using System.Text.Json; 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 Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RULE-FLOW-READINESS-1:审批流程依赖的启用门禁。 /// /// 要消灭的失败形态:规则声明了复核 / 超时升级,但当前租户没有对应的 /// EXCEPTION_CLOSURE / EXCEPTION_ESCALATION 已发布流程定义。 /// 实际表现分两种,都不会让管理员看见问题 —— 复核链的 StartFlow 被 /// TryStartVerificationFlowAsync 的 catch 吞成一条 warning(建单与提交照常成功); /// 升级链虽然会抛,但人工升级只回一条错误消息、自动升级作业记一条 /// s8_timeout_auto_escalate_failed,而规则本身一路显示「已启用 / 最近结果:成功」。 /// /// 本文件守三件事:① 判据与 FlowEngineService.StartFlowCore 同源(含全局流程回落); /// ② 该要求是逐规则声明的(RequiresVerification / SupportsTimeoutEscalation), /// 未声明的规则完全不受影响;③ 停用路径不经过本门禁 —— /// 否则流程配坏的规则会卡在启用态关不掉。 /// public class S8ApprovalFlowReadinessTests { private const long TenantA = 838257186181189L; private const long DeptInA = 245L; private const string ClosureBizType = "EXCEPTION_CLOSURE"; private const string EscalationBizType = "EXCEPTION_ESCALATION"; private static readonly string Rule01Code = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode; /// Rule 01:四维全开(部门 / 处理池 / 复核 / 超时升级),因此两条流程判据都会被激活。 private static S8RuleDefinition Rule01() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .GetRequired(Rule01Code); /// 不声明复核 / 升级的规则:两条流程判据都不应被触发。 private static S8RuleDefinition RuleWithoutFlowRequirement() => new() { RuleCode = "UT_RULE_NO_FLOW_REQUIRED", DisplayName = "单测规则", DatasetCode = "UT_DATASET", RuleType = "TIMEOUT", SourceObjectType = "ORDER", SceneCode = "S4", ExceptionTypeCode = "UT_TYPE", Timeout = new S8TimeoutSemantics { CompletedStates = new[] { "COMPLETED" } }, // RequiresVerification / SupportsTimeoutEscalation 均默认 false Parameters = new S8RuleParameterPolicy() }; private static AdoS8WatchRule Row(long tenantId, string ruleCode) => new() { Id = 1, TenantId = tenantId, FactoryId = 0, RuleCode = ruleCode, Enabled = false, PollIntervalSeconds = 300, TriggerCountRequired = 1, RecoverCountRequired = 2, Severity = "SERIOUS", ParamsJson = JsonSerializer.Serialize(new Dictionary { ["graceMinutes"] = 0, ["defaultOccurrenceDeptId"] = DeptInA, ["defaultResponsibleDeptId"] = DeptInA }) }; /// 部门替身:DeptInA 属租户 A。本文件不考察部门维,让它恒定通过。 private sealed class FakeDeptValidator : IS8DepartmentScopeValidator { public Task ExistsInTenantAsync(long? deptId, long tenantId) => Task.FromResult(deptId == DeptInA && tenantId == TenantA); } /// 责任池替身:三类池各 1 名有效成员。本文件不考察池维,让它恒定通过。 private sealed class FakePool : IS8RuleResponsibilityReader { public Task> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType) => Task.FromResult(new List { new() { UserId = 1000, Valid = true } }); public Task> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) => Task.FromResult((IReadOnlyList)new List { 1000 }); public Task> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) => Task.FromResult((IReadOnlyList)new List { 1000 }); public Task IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId) => Task.FromResult(true); } /// /// 审批流程替身。复刻 FlowEngineService.StartFlowCore 的候选口径: /// 本租户流程或全局流程(TenantId IS NULL)任一存在即可用。 /// private sealed class FakeFlowReader : IS8ApprovalFlowScopeReader { private readonly HashSet<(string BizType, long TenantId)> _tenantFlows; private readonly HashSet _globalFlows; public FakeFlowReader( IEnumerable<(string BizType, long TenantId)>? tenantFlows = null, IEnumerable? globalFlows = null) { _tenantFlows = new HashSet<(string, long)>(tenantFlows ?? Array.Empty<(string, long)>()); _globalFlows = new HashSet(globalFlows ?? Array.Empty(), StringComparer.Ordinal); } public Task HasUsableFlowAsync(string bizType, long tenantId) => Task.FromResult(_tenantFlows.Contains((bizType, tenantId)) || _globalFlows.Contains(bizType)); } private static S8RuleReadinessGate Gate(FakeFlowReader flows) => new(new FakeDeptValidator(), new FakePool(), flows); private static Task Check(FakeFlowReader flows, S8RuleDefinition? definition = null) { var def = definition ?? Rule01(); return Gate(flows).CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, def.RuleCode), def), TenantA); } // ── Case 1:本租户存在流程 → PASS ───────────────────────────────────────── [Fact] public async Task T1_TenantOwnFlows_ArePassed() { var r = await Check(new FakeFlowReader(tenantFlows: new[] { (ClosureBizType, TenantA), (EscalationBizType, TenantA) })); Assert.True(r.Ok, r.Message); } // ── Case 2:本租户与全局都没有 → BLOCKED ────────────────────────────────── [Fact] public async Task T2_NoClosureFlowAnywhere_IsRejected() { // 只给升级流程,复核流程两侧皆无。 var r = await Check(new FakeFlowReader(tenantFlows: new[] { (EscalationBizType, TenantA) })); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode); Assert.Contains(ClosureBizType, r.Message); } [Fact] public async Task T3_NoEscalationFlowAnywhere_IsRejected() { // 只给复核流程,升级流程两侧皆无。 var r = await Check(new FakeFlowReader(tenantFlows: new[] { (ClosureBizType, TenantA) })); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.EscalationFlowMissing, r.ReasonCode); Assert.Contains(EscalationBizType, r.Message); } [Fact] public async Task T4_NoFlowAtAll_IsRejected() { var r = await Check(new FakeFlowReader()); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode); } // ── Case 3:本租户没有、但存在全局流程 → PASS(与运行时回落一致)──────────── [Fact] public async Task T5_GlobalFlowFallback_IsPassed() { // 本租户一条都没建,全部靠 TenantId IS NULL 的全局流程回落 —— // 这正是 FlowEngineService.StartFlowCore 的 (TenantId == current || TenantId == null) 语义。 var r = await Check(new FakeFlowReader(globalFlows: new[] { ClosureBizType, EscalationBizType })); Assert.True(r.Ok, r.Message); } [Fact] public async Task T6_GlobalClosure_PlusTenantEscalation_IsPassed() { // 混合来源:复核走全局、升级走本租户。运行时两者都能取到定义,门禁也必须放行。 var r = await Check(new FakeFlowReader( tenantFlows: new[] { (EscalationBizType, TenantA) }, globalFlows: new[] { ClosureBizType })); Assert.True(r.Ok, r.Message); } [Fact] public async Task T7_OtherTenantFlow_DoesNotCount() { // 别的租户有流程不算数 —— 运行时的候选谓词里没有这一项。 var r = await Check(new FakeFlowReader(tenantFlows: new[] { (ClosureBizType, 838257212780613L), (EscalationBizType, 838257212780613L) })); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode); } // ── 逐规则声明:未声明复核 / 升级的规则完全不受影响 ──────────────────────── [Fact] public async Task T8_RuleWithoutFlowRequirement_IsUnaffected() { // 一条流程都没有,但该规则既不复核也不升级 —— 不该被这两条判据挡住。 var r = await Check(new FakeFlowReader(), RuleWithoutFlowRequirement()); Assert.True(r.Ok, r.Message); } // ── 判据同源:Reader 的查询必须与 FlowEngine 的候选口径一致 ──────────────── [Fact] public void T9_ScopeReader_MatchesFlowEngineCandidatePredicate() { var source = ReadSource( "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs"); var body = MethodBody(source, "public async Task HasUsableFlowAsync"); // FlowEngineService.StartFlowCore 的四个候选条件,缺一即门禁与运行时背离。 Assert.Contains("f.BizType == bizType", body, StringComparison.Ordinal); Assert.Contains("f.IsPublished", body, StringComparison.Ordinal); Assert.Contains("!f.IsDelete", body, StringComparison.Ordinal); Assert.Contains("f.TenantId == tenantId || f.TenantId == null", body, StringComparison.Ordinal); // 关掉全局 AOP 是为了能看见全局流程;关掉之后必须自己写死租户谓词(上一条已断言)。 Assert.Contains("ClearFilter()", body, StringComparison.Ordinal); // 只读:门禁不得写流程定义。 Assert.DoesNotContain("Insertable", body, StringComparison.Ordinal); Assert.DoesNotContain("Updateable", body, StringComparison.Ordinal); Assert.DoesNotContain("Deleteable", body, StringComparison.Ordinal); } [Fact] public void T10_GateBizTypes_MatchRuntimeStartFlowBizTypes() { var gate = ReadSource( "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs"); var taskFlow = ReadSource( "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs"); // 门禁检查的 BizType 必须正好是运行时 StartFlow 要启动的那两条。 Assert.Contains($"ClosureBizType = \"{ClosureBizType}\"", gate, StringComparison.Ordinal); Assert.Contains($"EscalationBizType = \"{EscalationBizType}\"", gate, StringComparison.Ordinal); Assert.Contains($"BizType = \"{ClosureBizType}\"", taskFlow, StringComparison.Ordinal); Assert.Contains($"BizType = \"{EscalationBizType}\"", taskFlow, StringComparison.Ordinal); } // ── DI 注册契约:新判据必须能被容器解析 ─────────────────────────────────── /// /// S8ApprovalFlowScopeReader 必须实现 ITransient 并映射到 /// ,否则 S8RuleReadinessGate 在运行时 /// 解析失败 —— 而那会让 Enable / RunNow / Scheduler 三条入口一起 500, /// 是比缺门禁更严重的回归。注册方式与 S8DepartmentScopeValidator 保持同构。 /// [Fact] public void T12_ScopeReader_IsRegisteredAsTransient() { var impl = typeof(S8ApprovalFlowScopeReader); Assert.True(typeof(IS8ApprovalFlowScopeReader).IsAssignableFrom(impl), "S8ApprovalFlowScopeReader 未实现 IS8ApprovalFlowScopeReader"); var transient = impl.GetInterfaces().FirstOrDefault(i => i.Name == "ITransient"); Assert.True(transient != null, "S8ApprovalFlowScopeReader 未实现 ITransient —— 容器不会注册它,Gate 将无法解析"); // Gate 的构造函数必须按接口依赖(留 seam),不得直接依赖实现或仓储。 var ctor = typeof(S8RuleReadinessGate).GetConstructors().Single(); Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(IS8ApprovalFlowScopeReader)); Assert.DoesNotContain(ctor.GetParameters(), p => p.ParameterType == impl); } // ── Case 4:停用不过门禁 ────────────────────────────────────────────────── [Fact] public void T11_DisablePath_DoesNotGoThroughReadinessGate() { var source = ReadSource( "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs"); var enableBody = MethodBody(source, "public async Task EnableAsync"); var disableBody = MethodBody(source, "public async Task DisableAsync"); // 启用必须过门禁。 Assert.Contains("_readinessGate", enableBody, StringComparison.Ordinal); // 停用必须不过 —— 流程配坏的规则仍然要关得掉,否则会卡在启用态。 Assert.DoesNotContain("_readinessGate", disableBody, StringComparison.Ordinal); } // ── helpers ────────────────────────────────────────────────────────────── private static string ReadSource(string repoRelativePath) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "server"))) dir = dir.Parent; Assert.NotNull(dir); var full = Path.Combine(dir!.FullName, repoRelativePath); Assert.True(File.Exists(full), $"未找到源文件:{full}"); return File.ReadAllText(full); } /// 按大括号配平截取方法体,避免相邻方法的内容串入断言。 private static string MethodBody(string source, string signature) { var start = source.IndexOf(signature, StringComparison.Ordinal); Assert.True(start >= 0, $"未找到方法:{signature}"); var open = source.IndexOf('{', start); Assert.True(open >= 0, $"未找到方法体起始大括号:{signature}"); var depth = 0; for (var i = open; i < source.Length; i++) { if (source[i] == '{') depth++; else if (source[i] == '}') { depth--; if (depth == 0) return source[open..(i + 1)]; } } Assert.Fail($"方法体大括号不配平:{signature}"); return string.Empty; } }