using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Job; using Microsoft.Extensions.Configuration; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-P0-2-PER-JOB-KILL-SWITCH-1:三个 S8 后台 Job 的独立开关。 /// /// 修复前三个 Job 读**完全相同**的两个键,「只跑 Watch」在配置层不可表达。 /// 2026-09-02 受控启动时打开总开关同时放行了另外两个 Job,其中 /// ActiveFlowStuckScanRunOnStart = true)在重启瞬间写入 499 行告警日志; /// 当时唯一的隔离手段「先暂停 trigger 再重启」实测**跨重启不可靠**(两次结果相反)。 /// /// 本测试用真实 覆盖原 Prompt §九 的四组合真值表。 /// 这是 Gate 2 的代码侧证据;runtime 侧(重启后第一拍)留待统一窗口。 /// public class S8JobSwitchTests { private static IConfiguration Config(bool? environmentEnabled, bool master, bool watch, bool timeout, bool stuck) { var dict = new Dictionary { ["S8:Scheduler:Enabled"] = master.ToString(), ["S8:WatchScheduler:Enabled"] = watch.ToString(), ["S8:TimeoutAutoEscalation:Enabled"] = timeout.ToString(), ["S8:ActiveFlowStuckScan:Enabled"] = stuck.ToString(), }; if (environmentEnabled.HasValue) dict["Scheduler:Enabled"] = environmentEnabled.Value.ToString(); return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); } private static (bool Watch, bool Timeout, bool Stuck) Evaluate(IConfiguration cfg) => ( S8JobSwitch.Evaluate(cfg, S8BackgroundJob.WatchScheduler).Enabled, S8JobSwitch.Evaluate(cfg, S8BackgroundJob.TimeoutAutoEscalation).Enabled, S8JobSwitch.Evaluate(cfg, S8BackgroundJob.ActiveFlowStuckScan).Enabled); // ───────────────────────── 原 Prompt §九 四组合 ───────────────────────── /// Case A:Master=false,三个 Job 各自 true → 三个都不运行。 [Fact] public void CaseA_MasterOff_DisablesAllThreeJobs() { var (watch, timeout, stuck) = Evaluate(Config(true, master: false, watch: true, timeout: true, stuck: true)); Assert.False(watch); Assert.False(timeout); Assert.False(stuck); } /// Case B:Master=true,只有 Watch=true → 只有 Watch 运行。 [Fact] public void CaseB_OnlyWatchScheduler() { var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: true, timeout: false, stuck: false)); Assert.True(watch); Assert.False(timeout); Assert.False(stuck); } /// Case C:Master=true,只有 Timeout=true → 只有 Timeout 运行。 [Fact] public void CaseC_OnlyTimeoutAutoEscalation() { var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: true, stuck: false)); Assert.False(watch); Assert.True(timeout); Assert.False(stuck); } /// Case D:Master=true,只有 Stuck=true → 只有 StuckScan 运行。 [Fact] public void CaseD_OnlyActiveFlowStuckScan() { var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: false, stuck: true)); Assert.False(watch); Assert.False(timeout); Assert.True(stuck); } // ───────────────────────── 边界与默认 ───────────────────────── /// 环境级 master kill switch 优先于一切:Scheduler:Enabled=false → 全停。 [Fact] public void EnvironmentKillSwitch_OverridesEverything() { var (watch, timeout, stuck) = Evaluate(Config(false, master: true, watch: true, timeout: true, stuck: true)); Assert.False(watch); Assert.False(timeout); Assert.False(stuck); } /// /// 单 Job 键缺省时必须为 false(fail-safe):只配 Master=true 不足以让任何 Job 跑起来。 /// 宁可「本该跑却没跑」也不要「本不该跑却跑了」。 /// [Fact] public void MissingPerJobKey_DefaultsToDisabled() { var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Scheduler:Enabled"] = "true", ["S8:Scheduler:Enabled"] = "true", }).Build(); var (watch, timeout, stuck) = Evaluate(cfg); Assert.False(watch); Assert.False(timeout); Assert.False(stuck); } /// 环境级键缺省为 true,保持既有部署语义不变。 [Fact] public void MissingEnvironmentKey_DefaultsToEnabled() { var d = S8JobSwitch.Evaluate( Config(null, master: true, watch: true, timeout: false, stuck: false), S8BackgroundJob.WatchScheduler); Assert.True(d.EnvironmentEnabled); Assert.True(d.Enabled); } /// 三级与的每一级都要能单独否决。 [Theory] [InlineData(false, true, true, false)] [InlineData(true, false, true, false)] [InlineData(true, true, false, false)] [InlineData(true, true, true, true)] public void EffectiveIsConjunctionOfThreeLevels(bool env, bool master, bool own, bool expected) { var d = new S8JobSwitchDecision(env, master, own, null); Assert.Equal(expected, d.Enabled); } /// 键名与 env 名固化,防止后续改名导致部署侧静默失配。 [Theory] [InlineData(S8BackgroundJob.WatchScheduler, "S8:WatchScheduler:Enabled", "AIDOP_S8_WATCH_SCHEDULER_ENABLED")] [InlineData(S8BackgroundJob.TimeoutAutoEscalation, "S8:TimeoutAutoEscalation:Enabled", "AIDOP_S8_TIMEOUT_AUTO_ESCALATION_ENABLED")] [InlineData(S8BackgroundJob.ActiveFlowStuckScan, "S8:ActiveFlowStuckScan:Enabled", "AIDOP_S8_ACTIVE_FLOW_STUCK_SCAN_ENABLED")] public void ConfigKeysAndEnvNamesArePinned(S8BackgroundJob job, string key, string env) { Assert.Equal(key, S8JobSwitch.JobEnabledKey(job)); Assert.Equal(env, S8JobSwitch.JobEnvName(job)); } /// env 解析失败必须保留 JSON 值并回报 env 名(不吞、不静默改判)。 [Fact] public void UnparsableEnvOverride_KeepsJsonValueAndReportsName() { var name = S8JobSwitch.JobEnvName(S8BackgroundJob.WatchScheduler); var original = Environment.GetEnvironmentVariable(name); try { Environment.SetEnvironmentVariable(name, "not-a-bool"); var d = S8JobSwitch.Evaluate( Config(true, master: true, watch: true, timeout: false, stuck: false), S8BackgroundJob.WatchScheduler); Assert.True(d.JobEnabled); // 沿用 JSON 的 true Assert.Equal(name, d.ParseFailEnvName); // 但必须回报解析失败 } finally { Environment.SetEnvironmentVariable(name, original); } } // ───────────────────────── RunOnStart 硬门 ───────────────────────── /// /// ActiveFlowStuckScanRunOnStart = true,启动瞬间必然触发一次; /// 因此三个 Job 的 ExecuteAsync 都必须**先求值开关再做任何事**。 /// 断言:门禁调用出现在方法体内、且早于任何 CreateScope()(即拿 service 之前)。 /// [Theory] [InlineData(typeof(S8WatchSchedulerJob), "Job/S8WatchSchedulerJob.cs")] [InlineData(typeof(S8TimeoutAutoEscalationJob), "Job/S8TimeoutAutoEscalationJob.cs")] [InlineData(typeof(S8ActiveFlowStuckScanJob), "Job/S8ActiveFlowStuckScanJob.cs")] public void Gate_IsEvaluatedBeforeAnyServiceResolution(Type jobType, string relativePath) { _ = jobType; var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); var full = Path.Combine(root, relativePath); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); var src = File.ReadAllText(full); var gateIdx = src.IndexOf("S8JobSwitch.Evaluate(_configuration", StringComparison.Ordinal); var scopeIdx = src.IndexOf("_scopeFactory.CreateScope()", StringComparison.Ordinal); Assert.True(gateIdx > 0, "未找到 S8JobSwitch 门禁调用"); Assert.True(scopeIdx > 0, "未找到 CreateScope(Job 结构可能已变)"); Assert.True(gateIdx < scopeIdx, "开关求值必须早于任何 service 解析 / 业务扫描"); // 旧的双键写法必须彻底消失,避免有人只改一个 Job 造成三处漂移。 Assert.DoesNotContain("GetValue(\"S8:Scheduler:Enabled\"", src); Assert.DoesNotContain("GetValue(\"Scheduler:Enabled\"", src); } }