using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow; /// /// S7-FQC-TENANT-AUTHORITY-1 源码契约测试:S7 成品检验审批权威**租户本地化**(仅未来新建)。 /// /// 修复的根因(2026-09-14 审计 EVIDENCE COMPLETE): /// - ROLE_S7_FQC_* 三个 Code 全库各只有一行、固定挂默认租户(对照 ROLE_S6_IPQC_* 已各 10 份); /// - 所有 S7 定义(57 条,V1–V3 全版本)的 N2/N3 都写死默认租户物理 RoleId /// 1329915020002 / 1329915020003; /// - 各租户靠 9 条跨租户 SysUserRole 绑定借用,其中 4 条被 Running 实例实际依赖。 /// /// 本批只关未来新建这条口子:租户本地角色 + 定义按租户播种 + N2/N3 改 RoleCode。 /// **不迁** 7 条 Running 的 FlowJsonSnapshot、**不删**跨租户绑定、**不开** TenantStrictRoleBizTypes /// (顺序必须是 local roles → future definition → active migration → strict guard)。 /// /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:seed 依赖 ISqlSugarClient, /// 裸 xUnit 进程无法实例化。真实效果由本批 Runtime R1–R10 覆盖。 /// public class S7TenantAuthorityContractTests { private const string SupervisorCode = "ROLE_S7_FQC_SUPERVISOR"; private const string QeCode = "ROLE_S7_FQC_QE"; private const string InspectorCode = "ROLE_S7_FQC_INSPECTOR"; private static string AuthoritySeed() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S7TenantAuthoritySeed.cs")); private static string FlowSeed() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "FqcInspBillFlowSeed.cs")); private static string FlowConst() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs")); private static string Engine() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs")); private static string FlowTaskService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs")); /// /// **去注释**后的 C# 源码视图。这些文件的注释里会成段引用被淘汰的写法 /// (如「原写 RoleSupervisorId=1329915020002」「历史实例各用自己的 FlowJsonSnapshot」)来解释 /// 为什么不再那么做;裸文本 DoesNotContain 会把说明文字当成违规实现命中。 /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。 /// private static string CsCode(string src) { var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline); var kept = s.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)); return string.Join("\n", kept); } private static string FlowJsonBody() => Slice(CsCode(FlowSeed()), "private static string BuildFlowJson()", "private static ApprovalFlowEdgeItem BuildEdge"); // ── §27-1 / §27-2:N2 / N3 权威必须是 RoleCode ── /// N2 检验主管节点必须用 RoleCode 常量,而不是任何数字 Id。 [Fact] public void DefinitionN2_UsesSupervisorRoleCode() { var n2 = Slice(FlowJsonBody(), "NodeName = \"检验主管审核\"", "Text = new FlowTextItem"); Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleSupervisorCode", n2); Assert.DoesNotContain("RoleSupervisorId", n2); Assert.DoesNotContain(".ToString()", n2); } /// N3 QE 处置节点同理。 [Fact] public void DefinitionN3_UsesQeRoleCode() { var n3 = Slice(FlowJsonBody(), "NodeName = \"QE处置\"", "Text = new FlowTextItem"); Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleQeCode", n3); Assert.DoesNotContain("RoleQeId", n3); Assert.DoesNotContain(".ToString()", n3); } /// RoleCode 常量的字面值必须与 DB 中既有 Code 完全一致(改字面量会让解析静默落空)。 [Theory] [InlineData("RoleInspectorCode", InspectorCode)] [InlineData("RoleSupervisorCode", SupervisorCode)] [InlineData("RoleQeCode", QeCode)] public void RoleCodeConstants_MatchExistingDbCodes(string constName, string expected) { Assert.Contains($"public const string {constName} = \"{expected}\"", FlowConst()); } // ── §27-3:定义里禁止出现默认租户物理 RoleId ── /// /// 三个 legacy 物理 RoleId 只能作为「默认租户既有固定 Id」出现在租户本地化种子里, /// **不得**出现在流程定义 JSON 的构造中。 /// [Theory] [InlineData("1329915020001")] [InlineData("1329915020002")] [InlineData("1329915020003")] public void DefinitionJson_ContainsNoLegacyPhysicalRoleId(string legacyId) { Assert.DoesNotContain(legacyId, FlowJsonBody()); // FlowJsonBody 已是去注释视图 } /// 定义构造中不得引用任何 RoleId 常量(即使是本租户的,也只能用 Code)。 [Theory] [InlineData("RoleInspectorId")] [InlineData("RoleSupervisorId")] [InlineData("RoleQeId")] public void DefinitionJson_ReferencesNoRoleIdConstant(string idConst) { Assert.DoesNotContain(idConst, FlowJsonBody()); } /// legacy 固定 Id 仍必须保留在常量与种子里——默认租户的历史 FlowJson 与本地角色靠它对齐。 [Fact] public void LegacyRoleIds_StillRetainedForDefaultTenantIdentity() { Assert.Contains("RoleSupervisorId = 1329915020002", FlowConst()); Assert.Contains("LegacyDefaultTenantRoleId", AuthoritySeed()); Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", AuthoritySeed()); } // ── §27-4 / §27-5:角色供给是租户本地的,同 Code 可跨租户共存 ── /// 查重键必须是 (TenantId, Code);用全局 Any(Code) 就是「全库单例」的成因。 [Fact] public void RoleProvisioning_DedupesByTenantAndCode() { var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership"); Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", body); Assert.Contains("TenantId = tenantId,", body); } /// /// 同一个 RoleCode 在不同租户必须是不同 RoleId:默认租户沿用固定 Id,其余走雪花号。 /// 若两处都用固定 Id 就会主键冲突/退化回单例。 /// [Fact] public void SameRoleCode_GetsDistinctRoleIdPerTenant() { var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership"); Assert.Contains("? spec.LegacyDefaultTenantRoleId", body); Assert.Contains(": YitIdHelper.NextId()", body); } /// 旧的全局 EnsureRole(单例成因)必须已从 S7 种子中移除。 [Fact] public void LegacyGlobalEnsureRole_IsRemovedFromFlowSeed() { var s = CsCode(FlowSeed()); Assert.Contains("S7TenantAuthoritySeed.EnsureTenantAuthority(db)", s); Assert.DoesNotContain("ClearFilter().Any(x => x.Code == code)", s); Assert.DoesNotContain("private static void EnsureRoles(", s); } /// 租户清单取自 SysTenant 注册表,禁止硬编码具体租户 Id / UAT 账号。 [Theory] [InlineData("797403760988229")] [InlineData("838257186181189")] [InlineData("838257212780613")] [InlineData("AIDOPDemo")] [InlineData("UATQualityA")] public void TenantAuthoritySeed_HardcodesNoTenantOrAccount(string forbidden) { Assert.DoesNotContain(forbidden, AuthoritySeed()); Assert.DoesNotContain(forbidden, FlowSeed()); } /// 租户来源必须是 SysTenant,而不是 SysOrg(按 Org 播种正是 57 条定义与租户错配的成因)。 [Fact] public void TenantSet_ComesFromSysTenantNotSysOrg() { Assert.Contains("db.Queryable()", AuthoritySeed()); var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId"); Assert.Contains("S7TenantAuthoritySeed.GetActivatableTenantIds(db)", publish); Assert.DoesNotContain("db.Queryable()", publish); Assert.DoesNotContain("foreach (var orgId in orgIds)", publish); } // ── §十一:定义租户必须等于目标租户 ── /// 新定义的 TenantId 必须就是目标租户,不得再经 Org→Tenant 间接解析。 [Fact] public void NewDefinition_TenantIdEqualsTargetTenant() { var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId"); Assert.Contains("TenantId = tenantId,", publish); Assert.DoesNotContain("ApprovalFlowTenantSeed.ResolveTenantId", publish); Assert.Contains("x.TenantId == tenantId", publish); } // ── §十二:历史定义不得被改动 ── /// 不得下架 / 改写 / 删除任何历史定义(原 v2→v3 升级会 SetColumns(IsPublished=false))。 [Fact] public void HistoricalDefinitions_AreNeitherUnpublishedNorRewritten() { var s = CsCode(FlowSeed()); Assert.DoesNotContain("x.IsPublished == false", s); Assert.DoesNotContain("Updateable", s); Assert.DoesNotContain("Deleteable", s); } /// 补发靠新版本号(既有最大 + 1),不原地改已被实例引用的定义。 [Fact] public void NewDefinition_UsesIncrementedVersion() { var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId"); Assert.Contains("Version = (existing?.Version ?? 0) + 1", publish); } /// 幂等判据是「已含 RoleCode」——重复启动不会反复补发。 [Fact] public void Seeding_IsIdempotentOnRoleCodePresence() { var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId"); Assert.Contains("Contains(FqcInspBillFlowConst.RoleSupervisorCode)", publish); } // ── §27-6:N1 仍是 Initiator ── /// 本批不得把 N1 改回 Inspector Role —— 业务设计是「认领人(发起人)」。 [Fact] public void DefinitionN1_RemainsInitiator() { var n1 = Slice(FlowJsonBody(), "NodeName = \"检验员检验\"", "Text = new FlowTextItem"); Assert.Contains("ApproverType = nameof(ApproverTypeEnum.Initiator)", n1); Assert.Contains("ApproverIds = \"\"", n1); } // ── §十四 / §十五:成员语义只复制既有事实,legacy 绑定保留 ── /// 成员复制必须是 COPY 而非 MOVE:不得删除任何既有绑定。 [Fact] public void MembershipCopy_NeverDeletesLegacyBinding() { Assert.DoesNotContain("Deleteable", CsCode(AuthoritySeed())); Assert.Contains("COPY 不是 MOVE", AuthoritySeed()); // 该约定必须写在注释里 } /// 只复制已存在的授权事实,不凭猜测新增业务成员。 [Fact] public void MembershipCopy_DerivesOnlyFromExistingBindings() { var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId"); Assert.Contains("ur.RoleId == spec.LegacyDefaultTenantRoleId", body); Assert.Contains("u.TenantId != SqlSugarConst.DefaultTenantId", body); // SysUserRole 无唯一键,必须自己防重 Assert.Contains("x.UserId == user.Id && x.RoleId == localRoleId.Value", body); } /// Activation Gate:三角色都有本地角色且各有同租户成员,才为该租户发布 RoleCode 定义。 [Fact] public void ActivationGate_RequiresLocalRoleWithSameTenantMember() { var body = Slice(AuthoritySeed(), "public static List GetActivatableTenantIds", "private static List ResolveTenantIds"); Assert.Contains("ResolveLocalRoleId(db, tenantId, spec.Code)", body); Assert.Contains("u.TenantId == tenantId", body); Assert.Contains("if (sameTenantMembers == 0) { allReady = false; break; }", body); } // ── §27-7 / §二十五:本批不得触碰历史实例 ── /// 种子与租户权威基线都不得读写 ApprovalFlowInstance / FlowJsonSnapshot。 [Theory] [InlineData("ApprovalFlowInstance")] [InlineData("FlowJsonSnapshot")] [InlineData("InitiatorId")] public void Seeding_NeverTouchesExistingInstances(string forbidden) { Assert.DoesNotContain(forbidden, CsCode(AuthoritySeed())); Assert.DoesNotContain(forbidden, CsCode(FlowSeed())); } // ── §二十八 / §二十九:回归与顺序 ── /// S7 通用审批旁路守卫必须仍然生效(上一批 CLOSED,不得被本批影响)。 [Fact] public void GenericApprovalGuard_StillCoversS7() { var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};"); Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict); } /// /// §29:本批**不得**把 S7 加入 TenantStrictRoleBizTypes —— /// 7 条 Running 仍是 legacy 物理 RoleId 快照,先开守卫会当场把它们全部拦死。 /// 正确顺序:local roles → future definition RoleCode → active migration → strict guard。 /// [Fact] public void StrictRoleGuard_NotYetOpenedForS7() { var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};"); Assert.DoesNotContain("S7_FQC_INSPBILL", set); Assert.Contains("\"IPQC_INSPECTION\"", set); Assert.Contains("\"S6_PROCESS_INSPECTION\"", set); } /// RoleCode 解析必须是租户内解析(这是 RoleCode 方案成立的前提)。 [Fact] public void RoleCodeResolver_IsTenantScoped() { var body = Slice(Engine(), "if (codes.Count > 0)", "if (ids.Count == 0)"); Assert.Contains("r.TenantId == effectiveTenantId", body); Assert.Contains("r.Status == StatusEnum.Enable", body); Assert.Contains("codes.Contains(r.Code)", body); } /// 设计代次常量已升到 v4,且明确它不等于 ApprovalFlow.Version。 [Fact] public void FlowDefinitionVersion_BumpedAndDocumented() { var s = FlowConst(); Assert.Contains("public const int FlowDefinitionVersion = 4;", s); Assert.Contains("不等于", s); } private static string Slice(string src, string from, string to) { var a = src.IndexOf(from, StringComparison.Ordinal); Assert.True(a >= 0, $"未找到起点:{from}"); var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal); return b > a ? src[a..b] : src[a..]; } private static string FindFile(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(string.Join("/", parts)); } }