using Admin.NET.Plugin.AiDOP.Const.S8;
using Admin.NET.Plugin.AiDOP.Entity.S8;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-NOTIFY-RECIPIENT-1:通知收件人模型。
///
/// 取代旧的 ado_s8_notification_layer 成为 CURRENT AUTHORITY。
/// 旧表按 (scene_code, severity, level_code) 组织,不按规则,无法回答
/// 「这条规则出事通知谁」;level_code 的 L1/L2/L3 三档在库里只有 L1 被真正用过,
/// 且「主管 / 总监」在系统中没有对应实体;收件人只支持 Role 一种。
///
/// 优先级必须单一:新模型有配置就完全不走旧表。做成"两者合并"会让
/// 管理员删掉收件人之后通知照发,而他在新页面上已经看不到那些人 ——
/// 那是最难自查的一类配置故障。
///
public class S8NotificationRecipientTests
{
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);
}));
}
// ══════════════════════════════════════════════════════════════════════
// 目录:只覆盖真实存在的派发点
// ══════════════════════════════════════════════════════════════════════
///
/// 每个事件都必须有真实派发点。配了却永远不触发的通知,比没有通知更难排查 ——
/// 用户会以为"配置生效了但对方没看",而实际上代码里根本没有那一行。
///
[Fact]
public void EveryEvent_HasARealDispatchSite()
{
var wiring = string.Join('\n', new[]
{
CodeOnly("Service/S8/S8TaskFlowService.cs"),
CodeOnly("Service/S8/S8WatchSchedulerService.cs"),
CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs"),
});
foreach (var e in S8NotificationCatalog.Events)
{
var member = e.Code switch
{
S8NotifyEventCode.ExceptionCreated => nameof(S8NotifyEventCode.ExceptionCreated),
S8NotifyEventCode.ExceptionClaimed => nameof(S8NotifyEventCode.ExceptionClaimed),
S8NotifyEventCode.ExceptionTransferred => nameof(S8NotifyEventCode.ExceptionTransferred),
S8NotifyEventCode.VerificationSubmitted => nameof(S8NotifyEventCode.VerificationSubmitted),
S8NotifyEventCode.EscalationTriggered => nameof(S8NotifyEventCode.EscalationTriggered),
S8NotifyEventCode.ExceptionRecovered => nameof(S8NotifyEventCode.ExceptionRecovered),
S8NotifyEventCode.ExceptionOverdueClosed => nameof(S8NotifyEventCode.ExceptionOverdueClosed),
_ => null
};
Assert.True(member != null, $"事件 {e.Code} 未登记到本断言,无法确认它有派发点");
Assert.True(wiring.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
$"事件 {e.Code} 在代码里找不到任何派发点 —— 不得制造没有业务入口的事件");
Assert.False(string.IsNullOrWhiteSpace(e.TriggerHint));
}
}
///
/// 收件人类型第一版只有四类,且全部能真实解析出账号。
/// Supervisor 缺席是刻意的:系统里不存在组织主管关系
/// (SysOrg.DirectorId 与 SysUser.ManagerUserId 填充率均为 0),
/// 给一个永远解析为空的选项等于让管理员配一条永远发不出去的通知。
///
[Fact]
public void RecipientTypes_ExcludeSupervisorAndAnyPhantom()
{
// S8-RESPONSIBILITY-POOL-1 新增 ESCALATION_POOL:它解析的是**真实存在**的升级账号池,
// 不是幽灵类型 —— 本守卫要挡的一直是"配了却永远发不出去的选项"。
Assert.Equal(5, S8NotificationCatalog.RecipientTypes.Count);
Assert.Contains(S8RecipientType.HandlerPool, S8NotificationCatalog.RecipientTypes);
Assert.Contains(S8RecipientType.Assignee, S8NotificationCatalog.RecipientTypes);
Assert.Contains(S8RecipientType.Reviewer, S8NotificationCatalog.RecipientTypes);
Assert.Contains(S8RecipientType.EscalationPool, S8NotificationCatalog.RecipientTypes);
Assert.Contains(S8RecipientType.SpecificUser, S8NotificationCatalog.RecipientTypes);
foreach (var phantom in new[] { "ASSIGNEE_SUPERVISOR", "SUPERVISOR", "DEPARTMENT_LEADER", "L2_MANAGER", "L3_DIRECTOR" })
Assert.DoesNotContain(phantom, S8NotificationCatalog.RecipientTypes);
}
/// 未知事件 / 未知类型一律拒绝,不猜、不放行。
[Fact]
public void UnknownEventOrType_IsRejected()
{
Assert.False(S8NotificationCatalog.IsKnownEvent("NOT_A_REAL_EVENT"));
Assert.False(S8NotificationCatalog.IsKnownEvent(null));
Assert.False(S8NotificationCatalog.IsKnownRecipientType("ASSIGNEE_SUPERVISOR"));
Assert.False(S8NotificationCatalog.IsKnownRecipientType(null));
var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
Assert.Contains("未知事件码", svc);
Assert.Contains("未知收件人类型", svc);
}
// ══════════════════════════════════════════════════════════════════════
// Authority 优先级
// ══════════════════════════════════════════════════════════════════════
///
/// 新模型是收件人的唯一来源;旧分层完全不参与。
///
/// 本守卫的判据随 S8-LEGACY-NOTIFY-DECOMMISSION-1 收紧:
/// 原先要守的是「新模型有配置时旧表不得参与」(旧表仍是 miss 时的 fallback);
/// 本批把 fallback 整体切断后,要守的变成「旧表在任何情况下都不参与」。
/// 保护意图不变——两套语义绝不能同时作用于一次派发。
///
[Fact]
public void NewModel_TakesPrecedence_AndLegacyIsOnlyFallback()
{
var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
// 唯一来源:新模型
Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code);
// 未配置就是不发,不再有第二套语义接管
Assert.Contains("if (resolution.Source == \"NONE\")", code);
// 派发入口里不得再出现任何旧分层的读取
foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "_roleResolver" })
Assert.DoesNotContain(legacy, code);
// 规则级与租户级默认也不合并(后者只在前者为空时使用)
var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
Assert.Contains("if (rows.Count == 0 && ruleCode != S8NotificationCatalog.TenantDefaultRuleCode)", resolver);
}
/// 旧分层表不删,只降级 —— 现网既有通知不能被这批改动打断。
[Fact]
public void LegacyLayer_IsDemotedNotDeleted()
{
var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.500.sql")));
Assert.DoesNotContain("DROP TABLE `ado_s8_notification_layer`", sql);
Assert.DoesNotContain("DELETE FROM `ado_s8_notification_layer`", sql);
Assert.Contains("[LEGACY", sql);
Assert.Contains("s8_500_BLOCKED_legacy_layer_wiped", sql);
Assert.DoesNotContain("DELIMITER", sql);
}
// ══════════════════════════════════════════════════════════════════════
// 解析语义
// ══════════════════════════════════════════════════════════════════════
///
/// 输出只认 SysUserId,且最终统一过一遍账号有效性 ——
/// 配置可能是几个月前配的,人可能已停用或换了租户。
///
[Fact]
public void Resolver_OutputsValidatedSysUserIdsOnly()
{
var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
Assert.Contains("_userScope.ValidateUsersAsync(tenantId, ids)", code);
Assert.DoesNotContain("EmployeeMaster", code);
Assert.DoesNotContain("FactoryId", code);
}
///
/// HANDLER_POOL 直接复用 P1-C 的责任池,不复制第二份人员配置。
/// 两份人员名单迟早不一致,而不一致的那一刻没有任何报错。
///
[Fact]
public void HandlerPoolRecipient_ReusesRuleHandlerPool()
{
var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
Assert.Contains("_handlerPool.GetMemberIdsAsync", code);
Assert.DoesNotContain("handler_role", code);
Assert.DoesNotContain("OperatorBinding", code);
}
///
/// REVIEWER 解析当前事实,不是新建的 Reviewer Pool:
/// 优先取单据上的 verifier_user_id,尚未提交复核时回落审批流解析出的审批人。
///
[Fact]
public void ReviewerRecipient_ResolvesFactsNotANewPool()
{
var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
// 单据上已有检验人时,收件人就是他 —— 最准确的当前事实,这一条没变。
Assert.Contains("e.VerifierUserId is > 0", code);
// S8-RESPONSIBILITY-POOL-1:尚未提交复核时的回落,从"解析审批流节点审批人"
// 改为"该规则的复核账号池"。P1-D 当时刻意不建 Reviewer Pool,本批显式改变该决定:
// 审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
// 且会让通知链随流程节点结构悄悄变化。
Assert.DoesNotContain("ResolveFlowApproversAsync", code);
Assert.Contains("S8ResponsibilityType.Reviewer", code);
// 复核池是**独立关系表**,不得塞进收件人配置行 ——
// 两处各存一份人员名单迟早不一致,而不一致的那一刻没有任何报错。
foreach (var invented in new[] { "reviewer_user_ids", "ReviewerUserIds" })
Assert.DoesNotContain(invented, code);
Assert.Null(typeof(AdoS8NotificationRecipient).GetProperty("ReviewerUserIds"));
}
///
/// 0 收件人必须可观测:配了却发不出去与"没配置"是两回事,
/// 绝不静默当作发送成功 —— 那正是本模块反复出现的失败形态。
///
[Fact]
public void ZeroRecipients_IsObservable()
{
var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
Assert.Contains("ConfiguredButEmpty", resolver);
Assert.Contains("s8_notify_recipients_empty", resolver);
Assert.Contains("PerType", resolver); // 逐类型计数,0 人的那一类要能被看见
var dispatch = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
Assert.Contains("resolved 0 recipients", dispatch);
}
///
/// 通知失败不阻断业务动作,但必须落日志。
/// 认领已经成功落库,不能因为发不出消息就回滚;静默吞掉则会让
/// 「通知没收到」变成无从查起的问题。
///
[Fact]
public void NotificationFailure_DoesNotBlockBusinessAction()
{
var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
Assert.Contains("s8_notify_dispatch_failed", code);
Assert.Contains("DispatchEventAsync", code);
// 原来写死收件人的方法必须消失
Assert.DoesNotContain("NotifyUserAsync", code);
}
///
/// 站内信必须与实时通道一起投递。
///
/// 本部署已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms ——
/// 没有站内信。旧分层派发因此从来只推 SignalR(用户不在线就等于没发过),
/// 而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。
/// 统一模型如果只走 pushAdapter,会静默丢掉那部分站内信(本地实测已发生过)。
///
[Fact]
public void Dispatch_PublishesInAppNoticeNotOnlyRealtimePush()
{
var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
Assert.Contains("_noticeService.PublishToUsersAsync", code);
Assert.Contains("BuildNoticeContent", code);
// 站内信失败不能连带阻断实时推送,反之亦然 —— 两个独立 try
Assert.Contains("notice publish throw", code);
Assert.Contains("push throw", code);
}
///
/// 认领必须用更新后的实体派发通知。
///
/// 认领走原子 UPDATE,它只写库、不回写内存实体;直接拿旧实体派发会让
/// ASSIGNEE 读到 null 而解析出 0 人,且整条链路一路成功、日志里连一条记录都不留
/// —— 本地实测正是这个形态。
///
[Fact]
public void Claim_DispatchesWithReloadedEntity()
{
var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
Assert.Contains("var claimed = await LoadAsync(id, tenantId) ?? e;", code);
Assert.Contains("DispatchEventAsync(claimed, S8NotifyEventCode.ExceptionClaimed", code);
// 不得再用陈旧实体派发认领通知
Assert.DoesNotContain("DispatchEventAsync(e, S8NotifyEventCode.ExceptionClaimed", code);
}
/// 渠道只保留真实可用的两个,不为 UI 完整而虚构短信 / 邮件。
[Fact]
public void Channels_AreRealOnly()
{
var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
Assert.Contains("DefaultChannels", code);
Assert.DoesNotContain("\"SMS\"", code);
Assert.DoesNotContain("\"Email\"", code);
Assert.DoesNotContain("\"WeChat\"", code);
}
// ══════════════════════════════════════════════════════════════════════
// 存储与写入
// ══════════════════════════════════════════════════════════════════════
/// 身份是 (租户, 规则, 事件, 类型);无工厂、无员工。
[Fact]
public void Storage_IdentityIsTenantRuleEventType()
{
var t = typeof(AdoS8NotificationRecipient);
foreach (var p in new[] { "TenantId", "RuleCode", "EventCode", "RecipientType" })
Assert.NotNull(t.GetProperty(p));
Assert.Null(t.GetProperty("FactoryId"));
Assert.Null(t.GetProperty("EmployeeId"));
Assert.Null(t.GetProperty("LevelCode")); // L1/L2/L3 不进入新模型
}
///
/// SPECIFIC_USER 写入时逐个校验账号;没勾该类型时不得留下账号列表 ——
/// 留着会在下次勾选时悄悄复活一批旧人选。
///
[Fact]
public void SpecificUser_IsValidatedAndClearedWhenUnselected()
{
var code = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
Assert.Contains("_userScope.ValidateUsersAsync(scope.TenantId, users)", code);
Assert.Contains("所选账号不存在或已停用", code);
Assert.Contains("users.Clear();", code);
Assert.Contains("请至少选择一个账号", code);
}
///
/// 人工提报没有来源规则,其通知只能落在租户级默认上;
/// 用显式哨兵而不是 NULL —— NULL 在唯一索引里的比较语义会让人配出两条"默认"。
///
[Fact]
public void TenantDefault_UsesExplicitSentinelNotNull()
{
Assert.Equal("*", S8NotificationCatalog.TenantDefaultRuleCode);
var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
Assert.Contains("S8NotificationCatalog.TenantDefaultRuleCode", code);
}
/// Tab 4 不得回到 L1/L2/L3 那套语义。
[Fact]
public void NotifyTab_DoesNotReintroduceLevelSemantics()
{
var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
var template = page[page.IndexOf("", StringComparison.Ordinal)..];
var noComments = System.Text.RegularExpressions.Regex.Replace(
template, "", string.Empty,
System.Text.RegularExpressions.RegexOptions.Singleline);
foreach (var phantom in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" })
Assert.DoesNotContain(phantom, noComments);
// S8-RESPONSIBILITY-POOL-1:收件人文案不再由前端维护一份 RECIPIENT_LABEL 映射,
// 改为后端按责任关系推导后下发(recipientLabel)。前端各存一份映射,
// 迟早与后端的推导规则不一致 —— 而不一致的那一刻页面显示的是错的对象。
Assert.DoesNotContain("RECIPIENT_LABEL", page);
Assert.Contains("recipientLabel", page);
// 页面也不得再让业务用户挑收件人技术类型
foreach (var techEnum in new[] { "HANDLER_POOL", "SPECIFIC_USER", "recipientTypes" })
Assert.DoesNotContain(techEnum, noComments);
}
}