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,
"全部门禁必须在第一条破坏性语句之前完成");
}
}