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