MdpDbPullScopeContractTests.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  2. using SqlSugar;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  5. /// <summary>
  6. /// 入站抽数(MdpDbPullExecutor)的租户/工厂作用域契约。
  7. ///
  8. /// 缺陷背景:旧谓词 <c>COALESCE(NULLIF(factory_id,0),1) = @scopeFactoryId</c> 把
  9. /// factory_id==tenant_id 当成了一个真实工厂号。而 S1MdpScopeCatalog 给每个租户发的基线作用域是
  10. /// factoryId=1,于是 b_examine_result(660/660 行) / b_bom_child_examine(17600/17600 行)
  11. /// 这类"工厂号写成租户号"的源表永远匹配不上,长期 rows_read=0 却记 status=SUCCESS。
  12. ///
  13. /// 修复口径:与写入侧 MdpStagingWriter.NormalizeFactoryId 同源——
  14. /// NULL / &lt;=0 / ==tenant_id 一律归一为"无真实工厂"→默认工厂 1;真实工厂号保持逐值隔离。
  15. /// </summary>
  16. public class MdpDbPullScopeContractTests
  17. {
  18. // 取自共享库 aidopdev 的真实作用域值
  19. private const long TenantA = 797403760988229; // b_examine_result 508 行 / b_bom_child_examine 17228 行
  20. private const long TenantB = 838257186181189; // b_examine_result 152 行 / b_bom_child_examine 372 行
  21. private const long TenantC = 824585161322565; // ic_item / srm_purchase 走真实工厂 2410
  22. private const long RealFactoryA = 8010; // ic_item + srm_purchase 的真实工厂
  23. private const long RealFactoryB = 2410;
  24. private const long DefaultFactory = 1;
  25. // ---------- 归一语义:与写入侧严格同源 ----------
  26. [Fact]
  27. public void Resolve_IsExactlyWriterNormalizationWithDefaultFallback()
  28. {
  29. // 与 MdpStagingWriter 的 `factoryValue is > 0 ? factoryValue.Value : 1` 完全一致,
  30. // 包含 null / 0 / 负数 / 等于租户号 / 真实工厂 五种输入。
  31. long?[] samples = [null, 0, -1, -8010, TenantA, DefaultFactory, RealFactoryA, RealFactoryB];
  32. foreach (var sample in samples)
  33. {
  34. var expected = MdpStagingWriter.NormalizeFactoryId(TenantA, sample) ?? 1;
  35. Assert.Equal(expected, MdpFactoryScope.Resolve(TenantA, sample));
  36. }
  37. }
  38. [Theory]
  39. // TENANT_AS_FACTORY:factory_id 写成 tenant_id —— 必须归一到默认工厂 1
  40. [InlineData(TenantA, TenantA, DefaultFactory)]
  41. [InlineData(TenantB, TenantB, DefaultFactory)]
  42. // 无工厂的各种写法
  43. [InlineData(TenantA, null, DefaultFactory)]
  44. [InlineData(TenantA, 0L, DefaultFactory)]
  45. // FACTORY_SCOPED:真实工厂号原样保留
  46. [InlineData(TenantA, RealFactoryA, RealFactoryA)]
  47. [InlineData(TenantC, RealFactoryB, RealFactoryB)]
  48. public void Resolve_NormalizesTenantAsFactoryButKeepsRealFactories(long tenantId, long? rowFactoryId, long expected)
  49. {
  50. Assert.Equal(expected, MdpFactoryScope.Resolve(tenantId, rowFactoryId));
  51. }
  52. // ---------- TENANT_AS_FACTORY 源(b_examine_result / b_bom_child_examine,entity 47/48)----------
  53. [Fact]
  54. public void TenantAsFactorySource_IsPulledUnderTheDefaultFactoryScope()
  55. {
  56. // 修复前:COALESCE(NULLIF(tenantId,0),1) = 797403760988229 ≠ 1 → 0 行
  57. Assert.NotEqual(DefaultFactory, LegacyResolve(TenantA));
  58. // 修复后:命中默认工厂作用域 → 660/17600 行全部可拉
  59. Assert.True(MdpFactoryScope.Matches(TenantA, DefaultFactory, TenantA));
  60. Assert.True(MdpFactoryScope.Matches(TenantB, DefaultFactory, TenantB));
  61. }
  62. [Fact]
  63. public void TenantAsFactorySource_DoesNotLeakAcrossTenants()
  64. {
  65. // 租户 A 的行(factory_id=TenantA)在租户 B 的作用域里不是"无工厂",
  66. // 会被解析成一个真实工厂号 TenantA,从而不落进 B 的默认工厂作用域。
  67. Assert.Equal(TenantA, MdpFactoryScope.Resolve(TenantB, TenantA));
  68. Assert.False(MdpFactoryScope.Matches(TenantB, DefaultFactory, TenantA));
  69. Assert.False(MdpFactoryScope.Matches(TenantA, DefaultFactory, TenantB));
  70. // 而租户维度本身另有一道硬隔离:谓词必然带 tenant 等值。
  71. var predicates = new List<string>();
  72. MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", "factory_id");
  73. Assert.Contains("`tenant_id` = @scopeTenantId", predicates);
  74. }
  75. // ---------- FACTORY_SCOPED 源(ic_item / srm_purchase)----------
  76. [Fact]
  77. public void RealFactoryScopedSource_StillIsolatesFactoryAFromFactoryB()
  78. {
  79. Assert.True(MdpFactoryScope.Matches(TenantA, RealFactoryA, RealFactoryA));
  80. Assert.False(MdpFactoryScope.Matches(TenantA, RealFactoryB, RealFactoryA));
  81. Assert.True(MdpFactoryScope.Matches(TenantC, RealFactoryB, RealFactoryB));
  82. Assert.False(MdpFactoryScope.Matches(TenantC, RealFactoryA, RealFactoryB));
  83. // 真实工厂行不会被默认工厂作用域误捞
  84. Assert.False(MdpFactoryScope.Matches(TenantA, DefaultFactory, RealFactoryA));
  85. }
  86. [Fact]
  87. public void RealFactoryScopedSource_BehaviourIsUnchangedByTheFix()
  88. {
  89. // 真实工厂(!=0、!=tenant)在新旧口径下解析结果必须完全一致 —— 修复不得改动 FACTORY_SCOPED 源。
  90. foreach (var tenantId in new[] { TenantA, TenantB, TenantC })
  91. foreach (var factoryId in new long?[] { RealFactoryA, RealFactoryB, DefaultFactory, null, 0 })
  92. Assert.Equal(LegacyResolve(factoryId), MdpFactoryScope.Resolve(tenantId, factoryId));
  93. }
  94. [Fact]
  95. public void SharedRealFactoryAcrossTenants_IsSeparatedByTheTenantPredicate()
  96. {
  97. // ic_item 里租户 797…229 与 838…189 共用工厂 8010,工厂谓词本身分不开,靠租户谓词隔离。
  98. Assert.True(MdpFactoryScope.Matches(TenantA, RealFactoryA, RealFactoryA));
  99. Assert.True(MdpFactoryScope.Matches(TenantB, RealFactoryA, RealFactoryA));
  100. var predicates = new List<string>();
  101. MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", "factory_id");
  102. Assert.Equal(2, predicates.Count);
  103. Assert.Contains(predicates, p => p.Contains("@scopeTenantId") && p.Contains("`tenant_id`"));
  104. }
  105. // ---------- TENANT_ONLY 源(无 factory_id 列,占本库 68 张源表中的 58 张)----------
  106. [Fact]
  107. public void TenantOnlySource_EmitsTenantPredicateOnly()
  108. {
  109. var predicates = new List<string>();
  110. MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", null);
  111. Assert.Single(predicates);
  112. Assert.DoesNotContain("@scopeFactoryId", predicates[0]);
  113. Assert.True(MdpDbPullExecutor.NeedsTenantParameter("tenant_id", null));
  114. }
  115. [Fact]
  116. public void NoScopeColumns_EmitsNoPredicateAndBindsNoParameter()
  117. {
  118. var predicates = new List<string>();
  119. MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, null, null);
  120. Assert.Empty(predicates);
  121. Assert.False(MdpDbPullExecutor.NeedsTenantParameter(null, null));
  122. }
  123. // ---------- 谓词形状与参数绑定 ----------
  124. [Fact]
  125. public void FactoryPredicate_NormalizesZeroNullAndTenantAsFactory()
  126. {
  127. var predicates = new List<string>();
  128. MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, "tenant_id", "factory_id");
  129. var factoryPredicate = Assert.Single(predicates, p => p.Contains("@scopeFactoryId"));
  130. // 归一三要素:>0 才算工厂(覆盖 NULL/0/负数)、等于租户号置空、最后回落 1
  131. Assert.Contains("CASE WHEN `factory_id` > 0 THEN `factory_id` END", factoryPredicate);
  132. Assert.Contains("NULLIF(", factoryPredicate);
  133. Assert.Contains("@scopeTenantId", factoryPredicate);
  134. Assert.Contains(", 1)", factoryPredicate);
  135. // 旧的错误写法必须消失
  136. Assert.DoesNotContain("COALESCE(NULLIF(`factory_id`, 0), 1)", factoryPredicate);
  137. }
  138. [Fact]
  139. public void FactoryPredicate_QuotesIdentifierPerDialect()
  140. {
  141. var mysql = new List<string>();
  142. MdpDbPullExecutor.AppendScopePredicates(mysql, isSqlServer: false, "tenant_id", "factory_id");
  143. Assert.All(mysql, p => Assert.Contains("`", p));
  144. var sqlServer = new List<string>();
  145. MdpDbPullExecutor.AppendScopePredicates(sqlServer, isSqlServer: true, "tenant_id", "factory_id");
  146. Assert.All(sqlServer, p => Assert.DoesNotContain("`", p));
  147. Assert.Contains(sqlServer, p => p.Contains("CASE WHEN factory_id > 0 THEN factory_id END"));
  148. }
  149. [Fact]
  150. public void TenantParameter_IsBoundEvenWhenOnlyTheFactoryColumnExists()
  151. {
  152. // 回归:工厂谓词引用 @scopeTenantId;源表只有 factory_id 而无 tenant_id 时,
  153. // 旧的 `if (tenantCol != null)` 绑定条件会漏绑参数,导致 SQL 执行失败。
  154. Assert.True(MdpDbPullExecutor.NeedsTenantParameter(null, "factory_id"));
  155. var predicates = new List<string>();
  156. MdpDbPullExecutor.AppendScopePredicates(predicates, isSqlServer: false, null, "factory_id");
  157. Assert.Single(predicates);
  158. Assert.Contains("@scopeTenantId", predicates[0]);
  159. }
  160. private static long LegacyResolve(long? factoryId)
  161. => factoryId is null or 0 ? 1 : factoryId.Value;
  162. }
  163. /// <summary>零行假成功守卫:读到 0 行时必须能区分"源侧本就没有"与"全被作用域过滤掉"。</summary>
  164. public class MdpDbPullZeroRowGuardTests
  165. {
  166. private static MdpPullContext Ctx(int offset = 0) => new()
  167. {
  168. TenantId = 797403760988229,
  169. FactoryId = 1,
  170. FullRefresh = true,
  171. Offset = offset
  172. };
  173. [Fact]
  174. public void LegitimatelyEmptySource_IsPlainSuccess()
  175. {
  176. // dwd_material_shortage:共享库实测 0 行。探针同样取不到行 → 不告警。
  177. Assert.False(MdpDbPullExecutor.IsScopeMismatch(
  178. rowsRead: 0, Ctx(), "tenant_id", "factory_id", sourceHasRowsOutsideScope: false));
  179. }
  180. [Fact]
  181. public void AllFilteredByScope_IsFlaggedAsPartial()
  182. {
  183. // b_examine_result:源侧 660 行,作用域过滤后 0 行 → 必须告警,不得记无保留 SUCCESS。
  184. Assert.True(MdpDbPullExecutor.IsScopeMismatch(
  185. rowsRead: 0, Ctx(), "tenant_id", "factory_id", sourceHasRowsOutsideScope: true));
  186. }
  187. [Fact]
  188. public void NonEmptyRead_IsNeverFlagged()
  189. {
  190. Assert.False(MdpDbPullExecutor.IsScopeMismatch(
  191. rowsRead: 508, Ctx(), "tenant_id", "factory_id", sourceHasRowsOutsideScope: true));
  192. }
  193. [Fact]
  194. public void SourceWithoutScopeColumns_IsNeverFlagged()
  195. {
  196. // 没有发射作用域谓词就没有"被作用域过滤"这回事,0 行只能是源侧真没有。
  197. Assert.False(MdpDbPullExecutor.IsScopeMismatch(
  198. rowsRead: 0, Ctx(), null, null, sourceHasRowsOutsideScope: true));
  199. Assert.False(MdpDbPullExecutor.ShouldProbeScope(0, Ctx(), null, null));
  200. }
  201. [Fact]
  202. public void ExhaustedOffsetPage_IsNeverFlagged()
  203. {
  204. // 前一页已拉满作用域内数据、翻页到底的 0 行是正常的,探针(不带 OFFSET)会误报,故直接不探。
  205. Assert.False(MdpDbPullExecutor.ShouldProbeScope(0, Ctx(offset: 5000), "tenant_id", "factory_id"));
  206. Assert.False(MdpDbPullExecutor.IsScopeMismatch(
  207. rowsRead: 0, Ctx(offset: 5000), "tenant_id", "factory_id", sourceHasRowsOutsideScope: true));
  208. }
  209. [Fact]
  210. public void ScopeMismatch_UsesExistingPartialEnumMember_NotFailed()
  211. {
  212. // mdp_sync_log.status 既有 enum('RUNNING','SUCCESS','PARTIAL','FAILED'),无需改表。
  213. Assert.Equal("PARTIAL", MdpDbPullExecutor.SyncStatusPartial);
  214. Assert.Equal("PARTIAL", MdpDbPullExecutor.ResolveSyncStatus(
  215. MdpDbPullExecutor.SyncStatusPartial, "SCOPE_MISMATCH: ..."));
  216. // 显式 status 缺省时仍沿用旧的 "有 error 即 FAILED"
  217. Assert.Equal("SUCCESS", MdpDbPullExecutor.ResolveSyncStatus(null, null));
  218. Assert.Equal("FAILED", MdpDbPullExecutor.ResolveSyncStatus(null, "boom"));
  219. }
  220. [Fact]
  221. public void ProbeSql_DropsScopePredicatesButKeepsIncrementalWindow()
  222. {
  223. var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity
  224. {
  225. SourceTableName = "b_examine_result",
  226. IncrColumn = "update_time",
  227. LastCursor = "2026-08-01 00:00:00.000"
  228. };
  229. var ctx = new MdpPullContext { TenantId = 797403760988229, FactoryId = 1, FullRefresh = false };
  230. var probe = MdpDbPullExecutor.BuildScopeProbeSql(entity, isSqlServer: false, ctx, useKeyset: false);
  231. Assert.DoesNotContain("@scopeTenantId", probe);
  232. Assert.DoesNotContain("@scopeFactoryId", probe);
  233. Assert.Contains("`update_time` > @cursor", probe); // 增量窗口保留,避免把"增量无新数据"误判为作用域异常
  234. Assert.Contains("FROM b_examine_result", probe);
  235. Assert.Contains("LIMIT 1", probe); // 只做存在性判断
  236. }
  237. [Fact]
  238. public void ProbeSql_RejectsIllegalSourceTableName()
  239. {
  240. var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity
  241. {
  242. SourceTableName = "b_examine_result; DROP TABLE x"
  243. };
  244. Assert.Throws<InvalidOperationException>(() =>
  245. MdpDbPullExecutor.BuildScopeProbeSql(entity, isSqlServer: false, new MdpPullContext(), useKeyset: false));
  246. }
  247. [Fact]
  248. public void ProbeParameters_ExcludeScopeParametersOnly()
  249. {
  250. var parameters = new List<SugarParameter>
  251. {
  252. new("@scopeTenantId", 797403760988229),
  253. new("@scopeFactoryId", 1),
  254. new("@cursor", "2026-08-01 00:00:00.000"),
  255. new("@windowFrom", DateTime.Parse("2026-08-01"))
  256. };
  257. var stripped = MdpDbPullExecutor.StripScopeParameters(parameters);
  258. Assert.Equal(2, stripped.Count);
  259. Assert.DoesNotContain(stripped, p => p.ParameterName.Contains("scope", StringComparison.OrdinalIgnoreCase));
  260. Assert.Contains(stripped, p => p.ParameterName.Contains("cursor", StringComparison.OrdinalIgnoreCase));
  261. Assert.Contains(stripped, p => p.ParameterName.Contains("windowFrom", StringComparison.OrdinalIgnoreCase));
  262. }
  263. }