| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- 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;
- /// <summary>
- /// S8-P0-2-PER-JOB-KILL-SWITCH-1:三个 S8 后台 Job 的独立开关。
- ///
- /// <para>修复前三个 Job 读**完全相同**的两个键,「只跑 Watch」在配置层不可表达。
- /// 2026-09-02 受控启动时打开总开关同时放行了另外两个 Job,其中
- /// <c>ActiveFlowStuckScan</c>(<c>RunOnStart = true</c>)在重启瞬间写入 499 行告警日志;
- /// 当时唯一的隔离手段「先暂停 trigger 再重启」实测**跨重启不可靠**(两次结果相反)。</para>
- ///
- /// <para>本测试用真实 <see cref="IConfiguration"/> 覆盖原 Prompt §九 的四组合真值表。
- /// 这是 Gate 2 的代码侧证据;runtime 侧(重启后第一拍)留待统一窗口。</para>
- /// </summary>
- public class S8JobSwitchTests
- {
- private static IConfiguration Config(bool? environmentEnabled, bool master, bool watch, bool timeout, bool stuck)
- {
- var dict = new Dictionary<string, string?>
- {
- ["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 §九 四组合 ─────────────────────────
- /// <summary>Case A:Master=false,三个 Job 各自 true → 三个都不运行。</summary>
- [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);
- }
- /// <summary>Case B:Master=true,只有 Watch=true → 只有 Watch 运行。</summary>
- [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);
- }
- /// <summary>Case C:Master=true,只有 Timeout=true → 只有 Timeout 运行。</summary>
- [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);
- }
- /// <summary>Case D:Master=true,只有 Stuck=true → 只有 StuckScan 运行。</summary>
- [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);
- }
- // ───────────────────────── 边界与默认 ─────────────────────────
- /// <summary>环境级 master kill switch 优先于一切:Scheduler:Enabled=false → 全停。</summary>
- [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);
- }
- /// <summary>
- /// 单 Job 键缺省时必须为 false(fail-safe):只配 Master=true 不足以让任何 Job 跑起来。
- /// 宁可「本该跑却没跑」也不要「本不该跑却跑了」。
- /// </summary>
- [Fact]
- public void MissingPerJobKey_DefaultsToDisabled()
- {
- var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
- {
- ["Scheduler:Enabled"] = "true",
- ["S8:Scheduler:Enabled"] = "true",
- }).Build();
- var (watch, timeout, stuck) = Evaluate(cfg);
- Assert.False(watch);
- Assert.False(timeout);
- Assert.False(stuck);
- }
- /// <summary>环境级键缺省为 true,保持既有部署语义不变。</summary>
- [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);
- }
- /// <summary>三级与的每一级都要能单独否决。</summary>
- [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);
- }
- /// <summary>键名与 env 名固化,防止后续改名导致部署侧静默失配。</summary>
- [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));
- }
- /// <summary>env 解析失败必须保留 JSON 值并回报 env 名(不吞、不静默改判)。</summary>
- [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 硬门 ─────────────────────────
- /// <summary>
- /// <c>ActiveFlowStuckScan</c> 带 <c>RunOnStart = true</c>,启动瞬间必然触发一次;
- /// 因此三个 Job 的 <c>ExecuteAsync</c> 都必须**先求值开关再做任何事**。
- /// 断言:门禁调用出现在方法体内、且早于任何 <c>CreateScope()</c>(即拿 service 之前)。
- /// </summary>
- [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);
- }
- }
|