|
|
@@ -0,0 +1,192 @@
|
|
|
+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);
|
|
|
+ }
|
|
|
+}
|