| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- 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;
- }
- }
|