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; /// /// S8-AUTHORITY-HEALTH-GATE:门禁在调度器里的接线位置与爆炸半径。 /// /// 为什么是源码扫描而不是行为测试 /// 的构造参数里有 8 个 SqlSugarRepository<T>,其无参构造读取 /// SqlSugarSetup.ITenant 静态字段,没有 Furion 启动就是 NRE —— /// 本仓从未、也无法在测试里实例化调度器(grep "new S8WatchSchedulerService" 全仓 0 命中)。 /// 既有的 S8SchedulerScopeGuardTests / S8SystemRecoveredTimelineTests /// 走的都是同一条路。 /// /// 判定逻辑不在这里,在 (纯函数、可断言)。 /// 本文件只回答一个问题:门禁挡住的到底是哪一段。 /// 挡多了,正面观测停摆;挡少了,中台停更时照样假恢复。 /// public class S8RecoveryGateWiringTests { private const string SchedulerSrc = "Service/S8/S8WatchSchedulerService.cs"; // ───────────────── helpers ───────────────── /// 读取源码并剥掉注释行 —— 断言不应被注释里的字面量满足或破坏。 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> ProcessSingleRuleAsync", "public async Task OnRuleCompletedAsync"); private static string ReconcileBody() => Slice(Scheduler(), "private async Task> ReconcileRecoveriesForRuleAsync", "private static AdoS8DetectionLog BuildHitLog"); private static string RunSingleBody() => Slice(Scheduler(), "public async Task RunSingleRuleAsync", "private async Task> ProcessSingleRuleAsync"); /// /// 计算 处的花括号嵌套深度。 /// 只在单个方法体切片内使用:日志模板里的 {RuleCode} 之类占位符成对出现、 /// 净增为 0,不影响相对深度比较。若将来该方法引入 raw string SQL,本助手需先剥离字符串字面量。 /// 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; } // ───────────────── 接线基本形态 ───────────────── /// 调度器必须依赖端口接口,且不得自己知道运行日志表名。 [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); } /// 门禁必须在 reconcile 之前,且位于 ProcessSingleRuleAsync 内。 [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(结构侧):空集不得成为拦截理由 ───────────────── /// /// CASE B:门禁与「本轮有没有命中」完全无关。 /// 取数与门禁之间不得出现任何对结果规模的判断 —— /// 一旦有人补上 if (hits.Count == 0),真实恢复会永远不再发生, /// 而日志上每一轮都是成功。 /// [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:抗抖计数不得被拦截路径触碰 ───────────────── /// /// CASE I:门禁拦截时 ConsecutiveMissCount 既不加也不减。 /// /// 实现方式必须是「整段跳过 reconcile」,而不是在 reconcile 内部某处 return —— /// 后者会让候选查询与 miss 自增的相对顺序变成定时炸弹:今天写在自增之前, /// 明天挪到之后,就变成「拦截期间照样累计 miss,中台一恢复立刻集体假恢复」。 /// [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:正面观测不受影响 ───────────────── /// /// CASE J:门禁拦住的只有恢复,建单与刷新照常。 /// /// 用花括号深度证明 foreach (var hit in hits) 与方法首行同级 —— /// 它没有被裹进门禁的 if/else 里。这比 Assert.Contains 强: /// 把 foreach 缩进到 else 分支内,字符串照样能匹配到,深度不会。 /// /// 正面观测停摆是比假恢复更早被发现、但同样严重的失效: /// 中台停更期间新异常一条也建不出来,而页面上一片安静。 /// [Fact] public void J_BlockedPath_StillRunsCreateAndRefresh() { var body = ProcessBody(); var baseline = At(body, "var results = new List();"); 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:不判失败、不触发自动暂停 ───────────────── /// /// CASE K:门禁拦截不是一次失败的 run。 /// /// 若拦截走 Success = false,规则完成回调会累计 ConsecutiveFailureCount, /// 达到阈值即写 paused_until 把规则自动暂停一小时。 /// 于是中台跑一次批,监控就睡一小时 —— 而页面上只显示「最近结果:失败」, /// 没人知道是数据源的问题。 /// [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()); } /// /// CASE K':探测自身失败必须降级为 UNKNOWN,不得让异常穿到上层变成整条 run 失败 —— /// 那等于绕过本条守卫达成同一个后果。该兜底落在适配实现里,此处断言它确实存在。 /// [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); } // ───────────────── 观测性:拦截必须留痕 ───────────────── /// /// 拦截期间异常只增不减,这件事必须能被查到。 /// 只写日志不够 —— 日志会滚走,而 detection_log 是可查的。 /// 原因码要落 failure_reason,否则只知道「被拦了」、不知道为什么。 /// [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); } // ───────────────── 适配层的租户隔离 ───────────────── /// /// 适配层的租户谓词必须是严格相等。 /// /// 仓内 MdpMonitorService.BuildMdpRunLogTenantWhere 用的是 /// (tenant_id = @TenantId OR tenant_id = 0) —— 本类不得复用: /// 该表确实存在 tenant_id = 0 的平台行(实测 S1 有 111 条, /// 且 S5_PURCHASE_RECEIPT_MDP_SYNC 有 325 条平台级 SUCCESS), /// 一条这样的行会让所有租户同时被判成健康。 /// [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); } }