|
|
@@ -0,0 +1,195 @@
|
|
|
+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);
|
|
|
+ }
|
|
|
+}
|