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");
}
}