using Admin.NET.Plugin.AiDOP.Const.S8;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-RESPONSIBILITY-POOL-1:规则三责任池(处理 / 复核 / 升级)。
///
/// 本批要挡住的:角色与责任被重新混为一谈、复核人可以是池外的人、
/// 页面选甲而审批任务发给乙、升级回落到某个角色、通知对象与责任池各说各话。
/// 这些都是静默失败 —— 每一层都报成功,只有真实业务跑到那一步才会发现。
///
public class S8ResponsibilityPoolTests
{
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string Read(string relativePath)
{
var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
/// 去掉注释行,避免"注释里提到某个词"被当成实现证据。
private static string CodeOnly(string relativePath) =>
string.Join('\n', Read(relativePath).Split('\n').Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
}));
private static string PoolService() => CodeOnly("Service/S8/S8RuleResponsibilityPoolService.cs");
private static string VerifierEligibility() => CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
private static string ReadinessGate() => CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
private static string TaskFlow() => CodeOnly("Service/S8/S8TaskFlowService.cs");
private static string ClosureHandler() => CodeOnly("Service/S8/ExceptionClosureBizHandler.cs");
private static string Escalation() => CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
private static string NotifyResolver() => CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
// ══════════════════════════════ 目录与存储 ══════════════════════════════
/// 三种责任类型齐备,且每一种都有面向业务的文案(页面不出现技术枚举)。
[Fact]
public void Catalog_HasThreePoolsWithBusinessWording()
{
Assert.Equal(3, S8ResponsibilityCatalog.All.Count);
Assert.Equal(
new[] { S8ResponsibilityType.Handler, S8ResponsibilityType.Reviewer, S8ResponsibilityType.Escalation },
S8ResponsibilityCatalog.All.OrderBy(x => x.OrderNo).Select(x => x.Code));
foreach (var d in S8ResponsibilityCatalog.All)
{
Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
Assert.False(string.IsNullOrWhiteSpace(d.Description));
// 业务文案里不得出现技术术语
foreach (var banned in new[] { "Role", "ROLE_", "ApproverType", "Pool", "SysUser" })
Assert.DoesNotContain(banned, d.DisplayName + d.Description);
}
}
///
/// T1/T2/T3 存储路由:处理池仍走存量表,复核 / 升级走新表。
/// 这条断言锁的是「不为了表结构好看去迁已经稳定的处理池」这个决定 ——
/// 迁它要动就绪门禁 / 认领资格 / 可见性收敛 / 通知解析四条已跑通的链路。
///
[Fact]
public void Storage_HandlerStaysInLegacyTable_ReviewerAndEscalationInNewTable()
{
Assert.False(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Handler));
Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Reviewer));
Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Escalation));
var code = PoolService();
// HANDLER 读写都路由到存量实现,不在本服务里复制第二份处理池逻辑
Assert.Contains("_handlerReader.GetMemberIdsAsync", code);
Assert.Contains("_handlerWriter.SetMembersAsync", code);
}
/// T1–T3 写入:三类共用同一个写入口,成员判据同源。
[Fact]
public void T1_T2_T3_AllThreePools_ShareOneWriteEntryAndOneValidator()
{
var code = PoolService();
Assert.Contains("public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, string responsibilityType", code);
Assert.Contains("_userScope.ValidateUsersAsync", code);
// 未知类型不落库
Assert.Contains("未知的责任类型", Read("Service/S8/S8RuleResponsibilityPoolService.cs"));
}
// ══════════════════════════════ 租户与账号 ══════════════════════════════
///
/// T4/T5/T6 跨租户成员必须被拒;T7 停用账号同样被拒。
/// 判据统一走 S8UserScopeValidator(同租户 + 启用),
/// 且跨租户与不存在共用同一文案,不泄漏存在性。
///
[Fact]
public void T4_T7_CrossTenantAndInactiveMembers_AreRejectedWithSameMessage()
{
var code = PoolService();
Assert.Contains("var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted);", code);
Assert.Contains("throw new S8BizException(\"所选账号不存在或已停用\")", code);
// 池自己不写用户查询 —— 各写一套判据正是"配得进池、运行时又被拒"的来源
Assert.DoesNotContain("Queryable", code);
}
/// T8 工厂不参与责任池;T9 不出现员工主数据。
[Theory]
[InlineData("Service/S8/S8RuleResponsibilityPoolService.cs")]
[InlineData("Entity/S8/AdoS8RuleResponsibilityUser.cs")]
[InlineData("Infrastructure/S8/S8VerifierEligibility.cs")]
public void T8_T9_NoFactoryNoEmployee(string relativePath)
{
var code = CodeOnly(relativePath);
Assert.DoesNotContain("FactoryId", code);
Assert.DoesNotContain("factory_id", code);
Assert.DoesNotContain("EmployeeMaster", code);
Assert.DoesNotContain("EmployeeId", code);
}
///
/// T26 租户隔离:每一条池查询都带 tenant 谓词。
/// 读路径用 tenantId 参数、写路径用 scope.TenantId(服务端盖章),
/// 两种写法都算数;缺任何一条就是跨租户读写。
///
[Fact]
public void T26_EveryPoolQuery_CarriesTenantPredicate()
{
var code = PoolService();
var queries = code.Split("AsQueryable()").Skip(1).ToList();
Assert.NotEmpty(queries);
foreach (var q in queries)
{
var head = q[..Math.Min(q.Length, 400)];
Assert.True(head.Contains("x.TenantId == tenantId", StringComparison.Ordinal)
|| head.Contains("x.TenantId == scope.TenantId", StringComparison.Ordinal),
$"存在没有租户谓词的池查询:{head[..Math.Min(head.Length, 160)]}");
}
}
// ══════════════════════════════ 就绪门禁 ══════════════════════════════
///
/// T10/T11/T12 三条判据由各自的声明驱动,互不捆绑。
///
/// 拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
/// 再叠加复核 / 升级两条判据只会把耦合放大成三重。
///
[Fact]
public void T10_T11_T12_EachReadinessCheck_HasItsOwnDeclaration()
{
var code = ReadinessGate();
Assert.Contains("if (policy.RequiresDepartmentDefaultsForEnable)", code);
Assert.Contains("if (policy.RequiresHandlerPoolForEnable)", code);
Assert.Contains("if (policy.RequiresVerification)", code);
Assert.Contains("if (policy.SupportsTimeoutEscalation)", code);
// 各自独立的原因码 —— 管理员必须知道缺的是哪个池
Assert.Contains("HandlerPoolEmpty", code);
Assert.Contains("ReviewerPoolEmpty", code);
Assert.Contains("EscalationPoolEmpty", code);
}
///
/// 空池判据是「有没有当前有效的成员」,不是「表里有没有行」。
/// 账号停用后那一行还在,但那个人已经不算数了。只数行数会放行一条实际上没人负责的规则。
///
[Fact]
public void ReadinessGate_JudgesByValidMembers_NotRowCount()
{
var code = ReadinessGate();
Assert.Contains("members.Any(m => m.Valid)", code);
// 「一个都没配」与「配了但都失效」分开报:管理员的下一步动作完全不同
Assert.Contains("members.Count == 0 ? emptyMessage : allInvalidMessage", code);
}
/// Rule 01 显式声明三条判据;处理池那条与拆分前行为一致,不顺带改变启用条件。
[Fact]
public void Rule01_DeclaresAllThreeResponsibilities()
{
var def = Read("Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs");
Assert.Contains("RequiresHandlerPoolForEnable = true", def);
Assert.Contains("RequiresVerification = true", def);
Assert.Contains("SupportsTimeoutEscalation = true", def);
}
///
/// T23 空池绝不回落到角色。
/// 回落会让「我没给这条规则配人」与「有人在负责」同时成立 —— 最难查的一类错配。
///
[Fact]
public void T23_EmptyPool_NeverFallsBackToAnyRole()
{
foreach (var code in new[] { ReadinessGate(), Escalation(), VerifierEligibility() })
{
Assert.DoesNotContain("ROLE_QC", code);
Assert.DoesNotContain("EscalateRoleCode", code);
Assert.DoesNotContain("escalate_role_code", code);
}
}
/// T24 L1/L2/L3 与虚拟主管层级不得成为当前 authority。
[Fact]
public void T24_NoVirtualHierarchyAsCurrentAuthority()
{
foreach (var rel in new[]
{
"Service/S8/S8RuleResponsibilityPoolService.cs",
"Infrastructure/S8/S8VerifierEligibility.cs",
"Service/S8/S8TimeoutAutoEscalationService.cs",
"Const/S8/S8ResponsibilityCatalog.cs",
})
{
var code = CodeOnly(rel);
foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "DepartmentLeader", "DirectorId", "ManagerUserId" })
Assert.DoesNotContain(banned, code);
}
}
// ══════════════════════════════ 复核契约 ══════════════════════════════
///
/// T13/T14/T15 复核人资格 = 复核池 ∩ 审核权限 ∩ 可用账号,三者缺一不可。
///
[Fact]
public void T13_T14_T15_VerifierMustSatisfyAllThreeConditions()
{
var code = VerifierEligibility();
Assert.Contains("_userScope.FindActiveUserAsync(tenantId, verifierUserId)", code);
Assert.Contains("S8ResponsibilityType.Reviewer, verifierUserId", code);
Assert.Contains("S8ExceptionActionCode.Verify", code);
Assert.Contains("NotInReviewerPool", code);
Assert.Contains("NoActionPermission", code);
Assert.Contains("NotUsable", code);
}
///
/// 候选列表与提交校验同一份实现。
/// 两边各写一套,就会出现「页面能选、提交被拒」或更糟的「页面选甲、后端放行乙」。
///
[Fact]
public void VerifierCandidates_AndSubmitCheck_ShareOneImplementation()
{
var eligibility = VerifierEligibility();
Assert.Contains("Task CheckAsync", eligibility);
Assert.Contains("Task> ListCandidatesAsync", eligibility);
// 提交复核只调这一个门,不再自己拼权限判据
var flow = TaskFlow();
Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", flow);
Assert.DoesNotContain(
"_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", flow);
}
/// T15 绕过 UI 直接调 API 注入池外 verifier 同样被拒 —— 门在服务端。
[Fact]
public void T15_ApiInjectedOutsiderVerifier_IsRejectedServerSide()
{
var flow = TaskFlow();
var idx = flow.IndexOf("SubmitVerificationAsync(", StringComparison.Ordinal);
Assert.True(idx > 0);
var body = flow[idx..(idx + 1800)];
// 资格校验必须发生在状态迁移与落库之前
var checkIdx = body.IndexOf("_verifierEligibility.CheckAsync", StringComparison.Ordinal);
var mutateIdx = body.IndexOf("e.VerifierUserId = verifier.UserId", StringComparison.Ordinal);
Assert.True(checkIdx > 0 && mutateIdx > 0);
Assert.True(checkIdx < mutateIdx, "复核人资格必须在写入 verifier_user_id 之前判定");
}
///
/// T16 页面选定的检验人 == 审批任务的实际处理人。
///
/// 这是本批修的核心错位:ResolveApprovers 只看流程定义节点,
/// 完全不读 BizData,提交复核时传的 verifierUserId 从未参与审批人解析。
/// 当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。
///
[Fact]
public void T16_SelectedVerifier_BecomesTheActualFlowTaskAssignee()
{
var code = ClosureHandler();
Assert.Contains("AlignTaskToSelectedVerifierAsync", code);
Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", code);
Assert.Contains("keep.AssigneeId = verifierId;", code);
// 对齐必须发生在 OnFlowStarted 里 —— StartFlow 的顺序是先建任务再回调
var started = code.IndexOf("OnFlowStarted(long bizId", StringComparison.Ordinal);
var align = code.IndexOf("await AlignTaskToSelectedVerifierAsync", StringComparison.Ordinal);
Assert.True(started > 0 && align > started);
// 多余待办必须删除:留着等于池外的人仍能点通过,而 S8 侧校验又会拒绝他
Assert.Contains("AsDeleteable().Where(t => drop.Contains(t.Id))", code);
}
/// 对齐失败不回滚已提交的复核,但必须留下可查的日志 —— 静默失败是本模块的老病。
[Fact]
public void TaskAlignmentFailure_IsLoggedNotSwallowed()
{
var code = ClosureHandler();
Assert.Contains("s8_verify_task_aligned", code);
Assert.Contains("s8_verify_task_align_failed", code);
}
/// 不改共用的 FlowEngine —— 它是 S1/S5/S6/S7 共用的平台组件。
[Fact]
public void FlowEngine_IsNotModifiedByThisBatch()
{
var engine = Path.GetFullPath(Path.Combine(
PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
Assert.True(File.Exists(engine));
var code = File.ReadAllText(engine);
// 没有为 S8 加 BizData 动态审批人分支
Assert.DoesNotContain("verifierUserId", code);
Assert.DoesNotContain("S8ResponsibilityType", code);
}
// ══════════════════════════════ 升级契约 ══════════════════════════════
///
/// T19 超时升级按该规则的升级账号池解析,不再用 exception_type.escalate_role_code。
///
/// 旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上
/// (原实现注释已自陈)。责任池天然带 tenant_id,不存在这条越界路径。
///
[Fact]
public void T19_Escalation_ResolvesExactlyTheRuleEscalationPool()
{
var code = Escalation();
Assert.Contains("S8ResponsibilityType.Escalation", code);
Assert.Contains("_pools.GetValidMemberIdsAsync", code);
// 旧 authority 已彻底移除(含类型仓储)
Assert.DoesNotContain("EscalateRoleCode", code);
Assert.DoesNotContain("AdoS8ExceptionType", code);
Assert.DoesNotContain("_typeRep", code);
}
/// 空升级池 fail closed:跳过并落日志,不回落、不猜。
[Fact]
public void EmptyEscalationPool_SkipsLoudlyInsteadOfGuessing()
{
var code = Escalation();
Assert.Contains("reason=escalation_pool_empty", code);
Assert.Contains("if (escalationPool.Count == 0)", code);
// 人工提报没有来源规则,不参与规则级自动升级
Assert.Contains("reason=no_source_rule", code);
}
// ══════════════════════════════ 通知推导 ══════════════════════════════
///
/// T20/T21/T22 通知对象由责任关系推导,业务用户不选技术枚举。
///
[Fact]
public void T20_T21_T22_RecipientsAreDerivedFromResponsibilities()
{
Assert.Equal(S8RecipientType.HandlerPool,
S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionCreated));
Assert.Equal(S8RecipientType.Reviewer,
S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.VerificationSubmitted));
Assert.Equal(S8RecipientType.EscalationPool,
S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.EscalationTriggered));
Assert.Equal(S8RecipientType.Assignee,
S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionClaimed));
// 转派后 assignee_user_id 已经是新处理人,不需要额外的收件人类型
Assert.Equal(S8RecipientType.Assignee,
S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionTransferred));
}
///
/// 不制造不存在的事件:目录里每个事件都必须有真实派发点,
/// 且每个事件都必须有默认收件人推导。
///
[Fact]
public void EveryCatalogEvent_HasBothADispatchSiteAndADerivedRecipient()
{
var serviceDir = Path.Combine(PluginRoot, "Service", "S8");
var allCode = string.Join('\n', Directory.GetFiles(serviceDir, "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText));
foreach (var e in S8NotificationCatalog.Events)
{
var member = e.Code.Split('_')
.Select(p => char.ToUpperInvariant(p[0]) + p[1..].ToLowerInvariant())
.Aggregate(string.Empty, (a, b) => a + b);
Assert.True(allCode.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
$"事件 {e.Code} 在 Service/S8 下没有任何派发点 —— 配了却永远不触发的通知比没有通知更难排查");
Assert.NotNull(S8NotificationCatalog.ResolveDefaultRecipientType(e.Code));
}
}
/// 「复核通过 / 复核退回」刻意缺席 —— 代码里没有这两个派发点。
[Fact]
public void NonExistentVerificationEvents_AreNotInvented()
{
Assert.DoesNotContain("VERIFICATION_APPROVED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
Assert.DoesNotContain("VERIFICATION_REJECTED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
}
///
/// 通知里的 REVIEWER 不再解析审批流节点。
/// 审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
/// 且会让通知链随流程节点结构悄悄变化。
///
[Fact]
public void NotificationReviewer_NoLongerParsesApprovalFlowNodes()
{
var code = NotifyResolver();
Assert.DoesNotContain("ResolveFlowApproversAsync", code);
Assert.DoesNotContain("_reviewSource", code);
Assert.Contains("S8ResponsibilityType.Reviewer", code);
Assert.Contains("S8ResponsibilityType.Escalation", code);
}
/// 通知开关写入口只接布尔,收件人类型由目录推导 —— 页面不选技术枚举。
[Fact]
public void NotificationToggle_TakesOnlyABooleanFromTheUi()
{
var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
Assert.Contains("SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled)", svc);
Assert.Contains("S8NotificationCatalog.ResolveDefaultRecipientType(eventCode)", svc);
var dto = Read("Controllers/S8/AdoS8ConfigWatchRulesController.cs");
Assert.Contains("class AdoS8NotifyEventToggleDto", dto);
}
// ══════════════════════════════ 回归 ══════════════════════════════
/// T25 First Claim Wins 未被本批改动:仍是一条带前置条件的原子 UPDATE。
[Fact]
public void T25_FirstClaimWins_IsUnchanged()
{
var code = TaskFlow();
Assert.Contains("x.Status == fromStatus", code);
Assert.Contains("x.AssigneeUserId == null", code);
Assert.Contains("该异常已被其他人员认领", code);
// 认领资格仍是 动作权限 ∩ 处理池
Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", code);
}
/// 迁移不绑定任何具体账号或角色 —— 部署后三个池初始为空,由管理员显式配置。
[Fact]
public void Migration_BindsNoConcreteAccountsOrRoles()
{
var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql")));
Assert.DoesNotContain("INSERT INTO `ado_s8_rule_responsibility_user`", sql);
Assert.DoesNotContain("ROLE_", sql);
Assert.DoesNotContain("DELIMITER", sql); // AutoVersionUpdate 会整份拒绝含该词的脚本
// 唯一索引断言按**列成员**判定,不是只看索引名是否存在(1.0.496 踩过这个坑)
Assert.Contains("COLUMN_NAME IN ('tenant_id','rule_code','responsibility_type','user_id')", sql);
Assert.Contains("s8_503_BLOCKED_cross_tenant_member", sql);
Assert.Contains("s8_503_BLOCKED_unknown_responsibility_type", sql);
}
}