using Admin.NET.Plugin.AiDOP.Infrastructure; using Microsoft.Extensions.Hosting; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// mdp_transform_run_log 关停收口的契约。 /// /// 要守什么:每个 transform producer 都先 INSERT 一行 RUNNING 再进 try。 /// 宿主关停时这一行必须落到一个**能与失败区分开**的终态,否则两件事同时坏掉: /// ① RUNNING + end_time IS NULL 会被 S8 Authority 当成「有更新的一轮在飞」, /// 把该 (job_code, tenant_id) 的健康判定长期按住; /// ② 若图省事记成 FAILED,"转换失败"这个信号就被关停噪声稀释, /// 真出问题时没人再信那个计数。 /// /// 为什么要有反回归段:本仓有 17 个 producer、39 处 run-log 生命周期, /// 且还在长出新的。靠"记得接线"守不住 —— 所以段③直接扫源码, /// 任何新增的 producer 只要写了 INSERT ... RUNNING 却没接收口器,这里就会红。 /// /// 覆盖边界:真正的 UPDATE 需要库,测试环境没有。故本文件守三样**不需要库**的东西: /// ① 收口语句的结构不变量(条件 UPDATE 的三个条件缺一不可); /// ② 不需要碰库就能判定的分支(未关停 / 非法 id 一律不写库); /// ③ 全量 producer 的接线覆盖。 /// public class TransformRunLogFinalizerContractTests { // ══════════════════ 段①:收口语句的结构不变量 ══════════════════ /// /// WHERE 必须同时带 status='RUNNING'end_time IS NULL。 /// 少任何一个,都会在「正常跑完的一轮」上生效 —— 把 SUCCESS 改写成 ABORTED。 /// 这是本次改动最危险的失手方式,且一旦发生静默无声。 /// [Fact] public void FinalizeSql_OnlyTouchesRowsThatNeverReachedTerminalState() { var sql = TransformRunLogFinalizer.FinalizeSql; var where = sql[sql.IndexOf("WHERE", StringComparison.Ordinal)..]; Assert.Contains("id=@Id", where); Assert.Contains("status='RUNNING'", where); Assert.Contains("end_time IS NULL", where); } /// /// 必须是按主键的单行 UPDATE。没有 id=@Id 的收口语句会横扫整表, /// 把别的租户、别的 job 正在跑的行一起收口掉。 /// [Fact] public void FinalizeSql_IsSingleRowByPrimaryKey() { var sql = TransformRunLogFinalizer.FinalizeSql; Assert.Contains("UPDATE mdp_transform_run_log", sql); Assert.Contains("id=@Id", sql); Assert.DoesNotContain("job_code", sql); Assert.DoesNotContain("start_time <", sql); } /// /// 必须写 end_time。这不只是补个字段:S8 Authority 的 in-flight 判据就是 /// end_time IS NULL,不写 end_time 等于没收口,Authority 照旧被按住。 /// [Fact] public void FinalizeSql_WritesEndTime_SoAuthorityNoLongerSeesItInFlight() { Assert.Contains("end_time=@EndTime", TransformRunLogFinalizer.FinalizeSql); } /// /// ABORTED 必须与既有取值都不相同。实库现存 6 种取值 /// (RUNNING / SUCCESS / SUCCESS_WITH_WARNING / SKIPPED_NO_CHANGE / FAILED / CANCELED), /// 其中 CANCELED 已被 S0Dim 占用为业务级取消 —— 复用它会让 /// 「基础设施关停」与「调用方主动放弃」永久混在一起,事后无法分开。 /// [Fact] public void AbortedStatus_DoesNotCollideWithExistingVocabulary() { string[] existing = ["RUNNING", "SUCCESS", "SUCCESS_WITH_WARNING", "SKIPPED_NO_CHANGE", "FAILED", "CANCELED"]; Assert.DoesNotContain(TransformRunLogFinalizer.StatusAborted, existing); Assert.Equal("ABORTED", TransformRunLogFinalizer.StatusAborted); Assert.Equal("HOST_SHUTDOWN", TransformRunLogFinalizer.ReasonHostShutdown); } /// /// 取值必须放得进 status varchar(30),否则写入会被截断或报错。 /// [Fact] public void AbortedStatus_FitsStatusColumn() { Assert.True(TransformRunLogFinalizer.StatusAborted.Length <= 30); } /// /// 收口必须走 CopyNew() 的新连接,并显式清掉 Ado.CancellationToken。 /// /// 这条是实测换来的,不是防御性写法:首版实现只是"不传 token", /// 冒烟时 IsHostStopping 为真、runLogId 也对,收口 UPDATE 却抛 /// OperationCanceledException,一行没落。原因是 SqlSugar 把 token 挂在 Ado provider 上, /// 转换过程中传过 token 的调用会把它留在客户端实例里;关停时它已取消, /// 于是后续每条 SQL 都被连坐。谁把这两句删掉,收口就会**静默失效** —— /// 没有异常、没有报错,只是又开始攒孤儿。 /// /// [Fact] public void Finalize_RunsOnAFreshConnection_NotTheCallersCancelledOne() { var src = File.ReadAllText(Path.Combine( PluginDir().FullName, "Infrastructure", "TransformRunLogFinalizer.cs")); var body = src[src.IndexOf("public async Task FinalizeIfHostStoppingAsync", StringComparison.Ordinal)..]; Assert.Contains("CopyNew()", body); Assert.Contains("Ado.CancellationToken = null", body); Assert.DoesNotContain("await _db.Ado.ExecuteCommandAsync", body); } // ══════════════════ 段②:不碰库就能定下来的分支 ══════════════════ private sealed class FakeLifetime : IHostApplicationLifetime { private readonly CancellationTokenSource _stopping = new(); public CancellationToken ApplicationStarted => CancellationToken.None; public CancellationToken ApplicationStopping => _stopping.Token; public CancellationToken ApplicationStopped => CancellationToken.None; public void StopApplication() => _stopping.Cancel(); } /// /// db 是**刻意**的:这些分支一旦碰库就会 NRE。 /// 用它来证明「没关停就绝不写库」,比断言一个 mock 的调用次数更硬。 /// private static TransformRunLogFinalizer Finalizer(IHostApplicationLifetime lifetime) => new(null!, lifetime, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); [Fact] public void IsHostStopping_TracksApplicationStopping_NotAnyCancellationToken() { var lifetime = new FakeLifetime(); var finalizer = Finalizer(lifetime); Assert.False(finalizer.IsHostStopping); lifetime.StopApplication(); Assert.True(finalizer.IsHostStopping); } /// /// 宿主没关停 → 一行都不许改。客户端断开、业务超时、普通异常都走这条路, /// 它们各自已有自己的终态,收口器不得插手。 /// [Fact] public async Task Finalize_DoesNothing_WhenHostIsNotStopping() { var finalizer = Finalizer(new FakeLifetime()); Assert.False(await finalizer.FinalizeIfHostStoppingAsync(12345L, DateTime.Now)); } /// /// 没有有效 runLogId(INSERT 还没成功就被关停)时直接返回,不得拿 0 / 负数去 UPDATE。 /// [Theory] [InlineData(0L)] [InlineData(-1L)] public async Task Finalize_DoesNothing_WhenRunLogIdIsNotUsable(long runLogId) { var lifetime = new FakeLifetime(); lifetime.StopApplication(); Assert.False(await Finalizer(lifetime).FinalizeIfHostStoppingAsync(runLogId, DateTime.Now)); } /// /// 收口器**永不抛异常**。它总是从 finally / catch 里被调用, /// 一旦抛出就会顶掉真正的业务异常,让「转换为什么断了」永久丢失。 /// 这里让它在关停状态下拿 null 的 db 去写库 —— 内部必然出错,但对外必须是 false。 /// [Fact] public async Task Finalize_SwallowsItsOwnFailure_SoItNeverMasksTheOriginalException() { var lifetime = new FakeLifetime(); lifetime.StopApplication(); var result = await Finalizer(lifetime).FinalizeIfHostStoppingAsync(12345L, DateTime.Now); Assert.False(result); } // ══════════════════ 段③:全量 producer 接线的反回归 ══════════════════ private static DirectoryInfo PluginDir() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP"))) dir = dir.Parent; Assert.NotNull(dir); return new DirectoryInfo(Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP")); } /// /// 凡是往 mdp_transform_run_logRUNNING 的文件,都必须接了收口器。 /// /// 这条是本文件存在的主要理由:接线是 39 处的机械改动, /// 将来新增 producer 的人几乎一定不会读到本批的结论。让编译器管不着的事由测试管。 /// [Fact] public void EveryProducerThatOpensARunLog_IsWiredToTheFinalizer() { var missing = new List(); var wired = 0; foreach (var file in PluginDir().GetFiles("*.cs", SearchOption.AllDirectories)) { var text = File.ReadAllText(file.FullName); if (!text.Contains("INSERT INTO mdp_transform_run_log", StringComparison.Ordinal)) continue; if (text.Contains("_runLogFinalizer", StringComparison.Ordinal)) wired++; else missing.Add(file.Name); } Assert.True(wired > 0, "一个 producer 都没扫到,说明扫描口径已失效(INSERT 写法或表名变了)"); Assert.Empty(missing); } /// /// 每一处 MarkXxxRunFailedAsync 都必须被 IsHostStopping 挡过。 /// 漏掉一处,那条路径上的关停就会继续被记成 FAILED —— 而这正是本批要消除的语义污染。 /// [Fact] public void EveryFailureMarking_IsGuardedAgainstHostShutdown() { var unguarded = new List(); foreach (var file in PluginDir().GetFiles("*.cs", SearchOption.AllDirectories)) { var lines = File.ReadAllLines(file.FullName); for (var i = 0; i < lines.Length; i++) { var l = lines[i]; var isFailureCall = l.Contains("await Mark", StringComparison.Ordinal) && l.Contains("RunFailedAsync(", StringComparison.Ordinal) || l.Contains("await FailRunLogAsync(", StringComparison.Ordinal); if (!isFailureCall) continue; // 守卫就在上一行;private 方法自身的定义不算调用点。 if (i > 0 && lines[i - 1].Contains("!_runLogFinalizer.IsHostStopping", StringComparison.Ordinal)) continue; unguarded.Add($"{file.Name}:{i + 1}"); } } Assert.Empty(unguarded); } }