|
@@ -0,0 +1,156 @@
|
|
|
|
|
+using Xunit;
|
|
|
|
|
+
|
|
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// S8-LEGACY-NOTIFY-DECOMMISSION-1 · 收尾:旧通知分层<b>数据</b>的退役脚本契约。
|
|
|
|
|
+///
|
|
|
|
|
+/// <para><b>为什么用静态契约测试而不是跑真库</b>:这条迁移只有一句 DELETE,
|
|
|
|
|
+/// 而它的风险恰恰不在「能不能删掉」,在「会不会顺手删错表」。
|
|
|
|
|
+/// 一条 DELETE 写错表名的代价是把当前唯一在用的通知配置或规则责任池清空 ——
|
|
|
|
|
+/// 那种损失没有回滚点。因此这里守的是<b>脚本边界</b>:只准碰一张表、只准 DELETE、
|
|
|
|
|
+/// 不准出现任何 DDL。</para>
|
|
|
|
|
+///
|
|
|
|
|
+/// <para>真库执行由 <c>AutoVersionUpdate</c> 在部署时完成,verify 脚本会当场断言残留为 0。
|
|
|
|
|
+/// 本批<b>没有</b>、也不应该为了"证明删得掉"去动 shared DB。</para>
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class S8LegacyNotifyDataRetirementTests
|
|
|
|
|
+{
|
|
|
|
|
+ private const string Version = "1.0.505";
|
|
|
|
|
+
|
|
|
|
|
+ private static string ScriptsDir()
|
|
|
|
|
+ {
|
|
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
|
|
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
|
|
|
|
|
+ dir = dir.Parent;
|
|
|
|
|
+ Assert.NotNull(dir);
|
|
|
|
|
+ return Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static string Read(string fileName)
|
|
|
|
|
+ {
|
|
|
|
|
+ var path = Path.Combine(ScriptsDir(), fileName);
|
|
|
|
|
+ Assert.True(File.Exists(path), $"升级脚本不存在:{path}");
|
|
|
|
|
+ return File.ReadAllText(path);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>去掉 `--` 注释行 —— 注释里写了某个词不等于脚本会执行它。</summary>
|
|
|
|
|
+ private static string SqlOnly(string fileName) =>
|
|
|
|
|
+ string.Join('\n', Read(fileName).Split('\n')
|
|
|
|
|
+ .Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>迁移与 verify 两个文件都必须存在(项目既有规范)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void MigrationAndVerifyScriptsExist()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.False(string.IsNullOrWhiteSpace(Read($"{Version}.sql")));
|
|
|
|
|
+ Assert.False(string.IsNullOrWhiteSpace(Read($"{Version}.verify.sql")));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 迁移<b>只删一张表</b>,且是纯数据操作。
|
|
|
|
|
+ /// <para>DDL 一律禁止:本批的边界是「清数据不删表」,表 / Entity / CRUD 留给 P1-C3。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Migration_OnlyDeletesLegacyNotificationLayer()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = SqlOnly($"{Version}.sql");
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains("DELETE FROM `ado_s8_notification_layer`;", sql);
|
|
|
|
|
+
|
|
|
|
|
+ // 只有这一条语句
|
|
|
|
|
+ Assert.Equal(1, sql.Split(';').Count(seg => !string.IsNullOrWhiteSpace(seg)));
|
|
|
|
|
+
|
|
|
|
|
+ // 任何 DDL 或结构变更都不允许
|
|
|
|
|
+ foreach (var banned in new[] { "DROP TABLE", "DROP ", "ALTER TABLE", "CREATE TABLE", "TRUNCATE", "RENAME", "INSERT INTO", "UPDATE " })
|
|
|
|
|
+ Assert.DoesNotContain(banned, sql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
+
|
|
|
|
|
+ // AutoVersionUpdate 会整份拒绝含 DELIMITER 的脚本
|
|
|
|
|
+ Assert.DoesNotContain("DELIMITER", Read($"{Version}.sql"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// <b>不得触碰任何其它表。</b>
|
|
|
|
|
+ /// <para>逐一点名而不是只看语句数:将来若有人在这里追加一条,
|
|
|
|
|
+ /// 这份名单会立刻指出他碰了哪张不该碰的表。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Migration_TouchesNoOtherTable()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = SqlOnly($"{Version}.sql");
|
|
|
|
|
+ foreach (var protectedTable in new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "ado_s8_notification_recipient", "ado_s8_watch_rule",
|
|
|
|
|
+ "ado_s8_rule_handler_user", "ado_s8_rule_responsibility_user",
|
|
|
|
|
+ "ado_s8_exception", "ado_s8_exception_type", "ado_s8_exception_action_role",
|
|
|
|
|
+ "ado_s8_scene_config", "SysRole", "SysUser", "SysUserRole",
|
|
|
|
|
+ "mdp_", "dim_",
|
|
|
|
|
+ })
|
|
|
|
|
+ Assert.DoesNotContain(protectedTable, sql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 幂等:重跑就是再 DELETE 一次空表,影响 0 行、不报错。
|
|
|
|
|
+ /// <para>判据是「语句本身天然幂等」——不依赖任何 IF EXISTS 包装,
|
|
|
|
|
+ /// 也不依赖执行框架的去重(框架按 version + hash 记录,本就不会重复执行成功过的版本)。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Migration_IsNaturallyIdempotent()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = SqlOnly($"{Version}.sql").Trim();
|
|
|
|
|
+ Assert.StartsWith("DELETE FROM", sql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
+ Assert.DoesNotContain("WHERE", sql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// verify 断言残留为 0,并顺带守住「表还在、别的表也还在」。
|
|
|
|
|
+ /// <para>遵循仓库既有 verify 契约:每条语句自包含、返回布尔标量、禁止 <c>SET @var</c>。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Verify_AssertsZeroResidueAndNoCollateralDamage()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = SqlOnly($"{Version}.verify.sql");
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains("(SELECT COUNT(*) FROM `ado_s8_notification_layer`) = 0", sql);
|
|
|
|
|
+
|
|
|
|
|
+ // 表本身不得被删(本批只清数据)
|
|
|
|
|
+ Assert.Contains("TABLE_NAME = 'ado_s8_notification_layer') = 1", sql);
|
|
|
|
|
+
|
|
|
|
|
+ // 不该被动的表仍在
|
|
|
|
|
+ Assert.Contains("ado_s8_notification_recipient", sql);
|
|
|
|
|
+ Assert.Contains("ado_s8_rule_responsibility_user", sql);
|
|
|
|
|
+
|
|
|
|
|
+ // 契约:不得使用 SET @var(返回 null 会被判失败)。
|
|
|
|
|
+ // 只看去注释后的 SQL —— 契约说明本身就写着「禁止 SET @var」,查原文会自匹配。
|
|
|
|
|
+ Assert.DoesNotContain("SET @", sql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
+
|
|
|
|
|
+ // verify 只读,不得改数据
|
|
|
|
|
+ foreach (var banned in new[] { "DELETE", "INSERT", "UPDATE", "DROP", "ALTER", "CREATE" })
|
|
|
|
|
+ Assert.DoesNotContain(banned, sql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// csproj 必须同时满足:版本号 ≥ 本脚本编号,且两个脚本都注册了 Copy 条目。
|
|
|
|
|
+ /// <para>少了 Copy 条目脚本不会被复制到输出目录,<c>AutoVersionUpdate</c> 扫不到它 ——
|
|
|
|
|
+ /// 那是一种「文件在仓库里、但永远不会执行」的静默失效。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Csproj_RegistersScriptsAndVersionIsAtLeastScriptVersion()
|
|
|
|
|
+ {
|
|
|
|
|
+ var csprojPath = Path.Combine(
|
|
|
|
|
+ new DirectoryInfo(ScriptsDir()).Parent!.FullName, "Admin.NET.Web.Entry.csproj");
|
|
|
|
|
+ var csproj = File.ReadAllText(csprojPath);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains($@"<None Update=""UpdateScripts\{Version}.sql"">", csproj);
|
|
|
|
|
+ Assert.Contains($@"<None Update=""UpdateScripts\{Version}.verify.sql"">", csproj);
|
|
|
|
|
+
|
|
|
|
|
+ var m = System.Text.RegularExpressions.Regex.Match(csproj, @"<Version>([\d.]+)</Version>");
|
|
|
|
|
+ Assert.True(m.Success, "csproj 未找到 <Version>");
|
|
|
|
|
+ Assert.True(System.Version.Parse(m.Groups[1].Value) >= System.Version.Parse(Version),
|
|
|
|
|
+ $"csproj 版本 {m.Groups[1].Value} 低于脚本编号 {Version},AutoVersionUpdate 不会应用它");
|
|
|
|
|
+
|
|
|
|
|
+ // 三处版本号必须同号
|
|
|
|
|
+ foreach (var tag in new[] { "AssemblyVersion", "FileVersion", "Version" })
|
|
|
|
|
+ Assert.Contains($"<{tag}>{m.Groups[1].Value}</{tag}>", csproj);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|