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_log 写 RUNNING 的文件,都必须接了收口器。
///
/// 这条是本文件存在的主要理由:接线是 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);
}
}