| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- using System.Reflection;
- using Admin.NET.Plugin.AiDOP.Service.S8;
- using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-AUTHORITY-HEALTH-GATE:门禁在调度器里的<b>接线位置与爆炸半径</b>。
- ///
- /// <para><b>为什么是源码扫描而不是行为测试</b>:<see cref="S8WatchSchedulerService"/>
- /// 的构造参数里有 8 个 <c>SqlSugarRepository<T></c>,其无参构造读取
- /// <c>SqlSugarSetup.ITenant</c> 静态字段,没有 Furion 启动就是 NRE ——
- /// 本仓从未、也无法在测试里实例化调度器(<c>grep "new S8WatchSchedulerService"</c> 全仓 0 命中)。
- /// 既有的 <c>S8SchedulerScopeGuardTests</c> / <c>S8SystemRecoveredTimelineTests</c>
- /// 走的都是同一条路。</para>
- ///
- /// <para><b>判定逻辑不在这里</b>,在 <see cref="S8AuthorityHealthGateTests"/>(纯函数、可断言)。
- /// 本文件只回答一个问题:门禁挡住的到底是<b>哪一段</b>。
- /// 挡多了,正面观测停摆;挡少了,中台停更时照样假恢复。</para>
- /// </summary>
- public class S8RecoveryGateWiringTests
- {
- private const string SchedulerSrc = "Service/S8/S8WatchSchedulerService.cs";
- // ───────────────── helpers ─────────────────
- /// <summary>读取源码并剥掉注释行 —— 断言不应被注释里的字面量满足或破坏。</summary>
- private static string CodeOnly(string relative)
- {
- var dir = new DirectoryInfo(AppContext.BaseDirectory);
- while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
- dir = dir.Parent;
- Assert.NotNull(dir);
- var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP",
- relative.Replace('/', Path.DirectorySeparatorChar));
- Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
- return string.Join('\n', File.ReadAllLines(full)
- .Where(l =>
- {
- var t = l.TrimStart();
- return !t.StartsWith("///", StringComparison.Ordinal)
- && !t.StartsWith("//", StringComparison.Ordinal);
- }));
- }
- private static string Scheduler() => CodeOnly(SchedulerSrc);
- private static int At(string code, string anchor)
- {
- var i = code.IndexOf(anchor, StringComparison.Ordinal);
- Assert.True(i >= 0, $"未找到锚点:{anchor}");
- return i;
- }
- private static string Slice(string code, string from, string to)
- {
- var a = At(code, from);
- var b = code.IndexOf(to, a, StringComparison.Ordinal);
- return b > a ? code[a..b] : code[a..];
- }
- private static string ProcessBody() => Slice(Scheduler(),
- "private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync",
- "public async Task OnRuleCompletedAsync");
- private static string ReconcileBody() => Slice(Scheduler(),
- "private async Task<List<long>> ReconcileRecoveriesForRuleAsync",
- "private static AdoS8DetectionLog BuildHitLog");
- private static string RunSingleBody() => Slice(Scheduler(),
- "public async Task<S8RuleRunResult> RunSingleRuleAsync",
- "private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync");
- /// <summary>
- /// 计算 <paramref name="index"/> 处的花括号嵌套深度。
- /// <para>只在<b>单个方法体切片</b>内使用:日志模板里的 <c>{RuleCode}</c> 之类占位符成对出现、
- /// 净增为 0,不影响相对深度比较。若将来该方法引入 raw string SQL,本助手需先剥离字符串字面量。</para>
- /// </summary>
- private static int DepthAt(string code, int index)
- {
- var d = 0;
- for (var i = 0; i < index; i++)
- {
- if (code[i] == '{') d++;
- else if (code[i] == '}') d--;
- }
- return d;
- }
- private static int Count(string code, string needle)
- {
- int n = 0, i = 0;
- while ((i = code.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
- return n;
- }
- // ───────────────── 接线基本形态 ─────────────────
- /// <summary>调度器必须依赖端口接口,且不得自己知道运行日志表名。</summary>
- [Fact]
- public void Scheduler_DependsOnTheResolverSeamInterface()
- {
- var ctor = typeof(S8WatchSchedulerService).GetConstructors().Single();
- Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(IS8AuthorityHealthResolver));
- var code = Scheduler();
- Assert.DoesNotContain("new S8MdpAuthorityHealthResolver", code);
- // 物理表名属适配层的知识;调度器只传 DatasetCode。
- Assert.DoesNotContain("mdp_transform_run_log", code);
- Assert.DoesNotContain("dwd_requirement_examine_detail", code);
- Assert.DoesNotContain("mdp_std_so", code);
- }
- /// <summary>门禁必须在 reconcile <b>之前</b>,且位于 ProcessSingleRuleAsync 内。</summary>
- [Fact]
- public void Gate_RunsBeforeRecoveryReconcile()
- {
- var body = ProcessBody();
- Assert.True(At(body, "_authorityHealthResolver.ResolveAsync") < At(body, "health.AllowsRecovery"));
- Assert.True(At(body, "health.AllowsRecovery") < At(body, "ReconcileRecoveriesForRuleAsync("));
- }
- // ───────────────── CASE B(结构侧):空集不得成为拦截理由 ─────────────────
- /// <summary>
- /// CASE B:门禁与「本轮有没有命中」<b>完全无关</b>。
- /// <para>取数与门禁之间不得出现任何对结果规模的判断 ——
- /// 一旦有人补上 <c>if (hits.Count == 0)</c>,真实恢复会永远不再发生,
- /// 而日志上每一轮都是成功。</para>
- /// </summary>
- [Fact]
- public void B_GateIsNotConditionedOnHitCount()
- {
- var body = ProcessBody();
- var window = body[At(body, "await evaluator.EvaluateAsync")..At(body, "ReconcileRecoveriesForRuleAsync(")];
- Assert.DoesNotContain("hits.Count ==", window);
- Assert.DoesNotContain("hits.Count >", window);
- Assert.DoesNotContain("hits.Any(", window);
- Assert.DoesNotContain("!hits.Any", window);
- // reconcile 自身也不得新增空集早退 —— 空 hits 正是「全部恢复」的正常输入。
- var reconcile = ReconcileBody();
- Assert.DoesNotContain("hits.Count == 0", reconcile);
- Assert.DoesNotContain("!hits.Any()", reconcile);
- }
- // ───────────────── CASE I:抗抖计数不得被拦截路径触碰 ─────────────────
- /// <summary>
- /// CASE I:门禁拦截时 <c>ConsecutiveMissCount</c> <b>既不加也不减</b>。
- ///
- /// <para>实现方式必须是「整段跳过 reconcile」,而<b>不是</b>在 reconcile 内部某处 return ——
- /// 后者会让候选查询与 miss 自增的相对顺序变成定时炸弹:今天写在自增之前,
- /// 明天挪到之后,就变成「拦截期间照样累计 miss,中台一恢复立刻集体假恢复」。</para>
- /// </summary>
- [Fact]
- public void I_BlockedPath_LeavesConsecutiveMissCountUntouched()
- {
- var code = Scheduler();
- var reconcile = ReconcileBody();
- const string inc = "ConsecutiveMissCount = x.ConsecutiveMissCount + 1";
- Assert.Equal(2, Count(code, inc));
- Assert.Equal(2, Count(reconcile, inc)); // 两处自增必须全部落在 reconcile 内
- // 门禁绝不能被搬进 reconcile —— 那样自增与拦截的先后无法再被静态保证。
- Assert.DoesNotContain("_authorityHealthResolver", reconcile);
- Assert.DoesNotContain("AllowsRecovery", reconcile);
- Assert.DoesNotContain("S8AuthorityHealth", reconcile);
- // 拦截分支自身不碰任何抗抖计数与恢复标记。
- var body = ProcessBody();
- var blocked = body[At(body, "if (!health.AllowsRecovery)")..At(body, "ReconcileRecoveriesForRuleAsync(")];
- Assert.DoesNotContain("ConsecutiveMissCount", blocked);
- Assert.DoesNotContain("ConsecutiveHitCount", blocked);
- Assert.DoesNotContain("RecoveredAt", blocked);
- }
- // ───────────────── CASE J:正面观测不受影响 ─────────────────
- /// <summary>
- /// CASE J:门禁拦住的<b>只有恢复</b>,建单与刷新照常。
- ///
- /// <para>用花括号深度证明 <c>foreach (var hit in hits)</c> 与方法首行同级 ——
- /// 它没有被裹进门禁的 if/else 里。这比 <c>Assert.Contains</c> 强:
- /// 把 foreach 缩进到 else 分支内,字符串照样能匹配到,深度不会。</para>
- ///
- /// <para>正面观测停摆是比假恢复更早被发现、但同样严重的失效:
- /// 中台停更期间新异常一条也建不出来,而页面上一片安静。</para>
- /// </summary>
- [Fact]
- public void J_BlockedPath_StillRunsCreateAndRefresh()
- {
- var body = ProcessBody();
- var baseline = At(body, "var results = new List<S8WatchCreationResult>();");
- var gate = At(body, "if (!health.AllowsRecovery)");
- var loop = At(body, "foreach (var hit in hits)");
- Assert.True(loop > gate, "hit 循环必须在门禁之后");
- Assert.Equal(DepthAt(body, baseline), DepthAt(body, loop));
- }
- // ───────────────── CASE K:不判失败、不触发自动暂停 ─────────────────
- /// <summary>
- /// CASE K:门禁拦截<b>不是一次失败的 run</b>。
- ///
- /// <para>若拦截走 <c>Success = false</c>,规则完成回调会累计 <c>ConsecutiveFailureCount</c>,
- /// 达到阈值即写 <c>paused_until</c> 把规则自动暂停一小时。
- /// 于是中台跑一次批,监控就睡一小时 —— 而页面上只显示「最近结果:失败」,
- /// 没人知道是数据源的问题。</para>
- /// </summary>
- [Fact]
- public void K_BlockedPath_DoesNotFailTheRunNorAutoPause()
- {
- var body = ProcessBody();
- var blocked = body[At(body, "if (!health.AllowsRecovery)")..At(body, "foreach (var hit in hits)")];
- Assert.DoesNotContain("throw", blocked);
- Assert.DoesNotContain("Success = false", blocked);
- Assert.DoesNotContain("S8RuleRunResult", blocked);
- Assert.DoesNotContain("Enabled = false", blocked);
- Assert.DoesNotContain("PausedUntil", blocked);
- // 门禁不得上移到 RunSingleRuleAsync —— 那一层的失败语义就是整条 run 失败。
- Assert.DoesNotContain("_authorityHealthResolver", RunSingleBody());
- }
- /// <summary>
- /// CASE K':探测自身失败必须<b>降级为 UNKNOWN</b>,不得让异常穿到上层变成整条 run 失败 ——
- /// 那等于绕过本条守卫达成同一个后果。该兜底落在适配实现里,此处断言它确实存在。
- /// </summary>
- [Fact]
- public void K2_ResolverFailure_DegradesToUnknown_NotRunFailure()
- {
- var resolver = CodeOnly("Service/S8/Rules/Health/S8MdpAuthorityHealthResolver.cs");
- var probe = resolver[At(resolver, "catch (Exception ex)")..];
- Assert.Contains("S8AuthorityHealthState.Unknown", probe);
- Assert.Contains("S8AuthorityHealthReason.ResolverFailed", probe);
- Assert.DoesNotContain("throw;", probe);
- }
- // ───────────────── 观测性:拦截必须留痕 ─────────────────
- /// <summary>
- /// 拦截期间异常只增不减,这件事<b>必须能被查到</b>。
- /// <para>只写日志不够 —— 日志会滚走,而 detection_log 是可查的。
- /// 原因码要落 <c>failure_reason</c>,否则只知道「被拦了」、不知道为什么。</para>
- /// </summary>
- [Fact]
- public void BlockedPath_WritesRecoveryBlockedDetectionLog()
- {
- var body = ProcessBody();
- var blocked = body[At(body, "if (!health.AllowsRecovery)")..At(body, "foreach (var hit in hits)")];
- Assert.Contains("WriteDetectionLogAsync", blocked);
- Assert.Contains("DetectResult = DetectResultRecoveryBlocked", blocked);
- Assert.Contains("FailureReason = health.ReasonCode", blocked);
- Assert.Contains("PayloadSnapshot", blocked);
- // detect_result 列是 varchar(32),常量不得超长。
- var f = typeof(S8WatchSchedulerService).GetField("DetectResultRecoveryBlocked",
- BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
- Assert.NotNull(f);
- var v = (string)f!.GetRawConstantValue()!;
- Assert.Equal("RECOVERY_BLOCKED", v);
- Assert.True(v.Length <= 32);
- }
- // ───────────────── 适配层的租户隔离 ─────────────────
- /// <summary>
- /// 适配层的租户谓词必须是严格相等。
- ///
- /// <para>仓内 <c>MdpMonitorService.BuildMdpRunLogTenantWhere</c> 用的是
- /// <c>(tenant_id = @TenantId OR tenant_id = 0)</c> —— <b>本类不得复用</b>:
- /// 该表确实存在 <c>tenant_id = 0</c> 的平台行(实测 S1 有 111 条,
- /// 且 <c>S5_PURCHASE_RECEIPT_MDP_SYNC</c> 有 325 条平台级 SUCCESS),
- /// 一条这样的行会让所有租户同时被判成健康。</para>
- /// </summary>
- [Fact]
- public void Resolver_UsesStrictTenantEquality()
- {
- var resolver = CodeOnly("Service/S8/Rules/Health/S8MdpAuthorityHealthResolver.cs");
- Assert.DoesNotContain("OR tenant_id = 0", resolver);
- Assert.DoesNotContain("BuildMdpRunLogTenantWhere", resolver);
- Assert.Equal(3, Count(resolver, "r.tenant_id = @TenantId")); // 三条运行日志查询各一次
- // 必须显式过滤终态,否则会取到永不回收的孤儿 RUNNING 行。
- Assert.Contains("r.status = 'SUCCESS'", resolver);
- Assert.Contains("r.end_time IS NOT NULL", resolver);
- // 回溯窗口不是可选优化:无界回溯在长期停摆的租户上实测单次 146ms。
- Assert.Contains("r.start_time >= @LookbackFrom", resolver);
- }
- }
|