|
|
@@ -0,0 +1,345 @@
|
|
|
+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;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-RULE-FLOW-READINESS-1:审批流程依赖的启用门禁。
|
|
|
+///
|
|
|
+/// <para><b>要消灭的失败形态</b>:规则声明了复核 / 超时升级,但当前租户没有对应的
|
|
|
+/// <c>EXCEPTION_CLOSURE</c> / <c>EXCEPTION_ESCALATION</c> 已发布流程定义。
|
|
|
+/// 实际表现分两种,都不会让管理员看见问题 —— 复核链的 <c>StartFlow</c> 被
|
|
|
+/// <c>TryStartVerificationFlowAsync</c> 的 catch 吞成一条 warning(建单与提交照常成功);
|
|
|
+/// 升级链虽然会抛,但人工升级只回一条错误消息、自动升级作业记一条
|
|
|
+/// <c>s8_timeout_auto_escalate_failed</c>,而规则本身一路显示「已启用 / 最近结果:成功」。</para>
|
|
|
+///
|
|
|
+/// <para><b>本文件守三件事</b>:① 判据与 <c>FlowEngineService.StartFlowCore</c> 同源(含全局流程回落);
|
|
|
+/// ② 该要求是<b>逐规则声明</b>的(<c>RequiresVerification</c> / <c>SupportsTimeoutEscalation</c>),
|
|
|
+/// 未声明的规则完全不受影响;③ 停用路径<b>不经过</b>本门禁 ——
|
|
|
+/// 否则流程配坏的规则会卡在启用态关不掉。</para>
|
|
|
+/// </summary>
|
|
|
+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;
|
|
|
+
|
|
|
+ /// <summary>Rule 01:四维全开(部门 / 处理池 / 复核 / 超时升级),因此两条流程判据都会被激活。</summary>
|
|
|
+ private static S8RuleDefinition Rule01() =>
|
|
|
+ new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
|
|
|
+ .GetRequired(Rule01Code);
|
|
|
+
|
|
|
+ /// <summary>不声明复核 / 升级的规则:两条流程判据都不应被触发。</summary>
|
|
|
+ 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<string, object>
|
|
|
+ {
|
|
|
+ ["graceMinutes"] = 0,
|
|
|
+ ["defaultOccurrenceDeptId"] = DeptInA,
|
|
|
+ ["defaultResponsibleDeptId"] = DeptInA
|
|
|
+ })
|
|
|
+ };
|
|
|
+
|
|
|
+ /// <summary>部门替身:DeptInA 属租户 A。本文件不考察部门维,让它恒定通过。</summary>
|
|
|
+ private sealed class FakeDeptValidator : IS8DepartmentScopeValidator
|
|
|
+ {
|
|
|
+ public Task<bool> ExistsInTenantAsync(long? deptId, long tenantId) =>
|
|
|
+ Task.FromResult(deptId == DeptInA && tenantId == TenantA);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>责任池替身:三类池各 1 名有效成员。本文件不考察池维,让它恒定通过。</summary>
|
|
|
+ private sealed class FakePool : IS8RuleResponsibilityReader
|
|
|
+ {
|
|
|
+ public Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType) =>
|
|
|
+ Task.FromResult(new List<S8HandlerUserDto> { new() { UserId = 1000, Valid = true } });
|
|
|
+
|
|
|
+ public Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
|
|
|
+ Task.FromResult((IReadOnlyList<long>)new List<long> { 1000 });
|
|
|
+
|
|
|
+ public Task<IReadOnlyList<long>> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
|
|
|
+ Task.FromResult((IReadOnlyList<long>)new List<long> { 1000 });
|
|
|
+
|
|
|
+ public Task<bool> IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId) =>
|
|
|
+ Task.FromResult(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 审批流程替身。<b>复刻 <c>FlowEngineService.StartFlowCore</c> 的候选口径</b>:
|
|
|
+ /// 本租户流程或全局流程(TenantId IS NULL)任一存在即可用。
|
|
|
+ /// </summary>
|
|
|
+ private sealed class FakeFlowReader : IS8ApprovalFlowScopeReader
|
|
|
+ {
|
|
|
+ private readonly HashSet<(string BizType, long TenantId)> _tenantFlows;
|
|
|
+ private readonly HashSet<string> _globalFlows;
|
|
|
+
|
|
|
+ public FakeFlowReader(
|
|
|
+ IEnumerable<(string BizType, long TenantId)>? tenantFlows = null,
|
|
|
+ IEnumerable<string>? globalFlows = null)
|
|
|
+ {
|
|
|
+ _tenantFlows = new HashSet<(string, long)>(tenantFlows ?? Array.Empty<(string, long)>());
|
|
|
+ _globalFlows = new HashSet<string>(globalFlows ?? Array.Empty<string>(), StringComparer.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ public Task<bool> 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<S8RuleReadinessCheck> 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<bool> 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 注册契约:新判据必须能被容器解析 ───────────────────────────────────
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// <c>S8ApprovalFlowScopeReader</c> 必须实现 <c>ITransient</c> 并映射到
|
|
|
+ /// <see cref="IS8ApprovalFlowScopeReader"/>,否则 <c>S8RuleReadinessGate</c> 在运行时
|
|
|
+ /// 解析失败 —— 而那会让 Enable / RunNow / Scheduler 三条入口<b>一起</b> 500,
|
|
|
+ /// 是比缺门禁更严重的回归。注册方式与 <c>S8DepartmentScopeValidator</c> 保持同构。
|
|
|
+ /// </summary>
|
|
|
+ [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<AdoS8WatchRule> EnableAsync");
|
|
|
+ var disableBody = MethodBody(source, "public async Task<AdoS8WatchRule> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>按大括号配平截取方法体,避免相邻方法的内容串入断言。</summary>
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+}
|