S8ApprovalFlowReadinessTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. using System.Reflection;
  2. using System.Text.Json;
  3. using Admin.NET.Plugin.AiDOP.Const.S8;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Admin.NET.Plugin.AiDOP.Service.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  8. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  9. using Xunit;
  10. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  11. /// <summary>
  12. /// S8-RULE-FLOW-READINESS-1:审批流程依赖的启用门禁。
  13. ///
  14. /// <para><b>要消灭的失败形态</b>:规则声明了复核 / 超时升级,但当前租户没有对应的
  15. /// <c>EXCEPTION_CLOSURE</c> / <c>EXCEPTION_ESCALATION</c> 已发布流程定义。
  16. /// 实际表现分两种,都不会让管理员看见问题 —— 复核链的 <c>StartFlow</c> 被
  17. /// <c>TryStartVerificationFlowAsync</c> 的 catch 吞成一条 warning(建单与提交照常成功);
  18. /// 升级链虽然会抛,但人工升级只回一条错误消息、自动升级作业记一条
  19. /// <c>s8_timeout_auto_escalate_failed</c>,而规则本身一路显示「已启用 / 最近结果:成功」。</para>
  20. ///
  21. /// <para><b>本文件守三件事</b>:① 判据与 <c>FlowEngineService.StartFlowCore</c> 同源(含全局流程回落);
  22. /// ② 该要求是<b>逐规则声明</b>的(<c>RequiresVerification</c> / <c>SupportsTimeoutEscalation</c>),
  23. /// 未声明的规则完全不受影响;③ 停用路径<b>不经过</b>本门禁 ——
  24. /// 否则流程配坏的规则会卡在启用态关不掉。</para>
  25. /// </summary>
  26. public class S8ApprovalFlowReadinessTests
  27. {
  28. private const long TenantA = 838257186181189L;
  29. private const long DeptInA = 245L;
  30. private const string ClosureBizType = "EXCEPTION_CLOSURE";
  31. private const string EscalationBizType = "EXCEPTION_ESCALATION";
  32. private static readonly string Rule01Code = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
  33. /// <summary>Rule 01:四维全开(部门 / 处理池 / 复核 / 超时升级),因此两条流程判据都会被激活。</summary>
  34. private static S8RuleDefinition Rule01() =>
  35. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
  36. .GetRequired(Rule01Code);
  37. /// <summary>不声明复核 / 升级的规则:两条流程判据都不应被触发。</summary>
  38. private static S8RuleDefinition RuleWithoutFlowRequirement() => new()
  39. {
  40. RuleCode = "UT_RULE_NO_FLOW_REQUIRED",
  41. DisplayName = "单测规则",
  42. DatasetCode = "UT_DATASET",
  43. RuleType = "TIMEOUT",
  44. SourceObjectType = "ORDER",
  45. SceneCode = "S4",
  46. ExceptionTypeCode = "UT_TYPE",
  47. Timeout = new S8TimeoutSemantics { CompletedStates = new[] { "COMPLETED" } },
  48. // RequiresVerification / SupportsTimeoutEscalation 均默认 false
  49. Parameters = new S8RuleParameterPolicy()
  50. };
  51. private static AdoS8WatchRule Row(long tenantId, string ruleCode) => new()
  52. {
  53. Id = 1,
  54. TenantId = tenantId,
  55. FactoryId = 0,
  56. RuleCode = ruleCode,
  57. Enabled = false,
  58. PollIntervalSeconds = 300,
  59. TriggerCountRequired = 1,
  60. RecoverCountRequired = 2,
  61. Severity = "SERIOUS",
  62. ParamsJson = JsonSerializer.Serialize(new Dictionary<string, object>
  63. {
  64. ["graceMinutes"] = 0,
  65. ["defaultOccurrenceDeptId"] = DeptInA,
  66. ["defaultResponsibleDeptId"] = DeptInA
  67. })
  68. };
  69. /// <summary>部门替身:DeptInA 属租户 A。本文件不考察部门维,让它恒定通过。</summary>
  70. private sealed class FakeDeptValidator : IS8DepartmentScopeValidator
  71. {
  72. public Task<bool> ExistsInTenantAsync(long? deptId, long tenantId) =>
  73. Task.FromResult(deptId == DeptInA && tenantId == TenantA);
  74. }
  75. /// <summary>责任池替身:三类池各 1 名有效成员。本文件不考察池维,让它恒定通过。</summary>
  76. private sealed class FakePool : IS8RuleResponsibilityReader
  77. {
  78. public Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType) =>
  79. Task.FromResult(new List<S8HandlerUserDto> { new() { UserId = 1000, Valid = true } });
  80. public Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
  81. Task.FromResult((IReadOnlyList<long>)new List<long> { 1000 });
  82. public Task<IReadOnlyList<long>> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
  83. Task.FromResult((IReadOnlyList<long>)new List<long> { 1000 });
  84. public Task<bool> IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId) =>
  85. Task.FromResult(true);
  86. }
  87. /// <summary>
  88. /// 审批流程替身。<b>复刻 <c>FlowEngineService.StartFlowCore</c> 的候选口径</b>:
  89. /// 本租户流程或全局流程(TenantId IS NULL)任一存在即可用。
  90. /// </summary>
  91. private sealed class FakeFlowReader : IS8ApprovalFlowScopeReader
  92. {
  93. private readonly HashSet<(string BizType, long TenantId)> _tenantFlows;
  94. private readonly HashSet<string> _globalFlows;
  95. public FakeFlowReader(
  96. IEnumerable<(string BizType, long TenantId)>? tenantFlows = null,
  97. IEnumerable<string>? globalFlows = null)
  98. {
  99. _tenantFlows = new HashSet<(string, long)>(tenantFlows ?? Array.Empty<(string, long)>());
  100. _globalFlows = new HashSet<string>(globalFlows ?? Array.Empty<string>(), StringComparer.Ordinal);
  101. }
  102. public Task<bool> HasUsableFlowAsync(string bizType, long tenantId) =>
  103. Task.FromResult(_tenantFlows.Contains((bizType, tenantId)) || _globalFlows.Contains(bizType));
  104. }
  105. private static S8RuleReadinessGate Gate(FakeFlowReader flows) =>
  106. new(new FakeDeptValidator(), new FakePool(), flows);
  107. private static Task<S8RuleReadinessCheck> Check(FakeFlowReader flows, S8RuleDefinition? definition = null)
  108. {
  109. var def = definition ?? Rule01();
  110. return Gate(flows).CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, def.RuleCode), def), TenantA);
  111. }
  112. // ── Case 1:本租户存在流程 → PASS ─────────────────────────────────────────
  113. [Fact]
  114. public async Task T1_TenantOwnFlows_ArePassed()
  115. {
  116. var r = await Check(new FakeFlowReader(tenantFlows: new[]
  117. {
  118. (ClosureBizType, TenantA),
  119. (EscalationBizType, TenantA)
  120. }));
  121. Assert.True(r.Ok, r.Message);
  122. }
  123. // ── Case 2:本租户与全局都没有 → BLOCKED ──────────────────────────────────
  124. [Fact]
  125. public async Task T2_NoClosureFlowAnywhere_IsRejected()
  126. {
  127. // 只给升级流程,复核流程两侧皆无。
  128. var r = await Check(new FakeFlowReader(tenantFlows: new[] { (EscalationBizType, TenantA) }));
  129. Assert.False(r.Ok);
  130. Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode);
  131. Assert.Contains(ClosureBizType, r.Message);
  132. }
  133. [Fact]
  134. public async Task T3_NoEscalationFlowAnywhere_IsRejected()
  135. {
  136. // 只给复核流程,升级流程两侧皆无。
  137. var r = await Check(new FakeFlowReader(tenantFlows: new[] { (ClosureBizType, TenantA) }));
  138. Assert.False(r.Ok);
  139. Assert.Equal(S8RuleReadinessReasonCode.EscalationFlowMissing, r.ReasonCode);
  140. Assert.Contains(EscalationBizType, r.Message);
  141. }
  142. [Fact]
  143. public async Task T4_NoFlowAtAll_IsRejected()
  144. {
  145. var r = await Check(new FakeFlowReader());
  146. Assert.False(r.Ok);
  147. Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode);
  148. }
  149. // ── Case 3:本租户没有、但存在全局流程 → PASS(与运行时回落一致)────────────
  150. [Fact]
  151. public async Task T5_GlobalFlowFallback_IsPassed()
  152. {
  153. // 本租户一条都没建,全部靠 TenantId IS NULL 的全局流程回落 ——
  154. // 这正是 FlowEngineService.StartFlowCore 的 (TenantId == current || TenantId == null) 语义。
  155. var r = await Check(new FakeFlowReader(globalFlows: new[] { ClosureBizType, EscalationBizType }));
  156. Assert.True(r.Ok, r.Message);
  157. }
  158. [Fact]
  159. public async Task T6_GlobalClosure_PlusTenantEscalation_IsPassed()
  160. {
  161. // 混合来源:复核走全局、升级走本租户。运行时两者都能取到定义,门禁也必须放行。
  162. var r = await Check(new FakeFlowReader(
  163. tenantFlows: new[] { (EscalationBizType, TenantA) },
  164. globalFlows: new[] { ClosureBizType }));
  165. Assert.True(r.Ok, r.Message);
  166. }
  167. [Fact]
  168. public async Task T7_OtherTenantFlow_DoesNotCount()
  169. {
  170. // 别的租户有流程不算数 —— 运行时的候选谓词里没有这一项。
  171. var r = await Check(new FakeFlowReader(tenantFlows: new[]
  172. {
  173. (ClosureBizType, 838257212780613L),
  174. (EscalationBizType, 838257212780613L)
  175. }));
  176. Assert.False(r.Ok);
  177. Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode);
  178. }
  179. // ── 逐规则声明:未声明复核 / 升级的规则完全不受影响 ────────────────────────
  180. [Fact]
  181. public async Task T8_RuleWithoutFlowRequirement_IsUnaffected()
  182. {
  183. // 一条流程都没有,但该规则既不复核也不升级 —— 不该被这两条判据挡住。
  184. var r = await Check(new FakeFlowReader(), RuleWithoutFlowRequirement());
  185. Assert.True(r.Ok, r.Message);
  186. }
  187. // ── 判据同源:Reader 的查询必须与 FlowEngine 的候选口径一致 ────────────────
  188. [Fact]
  189. public void T9_ScopeReader_MatchesFlowEngineCandidatePredicate()
  190. {
  191. var source = ReadSource(
  192. "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs");
  193. var body = MethodBody(source, "public async Task<bool> HasUsableFlowAsync");
  194. // FlowEngineService.StartFlowCore 的四个候选条件,缺一即门禁与运行时背离。
  195. Assert.Contains("f.BizType == bizType", body, StringComparison.Ordinal);
  196. Assert.Contains("f.IsPublished", body, StringComparison.Ordinal);
  197. Assert.Contains("!f.IsDelete", body, StringComparison.Ordinal);
  198. Assert.Contains("f.TenantId == tenantId || f.TenantId == null", body, StringComparison.Ordinal);
  199. // 关掉全局 AOP 是为了能看见全局流程;关掉之后必须自己写死租户谓词(上一条已断言)。
  200. Assert.Contains("ClearFilter()", body, StringComparison.Ordinal);
  201. // 只读:门禁不得写流程定义。
  202. Assert.DoesNotContain("Insertable", body, StringComparison.Ordinal);
  203. Assert.DoesNotContain("Updateable", body, StringComparison.Ordinal);
  204. Assert.DoesNotContain("Deleteable", body, StringComparison.Ordinal);
  205. }
  206. [Fact]
  207. public void T10_GateBizTypes_MatchRuntimeStartFlowBizTypes()
  208. {
  209. var gate = ReadSource(
  210. "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs");
  211. var taskFlow = ReadSource(
  212. "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs");
  213. // 门禁检查的 BizType 必须正好是运行时 StartFlow 要启动的那两条。
  214. Assert.Contains($"ClosureBizType = \"{ClosureBizType}\"", gate, StringComparison.Ordinal);
  215. Assert.Contains($"EscalationBizType = \"{EscalationBizType}\"", gate, StringComparison.Ordinal);
  216. Assert.Contains($"BizType = \"{ClosureBizType}\"", taskFlow, StringComparison.Ordinal);
  217. Assert.Contains($"BizType = \"{EscalationBizType}\"", taskFlow, StringComparison.Ordinal);
  218. }
  219. // ── DI 注册契约:新判据必须能被容器解析 ───────────────────────────────────
  220. /// <summary>
  221. /// <c>S8ApprovalFlowScopeReader</c> 必须实现 <c>ITransient</c> 并映射到
  222. /// <see cref="IS8ApprovalFlowScopeReader"/>,否则 <c>S8RuleReadinessGate</c> 在运行时
  223. /// 解析失败 —— 而那会让 Enable / RunNow / Scheduler 三条入口<b>一起</b> 500,
  224. /// 是比缺门禁更严重的回归。注册方式与 <c>S8DepartmentScopeValidator</c> 保持同构。
  225. /// </summary>
  226. [Fact]
  227. public void T12_ScopeReader_IsRegisteredAsTransient()
  228. {
  229. var impl = typeof(S8ApprovalFlowScopeReader);
  230. Assert.True(typeof(IS8ApprovalFlowScopeReader).IsAssignableFrom(impl),
  231. "S8ApprovalFlowScopeReader 未实现 IS8ApprovalFlowScopeReader");
  232. var transient = impl.GetInterfaces().FirstOrDefault(i => i.Name == "ITransient");
  233. Assert.True(transient != null,
  234. "S8ApprovalFlowScopeReader 未实现 ITransient —— 容器不会注册它,Gate 将无法解析");
  235. // Gate 的构造函数必须按接口依赖(留 seam),不得直接依赖实现或仓储。
  236. var ctor = typeof(S8RuleReadinessGate).GetConstructors().Single();
  237. Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(IS8ApprovalFlowScopeReader));
  238. Assert.DoesNotContain(ctor.GetParameters(), p => p.ParameterType == impl);
  239. }
  240. // ── Case 4:停用不过门禁 ──────────────────────────────────────────────────
  241. [Fact]
  242. public void T11_DisablePath_DoesNotGoThroughReadinessGate()
  243. {
  244. var source = ReadSource(
  245. "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs");
  246. var enableBody = MethodBody(source, "public async Task<AdoS8WatchRule> EnableAsync");
  247. var disableBody = MethodBody(source, "public async Task<AdoS8WatchRule> DisableAsync");
  248. // 启用必须过门禁。
  249. Assert.Contains("_readinessGate", enableBody, StringComparison.Ordinal);
  250. // 停用必须不过 —— 流程配坏的规则仍然要关得掉,否则会卡在启用态。
  251. Assert.DoesNotContain("_readinessGate", disableBody, StringComparison.Ordinal);
  252. }
  253. // ── helpers ──────────────────────────────────────────────────────────────
  254. private static string ReadSource(string repoRelativePath)
  255. {
  256. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  257. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "server")))
  258. dir = dir.Parent;
  259. Assert.NotNull(dir);
  260. var full = Path.Combine(dir!.FullName, repoRelativePath);
  261. Assert.True(File.Exists(full), $"未找到源文件:{full}");
  262. return File.ReadAllText(full);
  263. }
  264. /// <summary>按大括号配平截取方法体,避免相邻方法的内容串入断言。</summary>
  265. private static string MethodBody(string source, string signature)
  266. {
  267. var start = source.IndexOf(signature, StringComparison.Ordinal);
  268. Assert.True(start >= 0, $"未找到方法:{signature}");
  269. var open = source.IndexOf('{', start);
  270. Assert.True(open >= 0, $"未找到方法体起始大括号:{signature}");
  271. var depth = 0;
  272. for (var i = open; i < source.Length; i++)
  273. {
  274. if (source[i] == '{') depth++;
  275. else if (source[i] == '}')
  276. {
  277. depth--;
  278. if (depth == 0) return source[open..(i + 1)];
  279. }
  280. }
  281. Assert.Fail($"方法体大括号不配平:{signature}");
  282. return string.Empty;
  283. }
  284. }