using Admin.NET.Plugin.AiDOP.Service.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-SYSTEM-RECOVERED-TIMELINE-1:恢复要在时间线上留痕。 /// /// 补的是什么:恢复此前已经写 recovered_at、写 RECOVERED detection_log、 /// 发恢复通知,唯独用户能看见的那条时间线没有。于是页面上异常"自己不见了", /// 没人能分辨是系统判定恢复、还是有人做了什么。 /// /// 刻意不动的语义:恢复自动关闭异常;状态机不变; /// recovered_at 与 RECOVERED detection_log 原样保留。 /// public class S8SystemRecoveredTimelineTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private static string CodeOnly(string relativePath) { var full = Path.Combine(PluginRoot, relativePath.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("Service/S8/S8WatchSchedulerService.cs"); /// 动作码是稳定常量,不是散落各处的字符串字面量。 [Fact] public void ActionCode_IsAStableConstant() { Assert.Equal("SYSTEM_RECOVERED", S8WatchSchedulerService.SystemRecoveredActionCode); } /// /// 阈值达到时不得写时间线 —— 抗抖期间每次未命中都留一条, /// 时间线会被刷屏,而且每一条都不代表真的恢复了。 /// [Fact] public void BelowThreshold_WritesNoTimeline() { var code = Scheduler(); // 未达阈值那一支必须在写时间线之前就 continue var pendingIdx = code.IndexOf("antiflap_pending_recovery", StringComparison.Ordinal); var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal); Assert.True(pendingIdx > 0 && timelineIdx > 0); Assert.True(pendingIdx < timelineIdx, "未达恢复阈值的分支必须早于时间线写入,否则抗抖期间每次未命中都会留痕"); Assert.Contains("if (newMissCount < recoverRequired)", code); } /// /// 阈值达到时:recovered_at 已写、RECOVERED detection_log 已写,之后才写时间线。 /// 顺序重要 —— 时间线是"已经判定恢复"的结果,不能先于判定出现。 /// [Fact] public void AtThreshold_WritesTimelineAfterStateAndDetectionLog() { var code = Scheduler(); var recoveredAtIdx = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal); var detectionLogIdx = code.IndexOf("DetectResult = DetectResultRecovered,", StringComparison.Ordinal); var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal); Assert.True(recoveredAtIdx > 0 && detectionLogIdx > 0 && timelineIdx > 0); Assert.True(recoveredAtIdx < timelineIdx, "recovered_at 必须先落库"); Assert.True(detectionLogIdx < timelineIdx, "RECOVERED detection_log 必须先落库"); } /// /// 恢复不自动关闭异常,也不改状态机 —— /// 恢复只表示"规则不再命中",是否闭环仍由人判断。 /// [Fact] public void Recovery_DoesNotAutoCloseOrChangeStatus() { var code = Scheduler(); // 恢复那一段不得出现状态改写 var start = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal); var end = code.IndexOf("TryDispatchRecoveredLayerNotificationAsync", StringComparison.Ordinal); Assert.True(start > 0 && end > start); var recoverySegment = code[start..end]; Assert.DoesNotContain("Status = \"CLOSED\"", recoverySegment); Assert.DoesNotContain("Status = \"RECOVERED\"", recoverySegment); Assert.DoesNotContain("ClosedAt", recoverySegment); // 时间线本身也不写状态迁移 var writer = CodeOnly("Service/S8/S8WatchSchedulerService.cs"); Assert.Contains("ToStatus = null,", writer); } /// /// operator_user_id 必须是 NULL,绝不伪造。 /// /// 时间线的操作人列刚在 S8-SYSUSER-ONLY-1 里从"混装 EmployeeMaster.RecID 与 /// SysUser.Id 两套空间"收拾干净(实测 CLAIM 19 行是员工 Id、其余是账号 Id)。 /// 系统动作往里塞 1 或任意账号 Id,等于把刚修好的东西重新弄脏。 /// [Fact] public void SystemAction_LeavesOperatorNullNeverFabricated() { var code = Scheduler(); var start = code.IndexOf("ActionCode = SystemRecoveredActionCode,", StringComparison.Ordinal); Assert.True(start > 0); var block = code[start..(start + 700)]; Assert.Contains("OperatorUserId = null,", block); Assert.Contains("OperatorName = null,", block); Assert.DoesNotContain("OperatorUserId = 1", block); Assert.DoesNotContain("EmployeeId", block); } /// /// 幂等:同一恢复周期只写一条;调度器重跑不重复追加; /// 但复发后真实的新恢复周期要能再写一条。 /// /// 判据用既有列表达:不存在 created_at 晚于 last_detected_at 的 /// SYSTEM_RECOVERED 行。复发时 RefreshDetectionAsync 会把 last_detected_at /// 推到旧恢复行之后,于是新周期自然放行 —— 不新增状态字段、不靠"最后一条是什么" /// (调度器根本不写其它时间线,那种判据会把第二次真实恢复也挡掉)。 /// [Fact] public void Idempotent_PerRecoveryCycle_ButAllowsGenuineRerecovery() { var code = Scheduler(); Assert.Contains("t.ActionCode == SystemRecoveredActionCode", code); Assert.Contains("t.CreatedAt > lastDetectedAt!.Value", code); Assert.Contains("already_recorded_for_current_cycle", code); // 复发确实会推进 last_detected_at 并清空 recovered_at —— 幂等判据依赖这一点 Assert.Contains("LastDetectedAt = hit.DetectedAt,", code); Assert.Contains("RecoveredAt = null,", code); } /// /// 时间线写失败不得回滚已判定的恢复状态 —— 但必须落日志。 /// 静默吞掉会让"恢复了却没有痕迹"变成无从查起的问题。 /// [Fact] public void TimelineFailure_DoesNotRollbackRecovery() { var code = Scheduler(); Assert.Contains("system_recovered_timeline_failed", code); Assert.Contains("system_recovered_timeline_written", code); } /// /// 前端把 NULL 操作人显示为「系统」,不是整行不渲染。 /// /// 原实现 v-if="item.operatorName" 在 NULL 时整行消失, /// 系统恢复那条看起来像"谁都没做过",用户无从判断是自动恢复还是有人动过。 /// [Fact] public void Frontend_ShowsSystemInsteadOfBlankOperator() { var page = File.ReadAllText(Path.GetFullPath(Path.Combine( PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue"))); Assert.Contains("function operatorLabel(", page); Assert.Contains("'系统'", page); Assert.Contains("operatorLabel(item)", page); Assert.DoesNotContain("v-if=\"item.operatorName\" class=\"muted\"", page); } /// /// 租户隔离不受本批影响:恢复处理仍在按租户取候选的循环里, /// 时间线只按 exceptionId 写,不跨租户。 /// [Fact] public void TenantIsolation_Preserved() { var code = Scheduler(); Assert.Contains("x.TenantId == tenantId", code); var start = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(long exceptionId", StringComparison.Ordinal); Assert.True(start > 0); var method = code[start..(start + 1600)]; // 时间线写入只按主键定位,不引入任何工厂维度 Assert.Contains("t.ExceptionId == exceptionId", method); Assert.DoesNotContain("FactoryId", method); } }