using System.Text.RegularExpressions; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// 迁移脚本的「登记」守卫:仓库里每一个版本号命名的 UpdateScripts/*.sql /// 都必须在 Admin.NET.Web.Entry.csproj 里有 CopyToOutputDirectory 条目。 /// /// 为什么需要:AutoVersionUpdate.LoadMigrationScripts 扫的是 /// AppContext.BaseDirectory/UpdateScripts,也就是**输出目录**。 /// 漏登记的脚本不会被复制过去,于是它「在仓库里、却永远不会执行」—— /// 没有报错、没有日志、部署看起来一切正常,直到有人发现表结构或数据没变。 /// 2026-09-28 的 1.0.589 就这么静默失效过一次:表和列是 SqlSugar CodeFirst 顺手建的, /// 脚本里的数据迁移部分一行都没跑。 /// /// 已有 S8LegacyNotifyDataRetirementTests.Csproj_RegistersScriptsAndVersionIsAtLeastScriptVersion /// 守着单个版本;本组把它推广到全部脚本,新增脚本无需再各写一条。 /// public class MigrationScriptRegistrationTests { /// /// 2026-09-28 本组守卫首次引入时**已经存在**的未登记脚本,即从未被 /// AutoVersionUpdate 执行过的脚本。列在这里只是为了让守卫能卡住**新增**遗漏, /// 不代表它们没问题。 /// /// 为什么不顺手补登记:补上之后,这 11 个从未跑过的迁移会在下一次启动时 /// 对现网库依次执行(含 1.0.463 的 BEFORE UPDATE 触发器、 /// 1.0.257 的 29 张 qms_* 加列)。其中至少 1.0.257 自己注明 /// 「Applied on aidopdev 2026-07-23」——是走配套 Python 手工执行的, /// 也就是说这些脚本各自的「该不该自动跑」需要逐个判断,不是一次批量登记能解决的。 /// 故此处只做登记留痕,处置另开任务。 /// /// 名单只允许变短,不允许变长:逐个核实并处置后从本列表删除; /// 若有人往里加新项,说明又漏登记了一个,应该去补 csproj 而不是加白名单。 /// private static readonly string[] KnownUnregisteredAsOf20260928 = [ "1.0.208.sql", "1.0.212.sql", "1.0.257.sql", "1.0.258.sql", "1.0.259.sql", "1.0.260.sql", "1.0.261.sql", "1.0.262.sql", "1.0.263.sql", "1.0.264.sql", "1.0.463.sql", ]; [Fact] public void EveryVersionedScript_IsRegisteredInCsproj() { var csproj = File.ReadAllText(CsprojPath()); var missing = new List(); foreach (var file in VersionedScripts()) { var name = Path.GetFileName(file); if (csproj.Contains($@"", StringComparison.Ordinal)) continue; if (KnownUnregisteredAsOf20260928.Contains(name, StringComparer.OrdinalIgnoreCase)) continue; missing.Add(name); } Assert.True(missing.Count == 0, "以下迁移脚本未在 csproj 登记 CopyToOutputDirectory,不会被复制到输出目录," + "AutoVersionUpdate 永远扫不到它们(静默失效):" + string.Join(", ", missing)); } /// /// 已知名单里的项必须真的还没登记。补登记后应从名单里删掉, /// 否则名单会腐烂成一份没人敢动的陈旧白名单。 /// [Fact] public void KnownUnregisteredList_HasNoStaleEntries() { var csproj = File.ReadAllText(CsprojPath()); var stale = KnownUnregisteredAsOf20260928 .Where(n => csproj.Contains($@"", StringComparison.Ordinal)) .ToList(); Assert.True(stale.Count == 0, "以下脚本已补登记,请从 KnownUnregisteredAsOf20260928 中删除:" + string.Join(", ", stale)); } /// /// 版本号必须不低于最大脚本编号,否则 AutoVersionUpdate 不会应用尾部那几个脚本。 /// [Fact] public void AssemblyVersion_IsAtLeastMaxScriptVersion() { var csproj = File.ReadAllText(CsprojPath()); var m = Regex.Match(csproj, @"([\d.]+)"); Assert.True(m.Success, "csproj 未找到 "); var appVersion = Version.Parse(m.Groups[1].Value); var maxScript = VersionedScripts() .Select(f => Version.Parse(Path.GetFileNameWithoutExtension(f))) .DefaultIfEmpty(new Version(0, 0, 0)) .Max(); Assert.True(appVersion >= maxScript, $"csproj 版本 {appVersion} 低于最大脚本编号 {maxScript},AutoVersionUpdate 不会应用它"); } /// /// 每个 *.verify.sql 都必须有同号的主脚本,并且同样要登记。 /// 只登记主脚本、漏掉 verify,会让部署后断言静默跳过。 /// [Fact] public void EveryVerifyScript_HasSiblingAndIsRegistered() { var csproj = File.ReadAllText(CsprojPath()); var problems = new List(); foreach (var file in Directory.GetFiles(ScriptsDir(), "*.verify.sql")) { var name = Path.GetFileName(file); var version = name[..^".verify.sql".Length]; if (!File.Exists(Path.Combine(ScriptsDir(), $"{version}.sql"))) problems.Add($"{name}(缺同号主脚本)"); if (!csproj.Contains($@"", StringComparison.Ordinal)) problems.Add($"{name}(未登记)"); } Assert.True(problems.Count == 0, "verify 脚本问题:" + string.Join(", ", problems)); } /// /// 与 AutoVersionUpdate.LoadMigrationScripts 的筛选口径保持一致: /// 排除 *.verify.sql,只取文件名能解析成版本号的(WIP 占位名不参与)。 /// private static IEnumerable VersionedScripts() => Directory.GetFiles(ScriptsDir(), "*.sql", SearchOption.TopDirectoryOnly) .Where(f => !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.OrdinalIgnoreCase)) .Where(f => Version.TryParse(Path.GetFileNameWithoutExtension(f), out _)); private static string ScriptsDir() => Path.Combine(EntryDir(), "UpdateScripts"); private static string CsprojPath() => Path.Combine(EntryDir(), "Admin.NET.Web.Entry.csproj"); private static string EntryDir() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md"))) dir = dir.Parent; Assert.NotNull(dir); return Path.Combine(dir!.FullName, "server", "Admin.NET.Web.Entry"); } }