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