S8JobSwitchTests.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  2. using Admin.NET.Plugin.AiDOP.Job;
  3. using Microsoft.Extensions.Configuration;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  6. /// <summary>
  7. /// S8-P0-2-PER-JOB-KILL-SWITCH-1:三个 S8 后台 Job 的独立开关。
  8. ///
  9. /// <para>修复前三个 Job 读**完全相同**的两个键,「只跑 Watch」在配置层不可表达。
  10. /// 2026-09-02 受控启动时打开总开关同时放行了另外两个 Job,其中
  11. /// <c>ActiveFlowStuckScan</c>(<c>RunOnStart = true</c>)在重启瞬间写入 499 行告警日志;
  12. /// 当时唯一的隔离手段「先暂停 trigger 再重启」实测**跨重启不可靠**(两次结果相反)。</para>
  13. ///
  14. /// <para>本测试用真实 <see cref="IConfiguration"/> 覆盖原 Prompt §九 的四组合真值表。
  15. /// 这是 Gate 2 的代码侧证据;runtime 侧(重启后第一拍)留待统一窗口。</para>
  16. /// </summary>
  17. public class S8JobSwitchTests
  18. {
  19. private static IConfiguration Config(bool? environmentEnabled, bool master, bool watch, bool timeout, bool stuck)
  20. {
  21. var dict = new Dictionary<string, string?>
  22. {
  23. ["S8:Scheduler:Enabled"] = master.ToString(),
  24. ["S8:WatchScheduler:Enabled"] = watch.ToString(),
  25. ["S8:TimeoutAutoEscalation:Enabled"] = timeout.ToString(),
  26. ["S8:ActiveFlowStuckScan:Enabled"] = stuck.ToString(),
  27. };
  28. if (environmentEnabled.HasValue) dict["Scheduler:Enabled"] = environmentEnabled.Value.ToString();
  29. return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
  30. }
  31. private static (bool Watch, bool Timeout, bool Stuck) Evaluate(IConfiguration cfg) => (
  32. S8JobSwitch.Evaluate(cfg, S8BackgroundJob.WatchScheduler).Enabled,
  33. S8JobSwitch.Evaluate(cfg, S8BackgroundJob.TimeoutAutoEscalation).Enabled,
  34. S8JobSwitch.Evaluate(cfg, S8BackgroundJob.ActiveFlowStuckScan).Enabled);
  35. // ───────────────────────── 原 Prompt §九 四组合 ─────────────────────────
  36. /// <summary>Case A:Master=false,三个 Job 各自 true → 三个都不运行。</summary>
  37. [Fact]
  38. public void CaseA_MasterOff_DisablesAllThreeJobs()
  39. {
  40. var (watch, timeout, stuck) = Evaluate(Config(true, master: false, watch: true, timeout: true, stuck: true));
  41. Assert.False(watch);
  42. Assert.False(timeout);
  43. Assert.False(stuck);
  44. }
  45. /// <summary>Case B:Master=true,只有 Watch=true → 只有 Watch 运行。</summary>
  46. [Fact]
  47. public void CaseB_OnlyWatchScheduler()
  48. {
  49. var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: true, timeout: false, stuck: false));
  50. Assert.True(watch);
  51. Assert.False(timeout);
  52. Assert.False(stuck);
  53. }
  54. /// <summary>Case C:Master=true,只有 Timeout=true → 只有 Timeout 运行。</summary>
  55. [Fact]
  56. public void CaseC_OnlyTimeoutAutoEscalation()
  57. {
  58. var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: true, stuck: false));
  59. Assert.False(watch);
  60. Assert.True(timeout);
  61. Assert.False(stuck);
  62. }
  63. /// <summary>Case D:Master=true,只有 Stuck=true → 只有 StuckScan 运行。</summary>
  64. [Fact]
  65. public void CaseD_OnlyActiveFlowStuckScan()
  66. {
  67. var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: false, stuck: true));
  68. Assert.False(watch);
  69. Assert.False(timeout);
  70. Assert.True(stuck);
  71. }
  72. // ───────────────────────── 边界与默认 ─────────────────────────
  73. /// <summary>环境级 master kill switch 优先于一切:Scheduler:Enabled=false → 全停。</summary>
  74. [Fact]
  75. public void EnvironmentKillSwitch_OverridesEverything()
  76. {
  77. var (watch, timeout, stuck) = Evaluate(Config(false, master: true, watch: true, timeout: true, stuck: true));
  78. Assert.False(watch);
  79. Assert.False(timeout);
  80. Assert.False(stuck);
  81. }
  82. /// <summary>
  83. /// 单 Job 键缺省时必须为 false(fail-safe):只配 Master=true 不足以让任何 Job 跑起来。
  84. /// 宁可「本该跑却没跑」也不要「本不该跑却跑了」。
  85. /// </summary>
  86. [Fact]
  87. public void MissingPerJobKey_DefaultsToDisabled()
  88. {
  89. var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
  90. {
  91. ["Scheduler:Enabled"] = "true",
  92. ["S8:Scheduler:Enabled"] = "true",
  93. }).Build();
  94. var (watch, timeout, stuck) = Evaluate(cfg);
  95. Assert.False(watch);
  96. Assert.False(timeout);
  97. Assert.False(stuck);
  98. }
  99. /// <summary>环境级键缺省为 true,保持既有部署语义不变。</summary>
  100. [Fact]
  101. public void MissingEnvironmentKey_DefaultsToEnabled()
  102. {
  103. var d = S8JobSwitch.Evaluate(
  104. Config(null, master: true, watch: true, timeout: false, stuck: false),
  105. S8BackgroundJob.WatchScheduler);
  106. Assert.True(d.EnvironmentEnabled);
  107. Assert.True(d.Enabled);
  108. }
  109. /// <summary>三级与的每一级都要能单独否决。</summary>
  110. [Theory]
  111. [InlineData(false, true, true, false)]
  112. [InlineData(true, false, true, false)]
  113. [InlineData(true, true, false, false)]
  114. [InlineData(true, true, true, true)]
  115. public void EffectiveIsConjunctionOfThreeLevels(bool env, bool master, bool own, bool expected)
  116. {
  117. var d = new S8JobSwitchDecision(env, master, own, null);
  118. Assert.Equal(expected, d.Enabled);
  119. }
  120. /// <summary>键名与 env 名固化,防止后续改名导致部署侧静默失配。</summary>
  121. [Theory]
  122. [InlineData(S8BackgroundJob.WatchScheduler, "S8:WatchScheduler:Enabled", "AIDOP_S8_WATCH_SCHEDULER_ENABLED")]
  123. [InlineData(S8BackgroundJob.TimeoutAutoEscalation, "S8:TimeoutAutoEscalation:Enabled", "AIDOP_S8_TIMEOUT_AUTO_ESCALATION_ENABLED")]
  124. [InlineData(S8BackgroundJob.ActiveFlowStuckScan, "S8:ActiveFlowStuckScan:Enabled", "AIDOP_S8_ACTIVE_FLOW_STUCK_SCAN_ENABLED")]
  125. public void ConfigKeysAndEnvNamesArePinned(S8BackgroundJob job, string key, string env)
  126. {
  127. Assert.Equal(key, S8JobSwitch.JobEnabledKey(job));
  128. Assert.Equal(env, S8JobSwitch.JobEnvName(job));
  129. }
  130. /// <summary>env 解析失败必须保留 JSON 值并回报 env 名(不吞、不静默改判)。</summary>
  131. [Fact]
  132. public void UnparsableEnvOverride_KeepsJsonValueAndReportsName()
  133. {
  134. var name = S8JobSwitch.JobEnvName(S8BackgroundJob.WatchScheduler);
  135. var original = Environment.GetEnvironmentVariable(name);
  136. try
  137. {
  138. Environment.SetEnvironmentVariable(name, "not-a-bool");
  139. var d = S8JobSwitch.Evaluate(
  140. Config(true, master: true, watch: true, timeout: false, stuck: false),
  141. S8BackgroundJob.WatchScheduler);
  142. Assert.True(d.JobEnabled); // 沿用 JSON 的 true
  143. Assert.Equal(name, d.ParseFailEnvName); // 但必须回报解析失败
  144. }
  145. finally { Environment.SetEnvironmentVariable(name, original); }
  146. }
  147. // ───────────────────────── RunOnStart 硬门 ─────────────────────────
  148. /// <summary>
  149. /// <c>ActiveFlowStuckScan</c> 带 <c>RunOnStart = true</c>,启动瞬间必然触发一次;
  150. /// 因此三个 Job 的 <c>ExecuteAsync</c> 都必须**先求值开关再做任何事**。
  151. /// 断言:门禁调用出现在方法体内、且早于任何 <c>CreateScope()</c>(即拿 service 之前)。
  152. /// </summary>
  153. [Theory]
  154. [InlineData(typeof(S8WatchSchedulerJob), "Job/S8WatchSchedulerJob.cs")]
  155. [InlineData(typeof(S8TimeoutAutoEscalationJob), "Job/S8TimeoutAutoEscalationJob.cs")]
  156. [InlineData(typeof(S8ActiveFlowStuckScanJob), "Job/S8ActiveFlowStuckScanJob.cs")]
  157. public void Gate_IsEvaluatedBeforeAnyServiceResolution(Type jobType, string relativePath)
  158. {
  159. _ = jobType;
  160. var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  161. var full = Path.Combine(root, relativePath);
  162. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  163. var src = File.ReadAllText(full);
  164. var gateIdx = src.IndexOf("S8JobSwitch.Evaluate(_configuration", StringComparison.Ordinal);
  165. var scopeIdx = src.IndexOf("_scopeFactory.CreateScope()", StringComparison.Ordinal);
  166. Assert.True(gateIdx > 0, "未找到 S8JobSwitch 门禁调用");
  167. Assert.True(scopeIdx > 0, "未找到 CreateScope(Job 结构可能已变)");
  168. Assert.True(gateIdx < scopeIdx, "开关求值必须早于任何 service 解析 / 业务扫描");
  169. // 旧的双键写法必须彻底消失,避免有人只改一个 Job 造成三处漂移。
  170. Assert.DoesNotContain("GetValue(\"S8:Scheduler:Enabled\"", src);
  171. Assert.DoesNotContain("GetValue(\"Scheduler:Enabled\"", src);
  172. }
  173. }