S0DimContractTests.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. using System;
  2. using System.Linq;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform;
  4. using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim;
  7. /// <summary>
  8. /// S0 主数据维度的**声明与 SQL 生成**契约测试(纯函数,不接触数据库)。
  9. ///
  10. /// 这些断言把两类曾经真实踩过的坑钉在编译/测试期:
  11. /// ① LocationMaster 是 dual-column 复刻表,snake_case 一侧是**填了假值的死列**
  12. /// (rec_id 恒 0 / domain_code 恒空串 / is_active 恒 0),取错不报错、静默返回垃圾;
  13. /// ② 租户隔离必须由 SQL 谓词保证,不能依赖调用方自觉。
  14. /// </summary>
  15. public class S0DimContractTests
  16. {
  17. private const StringComparison Ord = StringComparison.Ordinal;
  18. [Fact]
  19. public void Catalog_should_be_self_consistent()
  20. {
  21. S0DimCatalog.ValidateAll();
  22. Assert.Equal(4, S0DimCatalog.All.Count);
  23. Assert.All(S0DimCatalog.All, d => Assert.Equal(S0DimCatalog.LocalSourceSystem, d.SourceSystem));
  24. }
  25. [Fact]
  26. public void Catalog_find_should_be_case_insensitive_and_null_safe()
  27. {
  28. Assert.Same(S0DimCatalog.WorkCenter, S0DimCatalog.Find("work_center"));
  29. Assert.Same(S0DimCatalog.LocationShelf, S0DimCatalog.Find(" LOCATION_SHELF "));
  30. Assert.Null(S0DimCatalog.Find("NOT_A_KEY"));
  31. Assert.Null(S0DimCatalog.Find(null));
  32. }
  33. [Fact]
  34. public void Every_definition_must_declare_explicit_json_path()
  35. {
  36. foreach (var def in S0DimCatalog.All)
  37. foreach (var col in def.Columns.Where(c => c.Kind != S0DimValueKind.TenantIdColumn))
  38. Assert.False(string.IsNullOrWhiteSpace(col.JsonPath),
  39. $"{def.Key}.{col.TargetColumn} 必须显式声明 JsonPath");
  40. }
  41. [Fact]
  42. public void Definition_without_json_path_should_be_rejected()
  43. {
  44. var bad = S0DimCatalog.WorkCenter with
  45. {
  46. Columns =
  47. [
  48. new S0DimColumn("tenant_id", S0DimValueKind.TenantIdColumn),
  49. new S0DimColumn("domain_code", S0DimValueKind.Str, null, Required: true),
  50. new S0DimColumn("work_center_code", S0DimValueKind.Str, "WorkCtr", Required: true)
  51. ]
  52. };
  53. var ex = Assert.Throws<InvalidOperationException>(() => bad.Validate());
  54. Assert.Contains("JsonPath", ex.Message, Ord);
  55. }
  56. [Fact]
  57. public void Business_key_order_must_match_source_biz_key_expr()
  58. {
  59. // dim 业务键顺序与 mdp_entity.biz_key_expr 不一致时,stg.source_biz_key 无法与 dim 业务键对账
  60. var bad = S0DimCatalog.WorkCenter with { SourceBizKeyColumns = ["WorkCtr", "Domain"] };
  61. var ex = Assert.Throws<InvalidOperationException>(() => bad.Validate());
  62. Assert.Contains("业务键顺序不一致", ex.Message, Ord);
  63. }
  64. [Fact]
  65. public void Business_key_columns_must_be_required()
  66. {
  67. var cols = S0DimCatalog.WorkCenter.Columns
  68. .Select(c => c.TargetColumn == "domain_code" ? c with { Required = false } : c).ToList();
  69. var bad = S0DimCatalog.WorkCenter with { Columns = cols };
  70. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  71. }
  72. [Fact]
  73. public void Business_key_must_start_with_tenant_id()
  74. {
  75. var bad = S0DimCatalog.WorkCenter with
  76. {
  77. BusinessKeyColumns = ["domain_code", "work_center_code"],
  78. SourceBizKeyColumns = ["WorkCtr"]
  79. };
  80. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  81. }
  82. [Fact]
  83. public void Identifiers_must_be_whitelisted()
  84. {
  85. var bad = S0DimCatalog.WorkCenter with { DimTable = "dim_work_center; DROP TABLE x" };
  86. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  87. }
  88. // ────────────────────────────────────────────────────────────────
  89. // LocationMaster 影子列黑名单(🔴 本仓最容易静默出错的一处)
  90. // ────────────────────────────────────────────────────────────────
  91. public static TheoryData<string> LocationShadowPaths() =>
  92. ["$.domain_code", "$.rec_id", "$.is_active", "$.physical_address", "$.create_user"];
  93. [Theory]
  94. [MemberData(nameof(LocationShadowPaths))]
  95. public void Location_insert_sql_must_never_read_shadow_columns(string shadowPath)
  96. {
  97. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Location);
  98. Assert.DoesNotContain(shadowPath, sql, Ord);
  99. }
  100. /// <summary>
  101. /// LocationMaster 影子列的**裸列名**(不带 <c>$.</c> 前缀)。
  102. ///
  103. /// <para>源侧校验和 / 业务键 SQL 把 <c>JsonPath</c> 当作**源表物理列名**用
  104. /// (<c>S0DimSqlBuilder.BuildSourceChecksumSql</c>:<c>a.`Domain`</c>),**整条语句里没有任何 <c>$.</c>**。
  105. /// 因此对这两个生成器断言 <c>DoesNotContain("$.domain_code")</c> 是**恒真**的,零保护 ——
  106. /// 必须断言反引号形式。</para>
  107. /// </summary>
  108. public static TheoryData<string> LocationShadowColumns() =>
  109. ["domain_code", "rec_id", "is_active", "physical_address", "create_user"];
  110. [Theory]
  111. [MemberData(nameof(LocationShadowColumns))]
  112. public void Location_source_side_sql_must_never_read_shadow_columns(string shadowColumn)
  113. {
  114. // 这是本批**唯一能静默通过全部对账**的缺陷类:
  115. // JsonPath 若误写成影子列,dim 侧读 staging 的 $.is_active(=0),
  116. // 源侧校验和读源表的 a.`is_active`(同样 =0)→ 两侧自洽 → checksum 相等 → PASS,
  117. // 而真实的 IsActive=1 被静默翻成 0。影子列存 falsy 而非 NULL,不会抛任何异常。
  118. var needle = $"{S0DimSqlBuilder.SrcAlias}.`{shadowColumn}`";
  119. Assert.DoesNotContain(needle, S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Location), Ord);
  120. Assert.DoesNotContain(needle, S0DimSqlBuilder.SourceBizKeySetSql(S0DimCatalog.Location), Ord);
  121. }
  122. [Theory]
  123. [InlineData("Domain")]
  124. [InlineData("IsActive")]
  125. [InlineData("PhysicalAddress")]
  126. [InlineData("UpdateTime")]
  127. public void Location_source_checksum_must_read_real_columns(string realColumn)
  128. {
  129. // 正向断言:光有 DoesNotContain 不够 —— 列被整个删掉时它也通过。
  130. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`{realColumn}`",
  131. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Location), Ord);
  132. }
  133. [Fact]
  134. public void Insert_and_source_checksum_must_read_the_same_declared_path()
  135. {
  136. // 通用护栏(不限 Location):dim 物化走 staging 的 $.JsonPath,
  137. // 源侧校验和走源表的 a.`JsonPath`。两者必须来自**同一个**声明,
  138. // 否则「写进 dim 的值」与「用来验证 dim 的值」根本不是一回事。
  139. foreach (var def in S0DimCatalog.All)
  140. {
  141. var insert = S0DimSqlBuilder.BuildInsertSql(def);
  142. var srcChk = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  143. foreach (var c in def.Columns)
  144. {
  145. if (c.Kind == S0DimValueKind.TenantIdColumn) continue;
  146. Assert.Contains($"$.{c.JsonPath}", insert, Ord);
  147. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`{c.JsonPath}`", srcChk, Ord);
  148. }
  149. }
  150. }
  151. [Fact]
  152. public void Source_and_dim_checksum_must_have_identical_segment_count()
  153. {
  154. // 位置对称性:两侧 CONCAT_WS 的段数/顺序错位会产出**完全自洽的错值**。
  155. // 段数相等是最容易机械校验的必要条件(顺序由 ChecksumColumns 同一函数保证)。
  156. foreach (var def in S0DimCatalog.All)
  157. {
  158. var src = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  159. var dim = S0DimSqlBuilder.BuildDimChecksumSql(def);
  160. Assert.Equal(Occurrences(src, "IFNULL("), Occurrences(dim, "IFNULL("));
  161. Assert.Equal(Occurrences(src, "CONCAT_WS("), Occurrences(dim, "CONCAT_WS("));
  162. }
  163. }
  164. private static int Occurrences(string haystack, string needle)
  165. {
  166. var n = 0;
  167. for (var i = haystack.IndexOf(needle, Ord); i >= 0; i = haystack.IndexOf(needle, i + needle.Length, Ord))
  168. n++;
  169. return n;
  170. }
  171. [Fact]
  172. public void Required_string_columns_must_also_reject_empty_string()
  173. {
  174. // IS NOT NULL 挡不住空串,而 CONCAT_WS 会保留空段:
  175. // Domain='' 会拼出 '#LOC01' 这种「形似合法」的业务键,且不会被 blank_cnt 探针捕获
  176. // (它只捕获整串全空)。故 Required 的 Str 列必须同时有 <> '' 守卫。
  177. foreach (var def in S0DimCatalog.All)
  178. {
  179. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  180. foreach (var c in def.Columns)
  181. {
  182. if (c.Kind != S0DimValueKind.Str || !c.Required) continue;
  183. // 必须逐列绑定:整串级的 Assert.Contains("<> ''") 在「5 个 Required 列只有 1 个带守卫」时照样通过。
  184. var expr = MdpJsonSql.Str(S0DimSqlBuilder.StgAlias, c.JsonPath!);
  185. Assert.Contains($"({expr}) IS NOT NULL", sql, Ord);
  186. Assert.Contains($"({expr}) <> ''", sql, Ord);
  187. }
  188. }
  189. }
  190. [Fact]
  191. public void Every_business_key_component_must_be_required_and_of_key_safe_kind()
  192. {
  193. // 守住上一条测试的前提:若将来把某个业务键分量改成非 Required,
  194. // 空串守卫会连同 NULL 守卫一起消失,而上一条测试仍会通过(它只遍历 Required 列)。
  195. foreach (var def in S0DimCatalog.All)
  196. {
  197. foreach (var bk in def.BusinessKeyColumns.Skip(1))
  198. {
  199. var col = def.Columns.First(x => x.TargetColumn == bk);
  200. Assert.True(col.Required, $"[{def.Key}] 业务键分量 {bk} 必须 Required");
  201. Assert.True(col.Kind is S0DimValueKind.Str or S0DimValueKind.Int,
  202. $"[{def.Key}] 业务键分量 {bk} 的 Kind={col.Kind} 不适合做键" +
  203. "(Dec 有精度格式化差异、DateTimeSec 截秒、BoolTrue 基数过低)");
  204. }
  205. }
  206. }
  207. public static TheoryData<string> LocationRealPaths() =>
  208. ["$.Domain", "$.IsActive", "$.PhysicalAddress", "$.location", "$.descr", "$.typed", "$.storer", "$.UpdateTime"];
  209. [Theory]
  210. [MemberData(nameof(LocationRealPaths))]
  211. public void Location_insert_sql_must_read_real_columns(string realPath)
  212. {
  213. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Location);
  214. Assert.Contains(realPath, sql, Ord);
  215. }
  216. [Fact]
  217. public void Location_shadow_blacklist_must_not_be_applied_to_shelf()
  218. {
  219. // LocationShelfMaster 本身就是 snake_case、无影子列:
  220. // 这里的 $.domain_code / $.update_time 是**正确**的物理列名,不适用父表的禁用规则
  221. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.LocationShelf);
  222. Assert.Contains("$.domain_code", sql, Ord);
  223. Assert.Contains("$.location", sql, Ord);
  224. Assert.Contains("$.inv_shelf", sql, Ord);
  225. Assert.Contains("$.update_time", sql, Ord);
  226. Assert.DoesNotContain("$.Domain'", sql, Ord);
  227. Assert.DoesNotContain("$.InvShelf", sql, Ord);
  228. }
  229. // ────────────────────────────────────────────────────────────────
  230. // 租户隔离:所有生成的 SQL 都必须带租户谓词
  231. // ────────────────────────────────────────────────────────────────
  232. [Fact]
  233. public void Insert_sql_must_filter_tenant_source_table_and_batch()
  234. {
  235. foreach (var def in S0DimCatalog.All)
  236. {
  237. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  238. Assert.Contains("s.`tenant_id` = @TenantId", sql, Ord);
  239. Assert.Contains("s.`source_system` = @SourceSystem", sql, Ord);
  240. Assert.Contains("s.`source_table` = @SourceTable", sql, Ord);
  241. Assert.Contains("s.`sync_batch_id` = @BatchId", sql, Ord);
  242. }
  243. }
  244. [Fact]
  245. public void Insert_sql_must_not_use_on_duplicate_key_update()
  246. {
  247. // 源侧业务键重复必须撞 dim 唯一键并回滚(transform fail),绝不 last-wins
  248. foreach (var def in S0DimCatalog.All)
  249. Assert.DoesNotContain("ON DUPLICATE KEY", S0DimSqlBuilder.BuildInsertSql(def), StringComparison.OrdinalIgnoreCase);
  250. }
  251. [Fact]
  252. public void Purge_sql_must_carry_all_three_predicates()
  253. {
  254. foreach (var def in S0DimCatalog.All)
  255. {
  256. var sql = S0DimSqlBuilder.BuildPurgeStagingSql(def);
  257. Assert.Contains("s.`tenant_id` = @TenantId", sql, Ord);
  258. Assert.Contains("s.`source_system` = @SourceSystem", sql, Ord);
  259. Assert.Contains("s.`source_table` = @SourceTable", sql, Ord);
  260. // purge 不限批次:本轮之前的残留也要清掉
  261. Assert.DoesNotContain("sync_batch_id", sql, Ord);
  262. }
  263. }
  264. [Fact]
  265. public void All_count_and_set_sql_must_be_tenant_scoped()
  266. {
  267. foreach (var def in S0DimCatalog.All)
  268. {
  269. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildSourceCountSql(def), Ord);
  270. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildStagingCountSql(def), Ord);
  271. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimCountSql(def), Ord);
  272. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimBatchImpuritySql(def), Ord);
  273. Assert.Contains("@TenantId", S0DimSqlBuilder.SourceBizKeySetSql(def), Ord);
  274. Assert.Contains("@TenantId", S0DimSqlBuilder.StagingBizKeySetSql(def), Ord);
  275. Assert.Contains("@TenantId", S0DimSqlBuilder.DimBizKeySetSql(def), Ord);
  276. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildSourceChecksumSql(def), Ord);
  277. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimChecksumSql(def), Ord);
  278. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimDuplicateBizKeySql(def), Ord);
  279. }
  280. }
  281. [Fact]
  282. public void Bool_columns_must_carry_recognizability_guard()
  283. {
  284. // MdpJsonSql.BoolTrue 对 null / 不可识别值静默返回 0;必须有守卫把这类行挡在 dim 之外。
  285. // 遍历全部维度并逐列绑定:只测 WorkCenter 会漏掉 Location / Department 的 BoolTrue 列。
  286. var covered = 0;
  287. foreach (var def in S0DimCatalog.All)
  288. {
  289. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  290. foreach (var c in def.Columns)
  291. {
  292. if (c.Kind != S0DimValueKind.BoolTrue) continue;
  293. var raw = MdpJsonSql.Raw(S0DimSqlBuilder.StgAlias, c.JsonPath!);
  294. Assert.Contains($"LOWER({raw}) IN ('1','0','true','false')", sql, Ord);
  295. covered++;
  296. }
  297. }
  298. Assert.True(covered > 0, "目录里一个 BoolTrue 列都没有,本测试已失去意义");
  299. }
  300. [Fact]
  301. public void Checksum_must_use_exact_integer_summation_not_double()
  302. {
  303. // 回归守卫(2026-09-07 Batch 2 实测缺陷):CONV() 返回字符串,SUM(字符串) 在 MySQL 里按 DOUBLE 累加,
  304. // 只有约 16 位有效数字;两侧扫描顺序不同会产生不同舍入,导致完全相同的数据被误判为 checksum 不一致
  305. // (租户 838257186181189:source=…980000 / dim=…980700,count 相同)。
  306. // 必须先 CAST 成 UNSIGNED,SUM 才走 DECIMAL 精确累加、与顺序无关。
  307. foreach (var def in S0DimCatalog.All)
  308. {
  309. foreach (var sql in new[] { S0DimSqlBuilder.BuildSourceChecksumSql(def), S0DimSqlBuilder.BuildDimChecksumSql(def) })
  310. {
  311. Assert.Contains("SUM(CAST(CONV(", sql, Ord);
  312. Assert.Contains("AS UNSIGNED))", sql, Ord);
  313. Assert.DoesNotContain("SUM(CONV(", sql, Ord);
  314. }
  315. }
  316. }
  317. [Fact]
  318. public void Shelf_definition_declares_parent_and_location_declares_mirror_unique()
  319. {
  320. Assert.Equal("dim_location", S0DimCatalog.LocationShelf.ParentDimTable);
  321. Assert.Equal(["domain_code", "location_code"], S0DimCatalog.LocationShelf.ParentKeyColumns);
  322. Assert.Equal(["tenant_id", "location_code"], S0DimCatalog.Location.MirrorUniqueColumns);
  323. var orphanSql = S0DimSqlBuilder.BuildOrphanChildSql(S0DimCatalog.LocationShelf, 10);
  324. Assert.Contains("dim_location", orphanSql, Ord);
  325. Assert.Contains("@TenantId", orphanSql, Ord);
  326. }
  327. [Fact]
  328. public void Work_center_dim_must_not_invent_company_or_factory_columns()
  329. {
  330. // 源 WorkCtrMaster 没有 company_ref_id / factory_ref_id,dim 不得凭空补
  331. var targets = S0DimCatalog.WorkCenter.Columns.Select(c => c.TargetColumn).ToList();
  332. Assert.DoesNotContain("company_id", targets);
  333. Assert.DoesNotContain("factory_id", targets);
  334. }
  335. [Fact]
  336. public void Shelf_dim_must_not_invent_is_active()
  337. {
  338. // 源 LocationShelfMaster 没有 IsActive 列,不发明状态语义
  339. var targets = S0DimCatalog.LocationShelf.Columns.Select(c => c.TargetColumn).ToList();
  340. Assert.DoesNotContain("is_active", targets);
  341. }
  342. [Fact]
  343. public void Two_definitions_may_share_staging_but_never_share_dim()
  344. {
  345. Assert.Equal(S0DimCatalog.Location.StagingTable, S0DimCatalog.LocationShelf.StagingTable);
  346. Assert.NotEqual(S0DimCatalog.Location.SourceTable, S0DimCatalog.LocationShelf.SourceTable);
  347. Assert.Equal(4, S0DimCatalog.All.Select(d => d.DimTable).Distinct().Count());
  348. }
  349. }