using System; using System.IO; using System.Linq; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// P0-A 数据纠偏 migration 的结构契约测试。 /// /// 这些断言能证明什么、不能证明什么(如实说明,不要高估): /// 它们对 migration 脚本文本做结构断言,能够拦住「谓词被改弱」「保全谓词退回 /// po_no IS NOT NULL」「漏掉租户排除」「先改 schema 后清数据」这类**致命且静默**的回归。 /// 它们**不能**证明脚本在真实数据上的执行结果 —— 那已由对 aidopdev 的只读 dry-run 覆盖 /// (G1 117154+4=117158、G2 双向差集 0、G5 保全 31 行),本仓无 DB 集成测试基础设施。 /// public class P0aCleanupMigrationContractTests { private const StringComparison Ord = StringComparison.Ordinal; private static string RepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry"))) dir = dir.Parent; Assert.NotNull(dir); return dir!.FullName; } private static string ScriptsDir() => Path.Combine(RepoRoot(), "server", "Admin.NET.Web.Entry", "UpdateScripts"); /// 主脚本文本。占位名或已 rename 的正式名都能找到。 private static string Migration() { var dir = ScriptsDir(); // 按内容锚点而非文件名定位:号段会随 rebase / 撞号重编而变,内容不会。 var f = Directory.GetFiles(dir, "*.sql") .FirstOrDefault(p => !p.EndsWith(".verify.sql", Ord) && File.ReadAllText(p).Contains("P0A-G1", Ord)); Assert.True(f != null, "找不到 P0-A cleanup migration(按 P0A-G1 门禁标识定位)"); return File.ReadAllText(f!); } private static string Verify() { var dir = ScriptsDir(); var f = Directory.GetFiles(dir, "*.verify.sql") .FirstOrDefault(p => File.ReadAllText(p).Contains("uk_po_line_stat", Ord)); Assert.True(f != null, "找不到 P0-A cleanup 的 verify 脚本(按 uk_po_line_stat 定位)"); return File.ReadAllText(f!); } // ── T1 · 种群 A 谓词:三重冗余缺一不可 ─────────────────────────────────────── // 只用 po_no IS NULL 是不够的(未来可能出现合法空 po_no);必须同时带 // 批次来源(sync_batch_id) + biz_key 形态(NOT LIKE '%|%') + 结构特征(work_order='')。 [Fact] public void T1_PopulationA_predicate_keeps_all_three_independent_conditions() { var sql = Migration(); Assert.Contains(@"`sync_batch_id` LIKE 'S3\_MDP\_%'", sql, Ord); Assert.Contains(@"`source_biz_key` NOT LIKE '%|%'", sql, Ord); Assert.Contains("`work_order` = ''", sql, Ord); Assert.Contains("`po_no` IS NULL", sql, Ord); Assert.Contains("`po_line` IS NULL", sql, Ord); } // ── T1b · 血缘交叉验证必须存在,且不得附加 source_system 等值条件 ────────── // stg 侧两代分属 AIDOP(colon) / AIDOPDEV_MYSQL(digits),std 侧被硬编码为 'AIDOP', // 加上 source_system 等值会假阴约 7.6 万行 —— 这是实测过的陷阱。 [Fact] public void T1b_Provenance_crosscheck_must_not_join_on_source_system() { var sql = Migration(); Assert.Contains("s.`source_table` = 'RoutingOpDetail'", sql, Ord); Assert.Contains("s.`source_biz_key` = o.`source_biz_key`", sql, Ord); Assert.DoesNotContain("s.`source_system` = o.`source_system`", sql, Ord); Assert.DoesNotContain("s.source_system = o.source_system", sql, Ord); } // ── T2 · 幸存者:必须断言 std 不被清空,且 DELETE 有精确 WHERE ────────────── [Fact] public void T2_Std_cleanup_is_scoped_and_never_truncates_the_table() { var sql = Migration(); Assert.Contains("DELETE FROM `mdp_std_process_outsource_order`", sql, Ord); Assert.DoesNotContain("TRUNCATE TABLE `mdp_std_process_outsource_order`", sql, Ord); // 清理后必须立即断言幸存者数量与门禁阶段一致 Assert.Contains("@std_after <> @survivors", sql, Ord); // verify 侧必须防「整表被清空」 Assert.Contains("FROM `mdp_std_process_outsource_order`) > 0", Verify(), Ord); } // ── T3 · 新唯一键:Option A 粒度,且旧键必须被移除 ────────────────────────── [Fact] public void T3_New_unique_key_matches_option_a_grain_and_old_key_is_dropped() { var sql = Migration(); Assert.Contains( "ADD UNIQUE INDEX `uk_po_line_stat` (`tenant_id`,`factory_id`,`stat_date`,`po_no`,`po_line`)", sql, Ord); Assert.Contains("DROP INDEX `uk_work_op_stat`", sql, Ord); // verify 必须钉死列序,而不只是「索引存在」 Assert.Contains("'tenant_id,factory_id,stat_date,po_no,po_line'", Verify(), Ord); // 旧键必须已消失 Assert.Contains("INDEX_NAME='uk_work_op_stat'", Verify(), Ord); } // ── T4 · NULL 收口:两列都必须 NOT NULL DEFAULT '',且 verify 连空串也拦 ──── [Fact] public void T4_Both_key_columns_are_hardened_to_not_null() { var sql = Migration(); Assert.Contains("MODIFY COLUMN `po_no` VARCHAR(100) NOT NULL DEFAULT ''", sql, Ord); Assert.Contains("MODIFY COLUMN `po_line` VARCHAR(50) NOT NULL DEFAULT ''", sql, Ord); var v = Verify(); Assert.Contains("WHERE `po_no` IS NULL) = 0", v, Ord); Assert.Contains("WHERE `po_line` IS NULL) = 0", v, Ord); Assert.Contains("WHERE `po_no` = '') = 0", v, Ord); Assert.Contains("WHERE `po_line` = '') = 0", v, Ord); Assert.Contains("IS_NULLABLE='YES'", v, Ord); } // ── T5 · 非法租户:回插必须显式排除,不依赖隐式 filter ───────────────────── [Fact] public void T5_Restore_explicitly_excludes_illegal_tenants() { var sql = Migration(); Assert.Contains("`tenant_id` NOT IN (0,1,1300000000001)", sql, Ord); Assert.Contains("WHERE `tenant_id` IN (0,1,1300000000001)) = 0", Verify(), Ord); } // ── T6 · 31 行保全往返:谓词必须是 IFNULL(po_no,'')<>'',不能退回 IS NOT NULL ── // 这是本批最容易静默出错的一处:代码侧 IFNULL 补丁上线后,种群 A 的 po_no // 变成**空串**而非 NULL;用 po_no IS NOT NULL 会把垃圾一并当成不可再生历史保下来 // (实测当时会多保 56 行)。 [Fact] public void T6_Preservation_predicate_excludes_empty_string_not_just_null() { var sql = Migration(); Assert.Contains("WHERE IFNULL(`po_no`,'') <> ''", sql, Ord); // 保全 / 回插 / verify 三处口径必须一致,且都不得出现裸 IS NOT NULL Assert.DoesNotContain("WHERE `po_no` IS NOT NULL", sql, Ord); Assert.DoesNotContain("WHERE po_no IS NOT NULL", sql, Ord); Assert.Contains("CREATE TABLE IF NOT EXISTS `dwd_process_outsource_delivery_keep_p0a`", sql, Ord); Assert.Contains("INSERT INTO `dwd_process_outsource_delivery`", sql, Ord); // verify 必须做双向差集,而不是只比行数 var v = Verify(); Assert.Contains("NOT EXISTS", v, Ord); Assert.Contains("dwd_process_outsource_delivery_keep_p0a", v, Ord); } // ── T7 · 执行顺序铁律:清数据必须在改 schema 之前 ────────────────────────── // 反序(先 NOT NULL / 先换 UK,再清种群 A)会把大量不同主数据压进少数键, // 把「膨胀」换成更坏的「静默丢数」。 [Fact] public void T7_Data_cleanup_must_precede_schema_hardening() { var sql = Migration(); var stdDelete = sql.IndexOf("DELETE FROM `mdp_std_process_outsource_order`", Ord); var truncate = sql.IndexOf("TRUNCATE TABLE `dwd_process_outsource_delivery`", Ord); var notNull = sql.IndexOf("MODIFY COLUMN `po_no`", Ord); var ukSwap = sql.IndexOf("DROP INDEX `uk_work_op_stat`", Ord); var restore = sql.IndexOf("SELECT * FROM `dwd_process_outsource_delivery_keep_p0a`", Ord); Assert.True(stdDelete > 0 && truncate > 0 && notNull > 0 && ukSwap > 0 && restore > 0); Assert.True(stdDelete < truncate, "std 清理必须先于 DWD TRUNCATE"); Assert.True(truncate < notNull, "schema 收口必须在空表期(TRUNCATE 之后)"); Assert.True(notNull < restore, "回插必须在 NOT NULL 收口之后"); Assert.True(ukSwap < restore, "回插必须在唯一键切换之后"); } // ── T8 · 保全必须先于 TRUNCATE,且有完整性断言 ───────────────────────────── [Fact] public void T8_Preservation_happens_before_truncate_with_a_count_assertion() { var sql = Migration(); var backup = sql.IndexOf("INSERT IGNORE INTO `dwd_process_outsource_delivery_keep_p0a`", Ord); var assertion = sql.IndexOf("@kept <> @preserve_cnt", Ord); var truncate = sql.IndexOf("TRUNCATE TABLE `dwd_process_outsource_delivery`", Ord); Assert.True(backup > 0 && assertion > 0 && truncate > 0); Assert.True(backup < assertion, "必须先备份再断言"); Assert.True(assertion < truncate, "行数一致性断言必须在 TRUNCATE 之前"); } // ── T9 · CHECK 约束:不得硬编码假定旧名,且必须防重复 ────────────────────── [Fact] public void T9_Tenant_check_constraint_is_resolved_dynamically_and_deduped() { var sql = Migration(); // 按语义定位,而不是假定名字存在 Assert.Contains("cc.CHECK_CLAUSE LIKE '%tenant_id%'", sql, Ord); Assert.Contains("@tenant_chk_cnt > 1", sql, Ord); // 发现重复即中止 Assert.Contains("ck_dwd_process_outsource_valid_tenant", sql, Ord); // 收敛到稳定名 // verify 必须断言「恰好一套」 Assert.Contains("AND cc.CHECK_CLAUSE LIKE '%1300000000001%'", Verify(), Ord); } // ── T10 · 门禁必须 fail-fast,且在任何破坏性语句之前 ─────────────────────── [Fact] public void T10_Guards_abort_before_any_destructive_statement() { var sql = Migration(); foreach (var g in new[] { "P0A-G1", "P0A-G2", "P0A-G3", "P0A-G4", "P0A-G5" }) Assert.Contains(g, sql, Ord); var lastGuard = sql.IndexOf("P0A-G5", Ord); var firstDestructive = new[] { sql.IndexOf("DELETE FROM `mdp_std_process_outsource_order`", Ord), sql.IndexOf("TRUNCATE TABLE `dwd_process_outsource_delivery`", Ord), sql.IndexOf("MODIFY COLUMN `po_no`", Ord), }.Where(i => i > 0).Min(); Assert.True(lastGuard < firstDestructive, "全部门禁必须在第一条破坏性语句之前完成"); } }