using System.Reflection;
using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
using Admin.NET.Plugin.AiDOP.Entity.SmartOps;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using Furion.Schedule;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.Infrastructure;
///
/// 工作包 B 的守卫测试:ETL 执行机闸门与领取过滤。
///
/// 背景:2026-09-24 aidopdev 连接耗尽(1040)。治理后执行机身份改为
/// 数据库驱动 + 人工指派,本组测试守住四条不变式,防止后续改动无声回归。
/// 详见 doc/plan/165-aidopdev连接耗尽根治执行任务书.md 工作包 B。
///
/// 本测试项目没有数据库夹具(见 csproj:只有 xunit + 项目引用),
/// 故涉及 SQL 的部分用「反射断言契约 + 源码扫描断言落地」两道,
/// 纯内存部分(闸门求值、回退)则做真实行为断言。
///
public class AidopJobGateTests : IDisposable
{
private readonly string _originalEnv;
public AidopJobGateTests()
{
_originalEnv = Environment.GetEnvironmentVariable(AidopJobGate.EnvEnabledName);
AidopRunnerState.ResetForTests();
}
public void Dispose()
{
Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, _originalEnv);
AidopRunnerState.ResetForTests();
}
// ───────────────────── 1. 所有 IJob 必须过闸门 ─────────────────────
///
/// 扫描插件里**所有** 实现类,断言其源码包含
/// AidopJobGate.ShouldRun。
///
/// 2026-09-25 实测 21 个实现类**全部**已满足,故本测试首次运行就应为绿。
/// 若它变红,几乎一定是有人新增了 Job 却忘了写闸门——那台非执行机会立刻开始
/// 跑全量,正是本次事故的成因。**不要通过加例外名单来让它变绿。**
///
[Fact]
public void EveryJob_CallsGate()
{
var jobTypes = typeof(AidopJobGate).Assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IJob).IsAssignableFrom(t))
.OrderBy(t => t.Name)
.ToList();
Assert.True(jobTypes.Count >= 21,
$"IJob 实现类只找到 {jobTypes.Count} 个,少于 2026-09-25 实测的 21 个;" +
"若确属删除作业请更新此下限,若是反射失效请先查清");
var missing = new List();
foreach (var t in jobTypes)
{
var src = TryReadSource(t);
Assert.True(src != null, $"找不到 {t.Name} 的源码文件,测试的路径推断需同步更新");
if (!src.Contains("AidopJobGate.ShouldRun", StringComparison.Ordinal))
missing.Add(t.Name);
}
Assert.True(missing.Count == 0,
"以下 Job 未调用 AidopJobGate.ShouldRun,非执行机上会照常跑:" + string.Join(", ", missing));
}
// ───────────────────── 2. 领取过滤:非执行机只领手工 ─────────────────────
///
/// ClaimNextQueuedAsync 必须带 bool runner 参数。
/// 有人把它删掉、或改成可选参数并默认 true,非执行机就会重新开始领 AUTO。
///
[Fact]
public void ClaimContract_HasRunnerParameter()
{
var m = typeof(IModuleRebuildJobStore).GetMethod(nameof(IModuleRebuildJobStore.ClaimNextQueuedAsync));
Assert.NotNull(m);
var p = m!.GetParameters().SingleOrDefault(x => x.Name == "runner");
Assert.True(p != null, "ClaimNextQueuedAsync 缺少 runner 参数");
Assert.Equal(typeof(bool), p!.ParameterType);
Assert.False(p.IsOptional, "runner 不得是可选参数:漏传时会静默按默认值放行");
}
/// 手工判定的规范定义。非执行机只能领这一类。
[Theory]
[InlineData("MANUAL", null, true)]
[InlineData("manual", null, true)] // 大小写不敏感
[InlineData("AUTO", null, false)]
[InlineData("BOOTSTRAP", null, false)]
[InlineData("AUTO_NIGHTLY", null, false)] // 工作包 C 将新增,必须天然落在白名单外
[InlineData("AUTO", 1001L, true)] // 带 requested_by 即视为人工触发
public void IsManualTrigger_Semantics(string triggerType, long? requestedBy, bool expected)
{
Assert.Equal(expected, ModuleRebuildTriggerType.IsManualTrigger(triggerType, requestedBy));
}
///
/// 规范定义必须真的落到 SQL 上。表达式树要翻译成 SQL,无法直接复用
/// IsManualTrigger,故这里扫源码确认两边没有走散。
///
[Fact]
public void ClaimQuery_AppliesManualWhitelistForNonRunner()
{
var src = ReadPluginSource("DataPlatform/MdpRebuild/ModuleRebuildStore.cs");
Assert.Contains("WhereIF(!runner", src, StringComparison.Ordinal);
Assert.Contains("ModuleRebuildTriggerType.Manual", src, StringComparison.Ordinal);
Assert.Contains("x.RequestedBy != null", src, StringComparison.Ordinal);
// 黑名单写法会在新增 AUTO_NIGHTLY 之类触发器时漏网,明令禁止
Assert.DoesNotContain("!= ModuleRebuildTriggerType.Auto", src, StringComparison.Ordinal);
}
// ───────────────────── 3. 指派唯一性由写入侧保证 ─────────────────────
///
/// 「最多一台执行机」现在由 ado_etl_runner_designation 的**结构**保证:
/// 主键固定为 ,表里放不下第二行。
///
/// 这条不变式没有被放弃,而是被更强的机制取代了。旧实现靠
/// AssignRunnerAsync 在同一事务内先清后置——那依赖调用方守规矩,
/// 且指派寄生在实例行上,进程重启就会丢(2026-09-28 的队列孤立事故)。
/// 现在并发指派会被主键与条件更新挡住。**不要改成按 instanceId 指派**。
///
[Fact]
public void RunnerDesignation_IsStructurallyUnique()
{
Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerSlotAsync)));
Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.GetDesignatedSlotAsync)));
// 指派必须接槽位,不得退回按实例指派
var assign = typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerSlotAsync))!;
Assert.Contains(assign.GetParameters(), p => p.Name == "slotCode");
Assert.DoesNotContain(assign.GetParameters(), p => p.Name == "instanceId");
var src = ReadPluginSource("Infrastructure/EtlInstanceStore.cs");
Assert.Contains("AdoEtlRunnerDesignation.SingletonId", src, StringComparison.Ordinal);
// 每一处读写都必须钉在那唯一一行上;漏掉 Where 会写出/读到第二行
var singletonRefs = src.Split("AdoEtlRunnerDesignation.SingletonId").Length - 1;
Assert.True(singletonRefs >= 3, $"引用单行主键的位置只有 {singletonRefs} 处,读写路径可能有遗漏");
}
///
/// 指派必须落在跨重启稳定的槽位上,不得再挂回进程级标识。
///
/// 根因守卫:instance_id 是「机器名:进程ID:启动时间」,
/// 把角色存在它上面意味着执行机每重启一次就必然丢指派、全库零台存活执行机、
/// 定时 ETL 静默停摆。2026-09-28 实测 28 条 AUTO_NIGHTLY 无人可领即由此而来。
/// 若有人把判定改回读 ado_etl_instance.is_runner,这条测试必须变红。
///
[Fact]
public void Registrar_ResolvesRunnerBySlot_NotByInstanceId()
{
var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
Assert.Contains("GetDesignatedSlotAsync", src, StringComparison.Ordinal);
Assert.Contains("AidopInstanceIdentity.SlotCode", src, StringComparison.Ordinal);
// 回读 is_runner 就是旧模型,必须彻底断开
Assert.DoesNotContain("x.IsRunner)", src, StringComparison.Ordinal);
Assert.DoesNotContain("Select(x => x.IsRunner", src, StringComparison.Ordinal);
}
///
/// 未声明槽位的进程永不是执行机。
///
/// 这比旧模型更严:旧模型下任何注册上来的进程都能被指派,
/// 开发机被指派正是 2026-09-24 连接耗尽事故的成因之一。
/// 判定里那条「槽位为空直接返回 false」的短路不得删除。
///
[Fact]
public void Registrar_SlotlessInstance_CanNeverBeRunner()
{
var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
Assert.Contains("string.IsNullOrEmpty(slot)", src, StringComparison.Ordinal);
// 槽位比对必须是精确相等,不能退化成「包含」或大小写不敏感的模糊匹配
Assert.Contains("string.Equals(slot, designated, StringComparison.Ordinal)", src, StringComparison.Ordinal);
}
///
/// 同槽位多个存活实例时失败关闭(两边都不跑),而不是抢着跑。
///
/// 没有租约就没有仲裁者,此时抢着跑等于双跑全量——那正是 2026-09-24
/// 连接耗尽的成因。代价只是滚动重启重叠的几十秒空窗。
/// 若将来要改成自动接管,必须先引入租约,不能简单地把这个判断删掉。
///
[Fact]
public void Registrar_SameSlotConflict_FailsClosed()
{
var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
Assert.Contains("others == 0", src, StringComparison.Ordinal);
Assert.Contains("x.InstanceId != instanceId", src, StringComparison.Ordinal);
}
///
/// 夜间自清与 QUEUED 回收都不得误伤页面手工任务。
///
/// ModuleRebuildTriggerType.IsManualTrigger 把带 requested_by
/// 的行也算人工触发,所以批量收口的 WHERE 必须同时带 RequestedBy == null;
/// 少了这一支,超管在监控页发起的重算会被夜间作业清掉。
///
[Fact]
public void BulkCancel_NeverTouchesManualOrRunning()
{
var store = typeof(IModuleRebuildJobStore);
Assert.NotNull(store.GetMethod("CancelUnstartedByTriggerAsync"));
Assert.NotNull(store.GetMethod("FailStaleQueuedAsync"));
var src = ReadPluginSource("DataPlatform/MdpRebuild/ModuleRebuildStore.cs");
// 锚在实现上,不能只匹配方法名:接口声明与实现签名同文,先命中声明会读到空的方法体
var idx = src.IndexOf("public async Task CancelUnstartedByTriggerAsync", StringComparison.Ordinal);
Assert.True(idx > 0, "找不到批量收口的实现,测试的定位串需同步更新");
var body = src[idx..Math.Min(src.Length, idx + 1600)];
Assert.Contains("x.Status == ModuleRebuildStatus.Queued", body, StringComparison.Ordinal);
Assert.Contains("x.RequestedBy == null", body, StringComparison.Ordinal);
}
///
/// QUEUED 收口阈值必须给足。GlobalMaxParallelScopes = 1 且单 scope 跑 10~20 分钟,
/// 夜间扇出 28 个 scope 时队尾**合法**等待可达 7 小时,阈值定短了会误杀正常排队。
///
[Fact]
public void QueuedStaleThreshold_LeavesRoomForLegitimateWaiting()
{
Assert.True(ModuleRebuildLock.QueuedStaleAfter >= TimeSpan.FromHours(12),
"QUEUED 收口阈值不足 12 小时,夜间全量队尾会被误杀;改小前请先核算 scope 数 × 单次耗时");
Assert.True(ModuleRebuildLock.QueuedStaleAfter > ModuleRebuildLock.StaleAfter,
"QUEUED 阈值必须明显宽于 RUNNING 无心跳阈值:前者是排队等待,后者是进程已死");
}
/// 注册器只报到、不自荐。自动选举已在 B-D1 明确否决。
[Fact]
public void Registrar_NeverSelfAssigns()
{
var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
Assert.Contains("IsRunner = false", src, StringComparison.Ordinal);
Assert.DoesNotContain("IsRunner = true", src, StringComparison.Ordinal);
}
// ───────────────────── 4. 指派未知时回退到配置 ─────────────────────
[Fact]
public void IsRunner_UsesAssignment_WhenFresh()
{
// 配置回退方向刻意设成相反值,确保断言命中的是指派而不是回退
Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false");
AidopRunnerState.Publish(true);
Assert.True(AidopJobGate.IsRunner);
Assert.True(AidopJobGate.IsAssignmentKnown);
AidopRunnerState.Publish(false);
Assert.False(AidopJobGate.IsRunner);
Assert.True(AidopJobGate.IsAssignmentKnown);
}
[Fact]
public void IsRunner_FallsBackToConfig_WhenAssignmentUnknown()
{
AidopRunnerState.Invalidate();
Assert.False(AidopJobGate.IsAssignmentKnown);
Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "true");
Assert.True(AidopJobGate.IsRunner);
Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false");
Assert.False(AidopJobGate.IsRunner);
}
///
/// 指派未知且配置缺失时不跑。
/// 曾是 true(「别静默停摆」),
/// 在人工指派模型下改成了 false:没指派就不跑是**预期行为**。
/// 改回 true 会让任意一台新起的进程擅自跑全量。
///
[Fact]
public void DefaultIsOff_WhenNothingConfigured()
{
Assert.False(AidopJobGate.DefaultEnabled);
AidopRunnerState.Invalidate();
Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, null);
// 宿主未就绪时 Furion.App.GetConfig 会抛,闸门内部吞掉并落到 DefaultEnabled
Assert.False(AidopJobGate.IsRunner);
}
/// 查库失败必须立即打回未知,不得沿用上一次的指派值。
[Fact]
public void Invalidate_DropsPreviousAssignment()
{
Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false");
AidopRunnerState.Publish(true);
Assert.True(AidopJobGate.IsRunner);
// 指派可能已被运维改掉,继续沿用会让两台实例同时自认执行机
AidopRunnerState.Invalidate();
Assert.False(AidopJobGate.IsAssignmentKnown);
Assert.False(AidopJobGate.IsRunner);
}
[Fact]
public void FreshnessWindow_CoversSeveralHeartbeats()
{
// 窗口需明显长于心跳周期,否则偶发一次心跳失败就会退化为配置回退
Assert.True(AidopRunnerState.FreshnessWindow >= EtlInstanceRegistrar.HeartbeatInterval * 2,
"新鲜度窗口至少要容纳两次心跳");
}
// ───────────────────── 源码定位 ─────────────────────
private static string PluginRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string ReadPluginSource(string relativePath)
{
var full = Path.Combine(PluginRoot, relativePath);
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
///
/// 按类型名在插件目录下找同名 .cs。作业类都是「一类一文件」,
/// 找不到即说明命名约定被打破,此时应让测试失败而不是悄悄跳过。
///
private static string TryReadSource(Type t)
{
var hits = Directory.GetFiles(PluginRoot, t.Name + ".cs", SearchOption.AllDirectories);
return hits.Length == 0 ? null : File.ReadAllText(hits[0]);
}
}