MigrationScriptRegistrationTests.cs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. using System.Text.RegularExpressions;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  4. /// <summary>
  5. /// 迁移脚本的「登记」守卫:仓库里每一个版本号命名的 <c>UpdateScripts/*.sql</c>
  6. /// 都必须在 <c>Admin.NET.Web.Entry.csproj</c> 里有 <c>CopyToOutputDirectory</c> 条目。
  7. ///
  8. /// <para><b>为什么需要</b>:<c>AutoVersionUpdate.LoadMigrationScripts</c> 扫的是
  9. /// <c>AppContext.BaseDirectory/UpdateScripts</c>,也就是**输出目录**。
  10. /// 漏登记的脚本不会被复制过去,于是它「在仓库里、却永远不会执行」——
  11. /// 没有报错、没有日志、部署看起来一切正常,直到有人发现表结构或数据没变。
  12. /// 2026-09-28 的 1.0.589 就这么静默失效过一次:表和列是 SqlSugar CodeFirst 顺手建的,
  13. /// 脚本里的数据迁移部分一行都没跑。</para>
  14. ///
  15. /// <para>已有 <c>S8LegacyNotifyDataRetirementTests.Csproj_RegistersScriptsAndVersionIsAtLeastScriptVersion</c>
  16. /// 守着单个版本;本组把它推广到全部脚本,新增脚本无需再各写一条。</para>
  17. /// </summary>
  18. public class MigrationScriptRegistrationTests
  19. {
  20. /// <summary>
  21. /// 2026-09-28 本组守卫首次引入时**已经存在**的未登记脚本,即从未被
  22. /// <c>AutoVersionUpdate</c> 执行过的脚本。列在这里只是为了让守卫能卡住**新增**遗漏,
  23. /// <b>不代表它们没问题</b>。
  24. ///
  25. /// <para>为什么不顺手补登记:补上之后,这 11 个从未跑过的迁移会在下一次启动时
  26. /// 对现网库依次执行(含 <c>1.0.463</c> 的 <c>BEFORE UPDATE</c> 触发器、
  27. /// <c>1.0.257</c> 的 29 张 <c>qms_*</c> 加列)。其中至少 <c>1.0.257</c> 自己注明
  28. /// 「Applied on aidopdev 2026-07-23」——是走配套 Python 手工执行的,
  29. /// 也就是说这些脚本各自的「该不该自动跑」需要逐个判断,不是一次批量登记能解决的。
  30. /// 故此处只做登记留痕,处置另开任务。</para>
  31. ///
  32. /// <para><b>名单只允许变短,不允许变长</b>:逐个核实并处置后从本列表删除;
  33. /// 若有人往里加新项,说明又漏登记了一个,应该去补 csproj 而不是加白名单。</para>
  34. /// </summary>
  35. private static readonly string[] KnownUnregisteredAsOf20260928 =
  36. [
  37. "1.0.208.sql", "1.0.212.sql",
  38. "1.0.257.sql", "1.0.258.sql", "1.0.259.sql", "1.0.260.sql",
  39. "1.0.261.sql", "1.0.262.sql", "1.0.263.sql", "1.0.264.sql",
  40. "1.0.463.sql",
  41. ];
  42. [Fact]
  43. public void EveryVersionedScript_IsRegisteredInCsproj()
  44. {
  45. var csproj = File.ReadAllText(CsprojPath());
  46. var missing = new List<string>();
  47. foreach (var file in VersionedScripts())
  48. {
  49. var name = Path.GetFileName(file);
  50. if (csproj.Contains($@"<None Update=""UpdateScripts\{name}"">", StringComparison.Ordinal))
  51. continue;
  52. if (KnownUnregisteredAsOf20260928.Contains(name, StringComparer.OrdinalIgnoreCase))
  53. continue;
  54. missing.Add(name);
  55. }
  56. Assert.True(missing.Count == 0,
  57. "以下迁移脚本未在 csproj 登记 CopyToOutputDirectory,不会被复制到输出目录,"
  58. + "AutoVersionUpdate 永远扫不到它们(静默失效):" + string.Join(", ", missing));
  59. }
  60. /// <summary>
  61. /// 已知名单里的项必须真的还没登记。补登记后应从名单里删掉,
  62. /// 否则名单会腐烂成一份没人敢动的陈旧白名单。
  63. /// </summary>
  64. [Fact]
  65. public void KnownUnregisteredList_HasNoStaleEntries()
  66. {
  67. var csproj = File.ReadAllText(CsprojPath());
  68. var stale = KnownUnregisteredAsOf20260928
  69. .Where(n => csproj.Contains($@"<None Update=""UpdateScripts\{n}"">", StringComparison.Ordinal))
  70. .ToList();
  71. Assert.True(stale.Count == 0,
  72. "以下脚本已补登记,请从 KnownUnregisteredAsOf20260928 中删除:" + string.Join(", ", stale));
  73. }
  74. /// <summary>
  75. /// 版本号必须不低于最大脚本编号,否则 <c>AutoVersionUpdate</c> 不会应用尾部那几个脚本。
  76. /// </summary>
  77. [Fact]
  78. public void AssemblyVersion_IsAtLeastMaxScriptVersion()
  79. {
  80. var csproj = File.ReadAllText(CsprojPath());
  81. var m = Regex.Match(csproj, @"<Version>([\d.]+)</Version>");
  82. Assert.True(m.Success, "csproj 未找到 <Version>");
  83. var appVersion = Version.Parse(m.Groups[1].Value);
  84. var maxScript = VersionedScripts()
  85. .Select(f => Version.Parse(Path.GetFileNameWithoutExtension(f)))
  86. .DefaultIfEmpty(new Version(0, 0, 0))
  87. .Max();
  88. Assert.True(appVersion >= maxScript,
  89. $"csproj 版本 {appVersion} 低于最大脚本编号 {maxScript},AutoVersionUpdate 不会应用它");
  90. }
  91. /// <summary>
  92. /// 每个 <c>*.verify.sql</c> 都必须有同号的主脚本,并且同样要登记。
  93. /// 只登记主脚本、漏掉 verify,会让部署后断言静默跳过。
  94. /// </summary>
  95. [Fact]
  96. public void EveryVerifyScript_HasSiblingAndIsRegistered()
  97. {
  98. var csproj = File.ReadAllText(CsprojPath());
  99. var problems = new List<string>();
  100. foreach (var file in Directory.GetFiles(ScriptsDir(), "*.verify.sql"))
  101. {
  102. var name = Path.GetFileName(file);
  103. var version = name[..^".verify.sql".Length];
  104. if (!File.Exists(Path.Combine(ScriptsDir(), $"{version}.sql")))
  105. problems.Add($"{name}(缺同号主脚本)");
  106. if (!csproj.Contains($@"<None Update=""UpdateScripts\{name}"">", StringComparison.Ordinal))
  107. problems.Add($"{name}(未登记)");
  108. }
  109. Assert.True(problems.Count == 0, "verify 脚本问题:" + string.Join(", ", problems));
  110. }
  111. /// <summary>
  112. /// 与 <c>AutoVersionUpdate.LoadMigrationScripts</c> 的筛选口径保持一致:
  113. /// 排除 <c>*.verify.sql</c>,只取文件名能解析成版本号的(WIP 占位名不参与)。
  114. /// </summary>
  115. private static IEnumerable<string> VersionedScripts() =>
  116. Directory.GetFiles(ScriptsDir(), "*.sql", SearchOption.TopDirectoryOnly)
  117. .Where(f => !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.OrdinalIgnoreCase))
  118. .Where(f => Version.TryParse(Path.GetFileNameWithoutExtension(f), out _));
  119. private static string ScriptsDir() =>
  120. Path.Combine(EntryDir(), "UpdateScripts");
  121. private static string CsprojPath() =>
  122. Path.Combine(EntryDir(), "Admin.NET.Web.Entry.csproj");
  123. private static string EntryDir()
  124. {
  125. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  126. while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
  127. dir = dir.Parent;
  128. Assert.NotNull(dir);
  129. return Path.Combine(dir!.FullName, "server", "Admin.NET.Web.Entry");
  130. }
  131. }