using Admin.NET.Plugin.AiDOP.DataPlatform.Executors; using SqlSugar; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// 入站抽数(MdpDbPullExecutor)的租户/工厂作用域契约。 /// /// 缺陷背景:旧谓词 COALESCE(NULLIF(factory_id,0),1) = @scopeFactoryId 把 /// factory_id==tenant_id 当成了一个真实工厂号。而 S1MdpScopeCatalog 给每个租户发的基线作用域是 /// factoryId=1,于是 b_examine_result(660/660 行) / b_bom_child_examine(17600/17600 行) /// 这类"工厂号写成租户号"的源表永远匹配不上,长期 rows_read=0 却记 status=SUCCESS。 /// /// 修复口径:与写入侧 MdpStagingWriter.NormalizeFactoryId 同源—— /// NULL / <=0 / ==tenant_id 一律归一为"无真实工厂"→默认工厂 1;真实工厂号保持逐值隔离。 /// public class MdpDbPullScopeContractTests { // 取自共享库 aidopdev 的真实作用域值 private const long TenantA = 797403760988229; // b_examine_result 508 行 / b_bom_child_examine 17228 行 private const long TenantB = 838257186181189; // b_examine_result 152 行 / b_bom_child_examine 372 行 private const long TenantC = 824585161322565; // ic_item / srm_purchase 走真实工厂 2410 private const long RealFactoryA = 8010; // ic_item + srm_purchase 的真实工厂 private const long RealFactoryB = 2410; private const long DefaultFactory = 1; // ---------- 归一语义:与写入侧严格同源 ---------- [Fact] public void Resolve_IsExactlyWriterNormalizationWithDefaultFallback() { // 与 MdpStagingWriter 的 `factoryValue is > 0 ? factoryValue.Value : 1` 完全一致, // 包含 null / 0 / 负数 / 等于租户号 / 真实工厂 五种输入。 long?[] samples = [null, 0, -1, -8010, TenantA, DefaultFactory, RealFactoryA, RealFactoryB]; foreach (var sample in samples) { var expected = MdpStagingWriter.NormalizeFactoryId(TenantA, sample) ?? 1; Assert.Equal(expected, MdpFactoryScope.Resolve(TenantA, sample)); } } [Theory] // TENANT_AS_FACTORY:factory_id 写成 tenant_id —— 必须归一到默认工厂 1 [InlineData(TenantA, TenantA, DefaultFactory)] [InlineData(TenantB, TenantB, DefaultFactory)] // 无工厂的各种写法 [InlineData(TenantA, null, DefaultFactory)] [InlineData(TenantA, 0L, DefaultFactory)] // FACTORY_SCOPED:真实工厂号原样保留 [InlineData(TenantA, RealFactoryA, RealFactoryA)] [InlineData(TenantC, RealFactoryB, RealFactoryB)] public void Resolve_NormalizesTenantAsFactoryButKeepsRealFactories(long tenantId, long? rowFactoryId, long expected) { Assert.Equal(expected, MdpFactoryScope.Resolve(tenantId, rowFactoryId)); } // ---------- TENANT_AS_FACTORY 源(b_examine_result / b_bom_child_examine,entity 47/48)---------- [Fact] public void TenantAsFactorySource_IsPulledUnderTheDefaultFactoryScope() { // 修复前:COALESCE(NULLIF(tenantId,0),1) = 797403760988229 ≠ 1 → 0 行 Assert.NotEqual(DefaultFactory, LegacyResolve(TenantA)); // 修复后:命中默认工厂作用域 → 660/17600 行全部可拉 Assert.True(MdpFactoryScope.Matches(TenantA, DefaultFactory, TenantA)); Assert.True(MdpFactoryScope.Matches(TenantB, DefaultFactory, TenantB)); } [Fact] public void TenantAsFactorySource_DoesNotLeakAcrossTenants() { // 租户 A 的行(factory_id=TenantA)在租户 B 的作用域里不是"无工厂", // 会被解析成一个真实工厂号 TenantA,从而不落进 B 的默认工厂作用域。 Assert.Equal(TenantA, MdpFactoryScope.Resolve(TenantB, TenantA)); Assert.False(MdpFactoryScope.Matches(TenantB, DefaultFactory, TenantA)); Assert.False(MdpFactoryScope.Matches(TenantA, DefaultFactory, TenantB)); // 而租户维度本身另有一道硬隔离:谓词必然带 tenant 等值。 var predicates = new List(); MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", "factory_id"); Assert.Contains("`tenant_id` = @scopeTenantId", predicates); } // ---------- FACTORY_SCOPED 源(ic_item / srm_purchase)---------- [Fact] public void RealFactoryScopedSource_StillIsolatesFactoryAFromFactoryB() { Assert.True(MdpFactoryScope.Matches(TenantA, RealFactoryA, RealFactoryA)); Assert.False(MdpFactoryScope.Matches(TenantA, RealFactoryB, RealFactoryA)); Assert.True(MdpFactoryScope.Matches(TenantC, RealFactoryB, RealFactoryB)); Assert.False(MdpFactoryScope.Matches(TenantC, RealFactoryA, RealFactoryB)); // 真实工厂行不会被默认工厂作用域误捞 Assert.False(MdpFactoryScope.Matches(TenantA, DefaultFactory, RealFactoryA)); } [Fact] public void RealFactoryScopedSource_BehaviourIsUnchangedByTheFix() { // 真实工厂(!=0、!=tenant)在新旧口径下解析结果必须完全一致 —— 修复不得改动 FACTORY_SCOPED 源。 foreach (var tenantId in new[] { TenantA, TenantB, TenantC }) foreach (var factoryId in new long?[] { RealFactoryA, RealFactoryB, DefaultFactory, null, 0 }) Assert.Equal(LegacyResolve(factoryId), MdpFactoryScope.Resolve(tenantId, factoryId)); } [Fact] public void SharedRealFactoryAcrossTenants_IsSeparatedByTheTenantPredicate() { // ic_item 里租户 797…229 与 838…189 共用工厂 8010,工厂谓词本身分不开,靠租户谓词隔离。 Assert.True(MdpFactoryScope.Matches(TenantA, RealFactoryA, RealFactoryA)); Assert.True(MdpFactoryScope.Matches(TenantB, RealFactoryA, RealFactoryA)); var predicates = new List(); MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", "factory_id"); Assert.Equal(2, predicates.Count); Assert.Contains(predicates, p => p.Contains("@scopeTenantId") && p.Contains("`tenant_id`")); } // ---------- TENANT_ONLY 源(无 factory_id 列,占本库 68 张源表中的 58 张)---------- [Fact] public void TenantOnlySource_EmitsTenantPredicateOnly() { var predicates = new List(); MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", null); Assert.Single(predicates); Assert.DoesNotContain("@scopeFactoryId", predicates[0]); Assert.True(MdpDbPullExecutor.NeedsTenantParameter("tenant_id", null)); } [Fact] public void NoScopeColumns_EmitsNoPredicateAndBindsNoParameter() { var predicates = new List(); MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, null, null); Assert.Empty(predicates); Assert.False(MdpDbPullExecutor.NeedsTenantParameter(null, null)); } // ---------- 谓词形状与参数绑定 ---------- [Fact] public void FactoryPredicate_NormalizesZeroNullAndTenantAsFactory() { var predicates = new List(); MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", "factory_id"); var factoryPredicate = Assert.Single(predicates, p => p.Contains("@scopeFactoryId")); // 归一三要素:>0 才算工厂(覆盖 NULL/0/负数)、等于租户号置空、最后回落 1 Assert.Contains("CASE WHEN `factory_id` > 0 THEN `factory_id` END", factoryPredicate); Assert.Contains("NULLIF(", factoryPredicate); Assert.Contains("@scopeTenantId", factoryPredicate); Assert.Contains(", 1)", factoryPredicate); // 旧的错误写法必须消失 Assert.DoesNotContain("COALESCE(NULLIF(`factory_id`, 0), 1)", factoryPredicate); } [Fact] public void FactoryPredicate_QuotesIdentifierPerDialect() { var mysql = new List(); MdpDbPullExecutor.AppendScopePredicates(mysql, isSqlServer: false, "tenant_id", "factory_id"); Assert.All(mysql, p => Assert.Contains("`", p)); var sqlServer = new List(); MdpDbPullExecutor.AppendScopePredicates(sqlServer, isSqlServer: true, "tenant_id", "factory_id"); Assert.All(sqlServer, p => Assert.DoesNotContain("`", p)); Assert.Contains(sqlServer, p => p.Contains("CASE WHEN factory_id > 0 THEN factory_id END")); } [Fact] public void TenantParameter_IsBoundEvenWhenOnlyTheFactoryColumnExists() { // 回归:工厂谓词引用 @scopeTenantId;源表只有 factory_id 而无 tenant_id 时, // 旧的 `if (tenantCol != null)` 绑定条件会漏绑参数,导致 SQL 执行失败。 Assert.True(MdpDbPullExecutor.NeedsTenantParameter(null, "factory_id")); var predicates = new List(); MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, null, "factory_id"); Assert.Single(predicates); Assert.Contains("@scopeTenantId", predicates[0]); } private static long LegacyResolve(long? factoryId) => factoryId is null or 0 ? 1 : factoryId.Value; } /// 零行假成功守卫:读到 0 行时必须能区分"源侧本就没有"与"全被作用域过滤掉"。 public class MdpDbPullZeroRowGuardTests { private static MdpPullContext Ctx(int offset = 0) => new() { TenantId = 797403760988229, FactoryId = 1, FullRefresh = true, Offset = offset }; [Fact] public void LegitimatelyEmptySource_IsPlainSuccess() { // dwd_material_shortage:共享库实测 0 行。探针同样取不到行 → 不告警。 Assert.False(MdpDbPullExecutor.IsScopeMismatch( rowsRead: 0, Ctx(), "tenant_id", "factory_id", sourceHasRowsOutsideScope: false)); } [Fact] public void AllFilteredByScope_IsFlaggedAsPartial() { // b_examine_result:源侧 660 行,作用域过滤后 0 行 → 必须告警,不得记无保留 SUCCESS。 Assert.True(MdpDbPullExecutor.IsScopeMismatch( rowsRead: 0, Ctx(), "tenant_id", "factory_id", sourceHasRowsOutsideScope: true)); } [Fact] public void NonEmptyRead_IsNeverFlagged() { Assert.False(MdpDbPullExecutor.IsScopeMismatch( rowsRead: 508, Ctx(), "tenant_id", "factory_id", sourceHasRowsOutsideScope: true)); } [Fact] public void SourceWithoutScopeColumns_IsNeverFlagged() { // 没有发射作用域谓词就没有"被作用域过滤"这回事,0 行只能是源侧真没有。 Assert.False(MdpDbPullExecutor.IsScopeMismatch( rowsRead: 0, Ctx(), null, null, sourceHasRowsOutsideScope: true)); Assert.False(MdpDbPullExecutor.ShouldProbeScope(0, Ctx(), null, null)); } [Fact] public void ExhaustedOffsetPage_IsNeverFlagged() { // 前一页已拉满作用域内数据、翻页到底的 0 行是正常的,探针(不带 OFFSET)会误报,故直接不探。 Assert.False(MdpDbPullExecutor.ShouldProbeScope(0, Ctx(offset: 5000), "tenant_id", "factory_id")); Assert.False(MdpDbPullExecutor.IsScopeMismatch( rowsRead: 0, Ctx(offset: 5000), "tenant_id", "factory_id", sourceHasRowsOutsideScope: true)); } [Fact] public void ScopeMismatch_UsesExistingPartialEnumMember_NotFailed() { // mdp_sync_log.status 既有 enum('RUNNING','SUCCESS','PARTIAL','FAILED'),无需改表。 Assert.Equal("PARTIAL", MdpDbPullExecutor.SyncStatusPartial); Assert.Equal("PARTIAL", MdpDbPullExecutor.ResolveSyncStatus( MdpDbPullExecutor.SyncStatusPartial, "SCOPE_MISMATCH: ...")); // 显式 status 缺省时仍沿用旧的 "有 error 即 FAILED" Assert.Equal("SUCCESS", MdpDbPullExecutor.ResolveSyncStatus(null, null)); Assert.Equal("FAILED", MdpDbPullExecutor.ResolveSyncStatus(null, "boom")); } /// /// 探针必须保留租户谓词、只去掉工厂谓词。 /// /// 此前两个作用域谓词一起剥掉,探针退化成 SELECT 1 FROM 源表 LIMIT 1。 /// 在多租户共享同一张源表的模型下,它对任何租户都会命中别的租户的行 —— /// 于是每个合法空租户都被误报成 SCOPE_MISMATCH(共享库上累计 291 条误报), /// 而真正被工厂谓词滤空的租户反而一条都没报出来,信号方向是反的。 /// /// 探针要回答的是「同一租户内,是否因工厂口径而取空」, /// 不是「整张表有没有数据」。 /// [Fact] public void ProbeSql_KeepsTenantPredicate_DropsOnlyFactoryPredicate() { var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity { SourceTableName = "b_examine_result", IncrColumn = "update_time", LastCursor = "2026-08-01 00:00:00.000" }; var ctx = new MdpPullContext { TenantId = 797403760988229, FactoryId = 1, FullRefresh = false }; var probe = MdpDbPullExecutor.BuildScopeProbeSql( entity, isSqlServer: false, ctx, useKeyset: false, tenantCol: "tenant_id"); Assert.Contains("`tenant_id` = @scopeTenantId", probe); // 租户维度必须保留 Assert.DoesNotContain("@scopeFactoryId", probe); // 工厂维度正是要去掉的那个 Assert.Contains("`update_time` > @cursor", probe); // 增量窗口保留,避免把"增量无新数据"误判为作用域异常 Assert.Contains("FROM b_examine_result", probe); Assert.Contains("LIMIT 1", probe); // 只做存在性判断 } /// 源表没有租户列时,探针自然不发射租户谓词——不得凭空造一个。 [Fact] public void ProbeSql_OmitsTenantPredicate_WhenSourceHasNoTenantColumn() { var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity { SourceTableName = "b_examine_result" }; var ctx = new MdpPullContext { TenantId = 797403760988229, FactoryId = 1 }; var probe = MdpDbPullExecutor.BuildScopeProbeSql( entity, isSqlServer: false, ctx, useKeyset: false, tenantCol: null); Assert.DoesNotContain("@scopeTenantId", probe); Assert.DoesNotContain("@scopeFactoryId", probe); } [Fact] public void ProbeSql_RejectsIllegalSourceTableName() { var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity { SourceTableName = "b_examine_result; DROP TABLE x" }; Assert.Throws(() => MdpDbPullExecutor.BuildScopeProbeSql(entity, isSqlServer: false, new MdpPullContext(), useKeyset: false)); } /// /// 参数剥离必须与探针 SQL 是否发射租户谓词严格一致: /// 带租户谓词就留 @scopeTenantId(否则参数缺失),不带就剔除(否则多绑一个 SQL 里没有的参数)。 /// @scopeFactoryId 两种情况下都剔除——探针的全部意义就是"去掉工厂口径再看一眼"。 /// [Fact] public void ProbeParameters_KeepTenantWhenProbeEmitsTenantPredicate() { var parameters = new List { new("@scopeTenantId", 797403760988229), new("@scopeFactoryId", 1), new("@cursor", "2026-08-01 00:00:00.000"), new("@windowFrom", DateTime.Parse("2026-08-01")) }; var kept = MdpDbPullExecutor.StripScopeParameters(parameters, keepTenantParameter: true); Assert.Equal(3, kept.Count); Assert.Contains(kept, p => p.ParameterName.Contains("scopeTenantId", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(kept, p => p.ParameterName.Contains("scopeFactoryId", StringComparison.OrdinalIgnoreCase)); var stripped = MdpDbPullExecutor.StripScopeParameters(parameters, keepTenantParameter: false); Assert.Equal(2, stripped.Count); Assert.DoesNotContain(stripped, p => p.ParameterName.Contains("scope", StringComparison.OrdinalIgnoreCase)); Assert.Contains(stripped, p => p.ParameterName.Contains("cursor", StringComparison.OrdinalIgnoreCase)); Assert.Contains(stripped, p => p.ParameterName.Contains("windowFrom", StringComparison.OrdinalIgnoreCase)); } }