using System.Reflection; using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild; 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. 指派唯一性由写入侧保证 ───────────────────── /// /// 「最多一台执行机」靠 AssignRunnerAsync 在同一事务内先清后置。 /// MySQL 无部分索引,数据库层面表达不了这条约束;拆成两条自动提交语句, /// 中间那一瞬会出现零台或(并发时)两台执行机。 /// [Fact] public void AssignRunner_ClearsOthersInSameTransaction() { Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerAsync))); var src = ReadPluginSource("Infrastructure/EtlInstanceStore.cs"); Assert.Contains("UseTranAsync", src, StringComparison.Ordinal); var tranIdx = src.IndexOf("UseTranAsync", StringComparison.Ordinal); var clearIdx = src.IndexOf("x.IsRunner && x.InstanceId != id", StringComparison.Ordinal); var setIdx = src.IndexOf("IsRunner = true", StringComparison.Ordinal); Assert.True(clearIdx > tranIdx, "清空其余指派必须发生在事务内"); Assert.True(setIdx > clearIdx, "必须先清后置,否则会短暂出现两台执行机"); } /// 注册器只报到、不自荐。自动选举已在 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]); } }