| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- using Admin.NET.Plugin.AiDOP.Service.S8;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-SYSTEM-RECOVERED-TIMELINE-1:恢复要在时间线上留痕。
- ///
- /// <para><b>补的是什么</b>:恢复此前已经写 <c>recovered_at</c>、写 RECOVERED detection_log、
- /// 发恢复通知,唯独<b>用户能看见的那条时间线没有</b>。于是页面上异常"自己不见了",
- /// 没人能分辨是系统判定恢复、还是有人做了什么。</para>
- ///
- /// <para><b>刻意不动的语义</b>:恢复<b>不</b>自动关闭异常;状态机不变;
- /// recovered_at 与 RECOVERED detection_log 原样保留。</para>
- /// </summary>
- 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");
- /// <summary>动作码是稳定常量,不是散落各处的字符串字面量。</summary>
- [Fact]
- public void ActionCode_IsAStableConstant()
- {
- Assert.Equal("SYSTEM_RECOVERED", S8WatchSchedulerService.SystemRecoveredActionCode);
- }
- /// <summary>
- /// 阈值<b>未</b>达到时不得写时间线 —— 抗抖期间每次未命中都留一条,
- /// 时间线会被刷屏,而且每一条都不代表真的恢复了。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 阈值达到时:recovered_at 已写、RECOVERED detection_log 已写,<b>之后</b>才写时间线。
- /// 顺序重要 —— 时间线是"已经判定恢复"的结果,不能先于判定出现。
- /// </summary>
- [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 必须先落库");
- }
- /// <summary>
- /// 恢复<b>不自动关闭</b>异常,也不改状态机 ——
- /// 恢复只表示"规则不再命中",是否闭环仍由人判断。
- /// </summary>
- [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);
- }
- /// <summary>
- /// <b>operator_user_id 必须是 NULL</b>,绝不伪造。
- ///
- /// <para>时间线的操作人列刚在 S8-SYSUSER-ONLY-1 里从"混装 EmployeeMaster.RecID 与
- /// SysUser.Id 两套空间"收拾干净(实测 CLAIM 19 行是员工 Id、其余是账号 Id)。
- /// 系统动作往里塞 1 或任意账号 Id,等于把刚修好的东西重新弄脏。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 幂等:同一恢复周期只写一条;调度器重跑不重复追加;
- /// 但复发后<b>真实的新恢复周期</b>要能再写一条。
- ///
- /// <para>判据用既有列表达:不存在 <c>created_at</c> 晚于 <c>last_detected_at</c> 的
- /// SYSTEM_RECOVERED 行。复发时 <c>RefreshDetectionAsync</c> 会把 last_detected_at
- /// 推到旧恢复行之后,于是新周期自然放行 —— 不新增状态字段、不靠"最后一条是什么"
- /// (调度器根本不写其它时间线,那种判据会把第二次真实恢复也挡掉)。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 时间线写失败不得回滚已判定的恢复状态 —— 但必须落日志。
- /// 静默吞掉会让"恢复了却没有痕迹"变成无从查起的问题。
- /// </summary>
- [Fact]
- public void TimelineFailure_DoesNotRollbackRecovery()
- {
- var code = Scheduler();
- Assert.Contains("system_recovered_timeline_failed", code);
- Assert.Contains("system_recovered_timeline_written", code);
- }
- /// <summary>
- /// 前端把 NULL 操作人显示为「系统」,不是整行不渲染。
- ///
- /// <para>原实现 <c>v-if="item.operatorName"</c> 在 NULL 时整行消失,
- /// 系统恢复那条看起来像"谁都没做过",用户无从判断是自动恢复还是有人动过。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 租户隔离不受本批影响:恢复处理仍在按租户取候选的循环里,
- /// 时间线只按 exceptionId 写,不跨租户。
- /// </summary>
- [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);
- }
- }
|