P0aCleanupMigrationContractTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. using System;
  2. using System.IO;
  3. using System.Linq;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  6. /// <summary>
  7. /// P0-A 数据纠偏 migration 的结构契约测试。
  8. ///
  9. /// <para><b>这些断言能证明什么、不能证明什么(如实说明,不要高估)</b>:
  10. /// 它们对 migration 脚本文本做结构断言,能够拦住「谓词被改弱」「保全谓词退回
  11. /// po_no IS NOT NULL」「漏掉租户排除」「先改 schema 后清数据」这类**致命且静默**的回归。
  12. /// 它们**不能**证明脚本在真实数据上的执行结果 —— 那已由对 aidopdev 的只读 dry-run 覆盖
  13. /// (G1 117154+4=117158、G2 双向差集 0、G5 保全 31 行),本仓无 DB 集成测试基础设施。</para>
  14. /// </summary>
  15. public class P0aCleanupMigrationContractTests
  16. {
  17. private const StringComparison Ord = StringComparison.Ordinal;
  18. private static string RepoRoot()
  19. {
  20. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  21. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry")))
  22. dir = dir.Parent;
  23. Assert.NotNull(dir);
  24. return dir!.FullName;
  25. }
  26. private static string ScriptsDir() =>
  27. Path.Combine(RepoRoot(), "server", "Admin.NET.Web.Entry", "UpdateScripts");
  28. /// <summary>主脚本文本。占位名或已 rename 的正式名都能找到。</summary>
  29. private static string Migration()
  30. {
  31. var dir = ScriptsDir();
  32. // 按内容锚点而非文件名定位:号段会随 rebase / 撞号重编而变,内容不会。
  33. var f = Directory.GetFiles(dir, "*.sql")
  34. .FirstOrDefault(p => !p.EndsWith(".verify.sql", Ord)
  35. && File.ReadAllText(p).Contains("P0A-G1", Ord));
  36. Assert.True(f != null, "找不到 P0-A cleanup migration(按 P0A-G1 门禁标识定位)");
  37. return File.ReadAllText(f!);
  38. }
  39. private static string Verify()
  40. {
  41. var dir = ScriptsDir();
  42. var f = Directory.GetFiles(dir, "*.verify.sql")
  43. .FirstOrDefault(p => File.ReadAllText(p).Contains("uk_po_line_stat", Ord));
  44. Assert.True(f != null, "找不到 P0-A cleanup 的 verify 脚本(按 uk_po_line_stat 定位)");
  45. return File.ReadAllText(f!);
  46. }
  47. // ── T1 · 种群 A 谓词:三重冗余缺一不可 ───────────────────────────────────────
  48. // 只用 po_no IS NULL 是不够的(未来可能出现合法空 po_no);必须同时带
  49. // 批次来源(sync_batch_id) + biz_key 形态(NOT LIKE '%|%') + 结构特征(work_order='')。
  50. [Fact]
  51. public void T1_PopulationA_predicate_keeps_all_three_independent_conditions()
  52. {
  53. var sql = Migration();
  54. Assert.Contains(@"`sync_batch_id` LIKE 'S3\_MDP\_%'", sql, Ord);
  55. Assert.Contains(@"`source_biz_key` NOT LIKE '%|%'", sql, Ord);
  56. Assert.Contains("`work_order` = ''", sql, Ord);
  57. Assert.Contains("`po_no` IS NULL", sql, Ord);
  58. Assert.Contains("`po_line` IS NULL", sql, Ord);
  59. }
  60. // ── T1b · 血缘交叉验证必须存在,且不得附加 source_system 等值条件 ──────────
  61. // stg 侧两代分属 AIDOP(colon) / AIDOPDEV_MYSQL(digits),std 侧被硬编码为 'AIDOP',
  62. // 加上 source_system 等值会假阴约 7.6 万行 —— 这是实测过的陷阱。
  63. [Fact]
  64. public void T1b_Provenance_crosscheck_must_not_join_on_source_system()
  65. {
  66. var sql = Migration();
  67. Assert.Contains("s.`source_table` = 'RoutingOpDetail'", sql, Ord);
  68. Assert.Contains("s.`source_biz_key` = o.`source_biz_key`", sql, Ord);
  69. Assert.DoesNotContain("s.`source_system` = o.`source_system`", sql, Ord);
  70. Assert.DoesNotContain("s.source_system = o.source_system", sql, Ord);
  71. }
  72. // ── T2 · 幸存者:必须断言 std 不被清空,且 DELETE 有精确 WHERE ──────────────
  73. [Fact]
  74. public void T2_Std_cleanup_is_scoped_and_never_truncates_the_table()
  75. {
  76. var sql = Migration();
  77. Assert.Contains("DELETE FROM `mdp_std_process_outsource_order`", sql, Ord);
  78. Assert.DoesNotContain("TRUNCATE TABLE `mdp_std_process_outsource_order`", sql, Ord);
  79. // 清理后必须立即断言幸存者数量与门禁阶段一致
  80. Assert.Contains("@std_after <> @survivors", sql, Ord);
  81. // verify 侧必须防「整表被清空」
  82. Assert.Contains("FROM `mdp_std_process_outsource_order`) > 0", Verify(), Ord);
  83. }
  84. // ── T3 · 新唯一键:Option A 粒度,且旧键必须被移除 ──────────────────────────
  85. [Fact]
  86. public void T3_New_unique_key_matches_option_a_grain_and_old_key_is_dropped()
  87. {
  88. var sql = Migration();
  89. Assert.Contains(
  90. "ADD UNIQUE INDEX `uk_po_line_stat` (`tenant_id`,`factory_id`,`stat_date`,`po_no`,`po_line`)",
  91. sql, Ord);
  92. Assert.Contains("DROP INDEX `uk_work_op_stat`", sql, Ord);
  93. // verify 必须钉死列序,而不只是「索引存在」
  94. Assert.Contains("'tenant_id,factory_id,stat_date,po_no,po_line'", Verify(), Ord);
  95. // 旧键必须已消失
  96. Assert.Contains("INDEX_NAME='uk_work_op_stat'", Verify(), Ord);
  97. }
  98. // ── T4 · NULL 收口:两列都必须 NOT NULL DEFAULT '',且 verify 连空串也拦 ────
  99. [Fact]
  100. public void T4_Both_key_columns_are_hardened_to_not_null()
  101. {
  102. var sql = Migration();
  103. Assert.Contains("MODIFY COLUMN `po_no` VARCHAR(100) NOT NULL DEFAULT ''", sql, Ord);
  104. Assert.Contains("MODIFY COLUMN `po_line` VARCHAR(50) NOT NULL DEFAULT ''", sql, Ord);
  105. var v = Verify();
  106. Assert.Contains("WHERE `po_no` IS NULL) = 0", v, Ord);
  107. Assert.Contains("WHERE `po_line` IS NULL) = 0", v, Ord);
  108. Assert.Contains("WHERE `po_no` = '') = 0", v, Ord);
  109. Assert.Contains("WHERE `po_line` = '') = 0", v, Ord);
  110. Assert.Contains("IS_NULLABLE='YES'", v, Ord);
  111. }
  112. // ── T5 · 非法租户:回插必须显式排除,不依赖隐式 filter ─────────────────────
  113. [Fact]
  114. public void T5_Restore_explicitly_excludes_illegal_tenants()
  115. {
  116. var sql = Migration();
  117. Assert.Contains("`tenant_id` NOT IN (0,1,1300000000001)", sql, Ord);
  118. Assert.Contains("WHERE `tenant_id` IN (0,1,1300000000001)) = 0", Verify(), Ord);
  119. }
  120. // ── T6 · 31 行保全往返:谓词必须是 IFNULL(po_no,'')<>'',不能退回 IS NOT NULL ──
  121. // 这是本批最容易静默出错的一处:代码侧 IFNULL 补丁上线后,种群 A 的 po_no
  122. // 变成**空串**而非 NULL;用 po_no IS NOT NULL 会把垃圾一并当成不可再生历史保下来
  123. // (实测当时会多保 56 行)。
  124. [Fact]
  125. public void T6_Preservation_predicate_excludes_empty_string_not_just_null()
  126. {
  127. var sql = Migration();
  128. Assert.Contains("WHERE IFNULL(`po_no`,'') <> ''", sql, Ord);
  129. // 保全 / 回插 / verify 三处口径必须一致,且都不得出现裸 IS NOT NULL
  130. Assert.DoesNotContain("WHERE `po_no` IS NOT NULL", sql, Ord);
  131. Assert.DoesNotContain("WHERE po_no IS NOT NULL", sql, Ord);
  132. Assert.Contains("CREATE TABLE IF NOT EXISTS `dwd_process_outsource_delivery_keep_p0a`", sql, Ord);
  133. Assert.Contains("INSERT INTO `dwd_process_outsource_delivery`", sql, Ord);
  134. // verify 必须做双向差集,而不是只比行数
  135. var v = Verify();
  136. Assert.Contains("NOT EXISTS", v, Ord);
  137. Assert.Contains("dwd_process_outsource_delivery_keep_p0a", v, Ord);
  138. }
  139. // ── T7 · 执行顺序铁律:清数据必须在改 schema 之前 ──────────────────────────
  140. // 反序(先 NOT NULL / 先换 UK,再清种群 A)会把大量不同主数据压进少数键,
  141. // 把「膨胀」换成更坏的「静默丢数」。
  142. [Fact]
  143. public void T7_Data_cleanup_must_precede_schema_hardening()
  144. {
  145. var sql = Migration();
  146. var stdDelete = sql.IndexOf("DELETE FROM `mdp_std_process_outsource_order`", Ord);
  147. var truncate = sql.IndexOf("TRUNCATE TABLE `dwd_process_outsource_delivery`", Ord);
  148. var notNull = sql.IndexOf("MODIFY COLUMN `po_no`", Ord);
  149. var ukSwap = sql.IndexOf("DROP INDEX `uk_work_op_stat`", Ord);
  150. var restore = sql.IndexOf("SELECT * FROM `dwd_process_outsource_delivery_keep_p0a`", Ord);
  151. Assert.True(stdDelete > 0 && truncate > 0 && notNull > 0 && ukSwap > 0 && restore > 0);
  152. Assert.True(stdDelete < truncate, "std 清理必须先于 DWD TRUNCATE");
  153. Assert.True(truncate < notNull, "schema 收口必须在空表期(TRUNCATE 之后)");
  154. Assert.True(notNull < restore, "回插必须在 NOT NULL 收口之后");
  155. Assert.True(ukSwap < restore, "回插必须在唯一键切换之后");
  156. }
  157. // ── T8 · 保全必须先于 TRUNCATE,且有完整性断言 ─────────────────────────────
  158. [Fact]
  159. public void T8_Preservation_happens_before_truncate_with_a_count_assertion()
  160. {
  161. var sql = Migration();
  162. var backup = sql.IndexOf("INSERT IGNORE INTO `dwd_process_outsource_delivery_keep_p0a`", Ord);
  163. var assertion = sql.IndexOf("@kept <> @preserve_cnt", Ord);
  164. var truncate = sql.IndexOf("TRUNCATE TABLE `dwd_process_outsource_delivery`", Ord);
  165. Assert.True(backup > 0 && assertion > 0 && truncate > 0);
  166. Assert.True(backup < assertion, "必须先备份再断言");
  167. Assert.True(assertion < truncate, "行数一致性断言必须在 TRUNCATE 之前");
  168. }
  169. // ── T9 · CHECK 约束:不得硬编码假定旧名,且必须防重复 ──────────────────────
  170. [Fact]
  171. public void T9_Tenant_check_constraint_is_resolved_dynamically_and_deduped()
  172. {
  173. var sql = Migration();
  174. // 按语义定位,而不是假定名字存在
  175. Assert.Contains("cc.CHECK_CLAUSE LIKE '%tenant_id%'", sql, Ord);
  176. Assert.Contains("@tenant_chk_cnt > 1", sql, Ord); // 发现重复即中止
  177. Assert.Contains("ck_dwd_process_outsource_valid_tenant", sql, Ord); // 收敛到稳定名
  178. // verify 必须断言「恰好一套」
  179. Assert.Contains("AND cc.CHECK_CLAUSE LIKE '%1300000000001%'", Verify(), Ord);
  180. }
  181. // ── T10 · 门禁必须 fail-fast,且在任何破坏性语句之前 ───────────────────────
  182. [Fact]
  183. public void T10_Guards_abort_before_any_destructive_statement()
  184. {
  185. var sql = Migration();
  186. foreach (var g in new[] { "P0A-G1", "P0A-G2", "P0A-G3", "P0A-G4", "P0A-G5" })
  187. Assert.Contains(g, sql, Ord);
  188. var lastGuard = sql.IndexOf("P0A-G5", Ord);
  189. var firstDestructive = new[]
  190. {
  191. sql.IndexOf("DELETE FROM `mdp_std_process_outsource_order`", Ord),
  192. sql.IndexOf("TRUNCATE TABLE `dwd_process_outsource_delivery`", Ord),
  193. sql.IndexOf("MODIFY COLUMN `po_no`", Ord),
  194. }.Where(i => i > 0).Min();
  195. Assert.True(lastGuard < firstDestructive,
  196. "全部门禁必须在第一条破坏性语句之前完成");
  197. }
  198. }