|
|
@@ -0,0 +1,187 @@
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// mdp_stg_so 贴源唯一键必须含 tenant_id 的契约。
|
|
|
+///
|
|
|
+/// <para>背景:该表自 1.0.130 建表起,唯一键是 (source_system, source_table, source_biz_key),
|
|
|
+/// 三列全部与租户无关,而贴源写入是 <c>ON DUPLICATE KEY UPDATE ... tenant_id=VALUES(tenant_id)</c>
|
|
|
+/// (MdpStagingWriter.cs)。⇒ 两个租户的同名单号会落在同一行,后写者覆盖 raw_data
|
|
|
+/// 并把 tenant_id 一并改写成自己,租户归属被静默篡改且不留痕迹。
|
|
|
+/// 单号生成器 <c>SeOrderService.GenerateBillNoAsync</c> 是「每租户各自从 0001 起编」,
|
|
|
+/// 前缀无租户分量 ⇒ 同日建单的两个租户必然撞号,这不是理论风险。</para>
|
|
|
+///
|
|
|
+/// <para>为什么是源文本契约测试而不是跑库集成测试:本仓 DataPlatform/ 下既有测试
|
|
|
+/// (MdpStandardUpsertContractTests / T8KpiTenantIsolationContractTests /
|
|
|
+/// S1RequirementExamineDwdContractTests)一律用这种方式守护数据中台 SQL,
|
|
|
+/// 单测环境不连库。这里沿用同一范式。</para>
|
|
|
+/// </summary>
|
|
|
+public class MdpStagingSoTenantKeyContractTests
|
|
|
+{
|
|
|
+ private const string TenantSafeKey =
|
|
|
+ "ADD UNIQUE KEY `uk_source_key` (`tenant_id`,`source_system`,`source_table`,`source_biz_key`)";
|
|
|
+
|
|
|
+ private static string ScriptsDir()
|
|
|
+ {
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
+ while (dir != null)
|
|
|
+ {
|
|
|
+ var scripts = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts");
|
|
|
+ if (Directory.Exists(scripts)) return scripts;
|
|
|
+ dir = dir.Parent;
|
|
|
+ }
|
|
|
+ throw new DirectoryNotFoundException("找不到 UpdateScripts 目录");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 按<b>内容</b>而不是按文件名定位本批迁移:本仓版本号是「提交那一刻才取号」的,
|
|
|
+ /// 文件名在合并前后都可能顺延,写死 1.0.NNN.sql 的测试会因与被测契约无关的原因集体变红。
|
|
|
+ /// </summary>
|
|
|
+ private static string MigrationPath()
|
|
|
+ {
|
|
|
+ // 注意:通配 "1.0.*.sql" 也会命中 "1.0.NNN.verify.sql"(它同样以 .sql 结尾),必须排除。
|
|
|
+ var hit = Directory.GetFiles(ScriptsDir(), "1.0.*.sql")
|
|
|
+ .Where(f => !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.Ordinal))
|
|
|
+ .Where(f => File.ReadAllText(f).Contains("mdp_stg_so", StringComparison.Ordinal)
|
|
|
+ && File.ReadAllText(f).Contains(TenantSafeKey, StringComparison.Ordinal))
|
|
|
+ .OrderBy(f => f, StringComparer.Ordinal)
|
|
|
+ .LastOrDefault();
|
|
|
+ if (hit == null)
|
|
|
+ throw new FileNotFoundException("找不到把 mdp_stg_so.uk_source_key 改为租户安全键的迁移脚本");
|
|
|
+ return hit;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// verify 脚本按<b>路径约定</b>取,与 AutoVersionUpdate 自己的取法一致
|
|
|
+ /// (Update/AutoVersionUpdate.cs 用 <c>Path.ChangeExtension(FilePath, ".verify.sql")</c>)。
|
|
|
+ /// 不按内容找:verify 里不会出现建索引的 DDL 原文。
|
|
|
+ /// </summary>
|
|
|
+ private static string VerifyPath()
|
|
|
+ {
|
|
|
+ var path = Path.ChangeExtension(MigrationPath(), ".verify.sql");
|
|
|
+ if (!File.Exists(path))
|
|
|
+ throw new FileNotFoundException($"迁移缺少配套 verify 脚本:{path}");
|
|
|
+ return path;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string Migration() => File.ReadAllText(MigrationPath());
|
|
|
+
|
|
|
+ private static string Verify() => File.ReadAllText(VerifyPath());
|
|
|
+
|
|
|
+ private static string Writer() => File.ReadAllText(FindFile(
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "Executors", "MdpStagingWriter.cs"));
|
|
|
+
|
|
|
+ private static string FindFile(params string[] parts)
|
|
|
+ {
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
+ while (dir != null)
|
|
|
+ {
|
|
|
+ var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
|
|
|
+ if (File.Exists(candidate)) return candidate;
|
|
|
+ dir = dir.Parent;
|
|
|
+ }
|
|
|
+ throw new FileNotFoundException($"找不到 {string.Join('/', parts)}");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 1 · 新键必须含 tenant_id,且 tenant_id 在最前 ────────────────────────────────
|
|
|
+ // 列序不是可有可无的:tenant_id 放末列虽然唯一性等价,
|
|
|
+ // 却让该索引无法为「租户内检索」提供最左前缀。
|
|
|
+ [Fact]
|
|
|
+ public void Migration_RekeysStagingSo_WithTenantIdFirst()
|
|
|
+ {
|
|
|
+ var migration = Migration();
|
|
|
+
|
|
|
+ Assert.Contains(TenantSafeKey, migration);
|
|
|
+ Assert.Contains("DROP INDEX `uk_source_key`", migration);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 2 · DROP 与 ADD 必须在同一条 ALTER 里 ────────────────────────────────────────
|
|
|
+ // AutoVersionUpdate 按 ; 拆句逐条执行且全程无事务。若拆成两句,中间一旦失败,
|
|
|
+ // 表会停在「完全没有唯一键」的状态 —— upsert 的 ON DUPLICATE KEY 整个失效,
|
|
|
+ // 贴源从「覆盖」退化为「无限追加」,比原缺陷更糟。
|
|
|
+ [Fact]
|
|
|
+ public void Migration_DropAndAdd_AreASingleAtomicStatement()
|
|
|
+ {
|
|
|
+ var migration = Migration();
|
|
|
+
|
|
|
+ var drop = migration.IndexOf("DROP INDEX `uk_source_key`", StringComparison.Ordinal);
|
|
|
+ var add = migration.IndexOf(TenantSafeKey, StringComparison.Ordinal);
|
|
|
+ Assert.True(drop >= 0 && add > drop, "应先 DROP 再 ADD,且同处一条 ALTER");
|
|
|
+
|
|
|
+ var between = migration.Substring(drop, add - drop);
|
|
|
+ Assert.DoesNotContain(";", between);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 3 · 幂等 / 有护栏 ────────────────────────────────────────────────────────────
|
|
|
+ // 重跑时必须整体落空操作,而不是再 DROP 一次(索引已不是旧形状,DROP 会报错并打挂启动)。
|
|
|
+ [Fact]
|
|
|
+ public void Migration_IsGuardedAndIdempotent()
|
|
|
+ {
|
|
|
+ var migration = Migration();
|
|
|
+
|
|
|
+ // 先探当前索引形状,再决定走哪条分支
|
|
|
+ Assert.Contains("information_schema.STATISTICS", migration);
|
|
|
+ Assert.Contains("INDEX_NAME = 'uk_source_key'", migration);
|
|
|
+ // 不满足前提时必须落空操作
|
|
|
+ Assert.Contains("'DO 0'", migration);
|
|
|
+ Assert.Contains("PREPARE stmt FROM @sql", migration);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 4 · 加宽唯一键前必须先处理存量 ───────────────────────────────────────────────
|
|
|
+ // 正常路径下(旧窄键仍在)加宽不可能失败,去重是 0 行空操作;
|
|
|
+ // 但对「唯一键曾被人工 DROP 掉」的库,存量可能已有重复,缺了这步 ADD UNIQUE 会直接失败。
|
|
|
+ [Fact]
|
|
|
+ public void Migration_DedupesByNewKey_BeforeAddingUniqueIndex()
|
|
|
+ {
|
|
|
+ var migration = Migration();
|
|
|
+
|
|
|
+ var dedup = migration.IndexOf("ROW_NUMBER() OVER", StringComparison.Ordinal);
|
|
|
+ Assert.True(dedup >= 0, "应包含按新键去重的兜底");
|
|
|
+
|
|
|
+ // 去重必须按「新键」分组,含 tenant_id;按旧窄键去重会跨租户删掉正当数据
|
|
|
+ Assert.Contains("PARTITION BY `tenant_id`,`source_system`,`source_table`,`source_biz_key`", migration);
|
|
|
+
|
|
|
+ Assert.True(dedup < migration.IndexOf(TenantSafeKey, StringComparison.Ordinal),
|
|
|
+ "去重必须发生在 ADD UNIQUE 之前");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 5 · verify 必须真的钉住隔离,而不只是钉住这一个索引名 ────────────────────────
|
|
|
+ // 任何一个漏了 tenant_id 的唯一索引都会重新成为 ON DUPLICATE KEY 的冲突判据,
|
|
|
+ // 把跨租户覆盖路径再打开一次。
|
|
|
+ [Fact]
|
|
|
+ public void Verify_AssertsNoTenantlessUniqueIndexRemains()
|
|
|
+ {
|
|
|
+ var verify = Verify();
|
|
|
+
|
|
|
+ Assert.Contains("'tenant_id,source_system,source_table,source_biz_key'", verify);
|
|
|
+ Assert.Contains("NON_UNIQUE = 0", verify);
|
|
|
+ Assert.Contains("SUM(COLUMN_NAME = 'tenant_id') = 0", verify);
|
|
|
+
|
|
|
+ // tenant_id 可为 NULL 会让唯一索引把多个 NULL 视为互不相同,隔离与去重同时失效,
|
|
|
+ // 故 1.0.356 建立的作用域 CHECK 必须仍在。
|
|
|
+ Assert.Contains("ck_mdp_stg_so_valid_scope", verify);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 6 · 贴源写入契约仍要求 tenant_id 列存在 ──────────────────────────────────────
|
|
|
+ // 键里有 tenant_id 但列被移除/不再写入,隔离等于没做。
|
|
|
+ [Fact]
|
|
|
+ public void StagingWriter_StillRequiresTenantIdColumn()
|
|
|
+ {
|
|
|
+ var writer = Writer();
|
|
|
+
|
|
|
+ Assert.Contains("\"tenant_id\"", writer);
|
|
|
+ Assert.Contains("RequiredCanonicalColumns", writer);
|
|
|
+ // upsert 冲突时会改写 tenant_id —— 正因如此,冲突键必须含 tenant_id
|
|
|
+ Assert.Contains("tenant_id=VALUES(tenant_id)", writer);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 7 · 标准层不得被反向「对齐」到窄键 ───────────────────────────────────────────
|
|
|
+ // 本批的意义是消除贴源层与标准层的口径不一致;mdp_std_so 从 1.0.130 起就是对的,
|
|
|
+ // 不能有人为了「统一」把它改窄。
|
|
|
+ [Fact]
|
|
|
+ public void Verify_PinsStandardLayerKeyToo()
|
|
|
+ {
|
|
|
+ Assert.Contains("mdp_std_so", Verify());
|
|
|
+ }
|
|
|
+}
|