using Admin.NET.Plugin.AiDOP.Const.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-LEGACY-NOTIFY-DECOMMISSION-1:旧通知分层的 Runtime 退役证明。 /// /// 本文件要钉死的一件事:通知收件人从此只有一个来源 —— /// ado_s8_notification_recipient(Rule + Event + RecipientType)。 /// 没配置就是不发;不会有第二套 (scene, severity, level) 语义在背后接管。 /// /// 为什么这值得一整个文件的守卫:双 Authority 是本模块最难自查的故障形态。 /// 管理员在规则弹窗里把收件人删干净,通知却照发 —— 因为旧表还在兜底, /// 而那批人在新页面上根本看不见。退役之后,缺口会以「0 收件人 + Warning」暴露出来, /// 而不是被悄悄补上。后来者若想恢复 fallback「让测试变绿」,请先读这段。 /// public class S8LegacyNotifyDecommissionTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private static string Read(string relative) { var full = Path.Combine(PluginRoot, relative.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } /// 去注释 —— 注释里提到某个词不等于代码在用它。 private static string CodeOnly(string relative) => string.Join('\n', Read(relative).Split('\n').Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); private const string Dispatcher = "Service/S8/S8NotificationLayerResolver.cs"; private const string Recipient = "Service/S8/S8NotificationRecipientResolver.cs"; private const string LegacySeed = "SeedData/S8NotificationLayerSeedData.cs"; // ══════════════════════ T1–T4 · 退役核心 ══════════════════════ /// T1:NEW 命中 → 正常解析并发送。 [Fact] public void T1_NewRecipientHit_IsDispatched() { var code = CodeOnly(Dispatcher); Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code); Assert.Contains("await DispatchToUsersAsync(input, resolution);", code); } /// /// T2:NEW 不存在 → 不得调用 Legacy resolver,直接返回。 /// 这是退役的核心断言:miss 之后没有第二条路。 /// [Fact] public void T2_NewRecipientMiss_DoesNotCallLegacy() { var code = CodeOnly(Dispatcher); Assert.Contains("if (resolution.Source == \"NONE\")", code); // NONE 分支之后到方法结束之间,不得出现任何旧表读取 var idx = code.IndexOf("if (resolution.Source == \"NONE\")", StringComparison.Ordinal); Assert.True(idx > 0); var tail = code[idx..]; foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "layers" }) Assert.DoesNotContain(legacy, tail); } /// /// T3:NEW 配了但解析出 0 人 → 保持既有 fail-loud(Warning + return),不回落。 /// 本批刻意不重构这一点:0 收件人必须可观测,而不是被旧配置补上。 /// [Fact] public void T3_ConfiguredButEmpty_StaysFailLoudWithoutFallback() { var code = CodeOnly(Dispatcher); Assert.Contains("resolution.UserIds.Count == 0", code); Assert.Contains("S8RecipientDispatch: resolved 0 recipients", code); // ConfiguredButEmpty 语义在收件人解析侧同样保留 var recipient = CodeOnly(Recipient); Assert.Contains("ConfiguredButEmpty", recipient); Assert.Contains("s8_notify_recipients_empty", recipient); } /// /// T4:本批最关键的退役证明 —— 即便旧表里躺着能匹配的数据, /// NEW miss 时也绝不会去用它。 /// /// 判据是「派发入口对旧表零引用」而不是「查询条件变了」: /// 只要还留着一行查询,将来某次「顺手加个开关」就能把它接回来。 /// [Fact] public void T4_LegacyRowsAreNeverConsultedEvenWhenTheyWouldMatch() { var code = CodeOnly(Dispatcher); foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "_roleResolver", "SceneCode == input.SceneCode", "x.Severity == severity", "S8ConfigScope.GlobalTenantId", }) Assert.DoesNotContain(legacy, code); } /// T5:Legacy Seed 不再生成任何分层数据。 [Fact] public void T5_LegacySeed_ProducesNothing() { var seed = CodeOnly(LegacySeed); Assert.Contains("HasData() => Array.Empty()", seed); // 原来的 scene→role 映射必须整体消失,否则等于给恢复留了入口 foreach (var gone in new[] { "ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_QC", "ROLE_WH_INBOUND", "ROLE_PURCHASER", "L1_OPERATOR", "sceneRoleMap", }) Assert.DoesNotContain(gone, seed); } // ══════════════════════ T6–T9 · 新链路不得回归 ══════════════════════ /// T6–T9:五种 RecipientType 的解析全部保留。 [Theory] [InlineData("HandlerPool", "_handlerPool.GetMemberIdsAsync")] [InlineData("Assignee", "e.AssigneeUserId is > 0")] [InlineData("Reviewer", "e.VerifierUserId is > 0")] [InlineData("EscalationPool", "S8ResponsibilityType.Escalation")] [InlineData("SpecificUser", "S8RoleResolver.SplitTokens(row.RecipientUserIds)")] public void T6_T9_AllRecipientTypesStillResolve(string type, string marker) { var code = CodeOnly(Recipient); Assert.Contains($"case S8RecipientType.{type}:", code); Assert.Contains(marker, code); } /// HANDLER_POOL / ESCALATION_POOL 仍从 Rule 责任池解析,未被改成别的来源。 [Fact] public void ResponsibilityPools_RemainTheSourceForPoolRecipients() { var code = CodeOnly(Recipient); Assert.Contains("IS8RuleHandlerPoolReader", code); Assert.Contains("IS8RuleResponsibilityReader", code); Assert.Contains("_pools.GetMemberIdsAsync(", code); // 绝不从旧 target_role / L1-L3 推责任 foreach (var banned in new[] { "TargetRoleIds", "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" }) Assert.DoesNotContain(banned, code); } /// T10:租户隔离不回归 —— 收件人最终仍过一遍同租户 + 启用校验。 [Fact] public void T10_TenantIsolationNotRegressed() { var code = CodeOnly(Recipient); Assert.Contains("_userScope.ValidateUsersAsync(tenantId, ids)", code); Assert.Contains("x.TenantId == tenantId", code); var dispatcher = CodeOnly(Dispatcher); Assert.Contains("input.TenantId", dispatcher); } /// /// T11:legacy backend 定义仍在(P1-C3 才清),但 Runtime 消费者 = 0。 /// [Fact] public void T11_LegacyBackendDefinitionsKept_ButZeroRuntimeConsumer() { foreach (var kept in new[] { "Entity/S8/AdoS8NotificationLayer.cs", "Service/S8/S8NotificationLayerService.cs", "Service/S8/S8NotificationLayerMerge.cs", "Controllers/S8/AdoS8ConfigNotificationLayersController.cs", "SeedData/S8NotificationLayerSeedData.cs", }) Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))), $"legacy backend 定义被误删(应留给 P1-C3):{kept}"); // 唯一的通知派发入口对旧表零引用 Assert.DoesNotContain("AdoS8NotificationLayer", CodeOnly(Dispatcher)); } /// /// T12:人工提报未被擅自改成 '*' 方案。 /// /// 已知缺口(本批明确不修):主动提报的异常 SourceRuleCode 为 NULL, /// 新模型会以 rule_code='*' 为键查找,而 '*' 目前没有任何业务写入路径。 /// fallback 切断后,这类异常的后续事件通知暂时无收件人来源。 /// /// 该缺口由「主动提报通知契约」独立梳理。本守卫防的是两种走偏: /// ① 为了补缺口而改 Manual Report 去塞一个假 RuleCode; /// ② 为了让测试变绿而把 legacy fallback 接回来。 /// [Fact] public void T12_ManualReportContract_IsUntouchedAndGapIsAcknowledged() { var manual = CodeOnly("Service/S8/S8ManualReportService.cs"); // 手工建单仍不写 SourceRuleCode —— 没有被塞进一个假规则码 var createIdx = manual.IndexOf("SourceType = \"MANUAL\"", StringComparison.Ordinal); Assert.True(createIdx > 0, "未找到手工提报的建单分支"); var block = manual[Math.Max(0, createIdx - 1500)..Math.Min(manual.Length, createIdx + 1500)]; Assert.DoesNotContain("SourceRuleCode =", block); // 也没有借机新增 '*' 的写入能力 var configSvc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs"); Assert.DoesNotContain("TenantDefaultRuleCode", configSvc); // '*' 仍然只有读侧引用(resolver 内),没有任何写入者 Assert.Contains("TenantDefaultRuleCode", CodeOnly(Recipient)); } /// /// 目录仍是 PROVISIONING 定位,没有被悄悄升格成 Runtime Authority。 /// 判据:派发链路(入口 + 收件人解析)都不查 DefaultRecipientByEvent /// [Fact] public void DefaultRecipientCatalog_StaysProvisioningOnly() { foreach (var runtime in new[] { Dispatcher, Recipient }) Assert.DoesNotContain("DefaultRecipientByEvent", CodeOnly(runtime)); // 目录本身与写入侧仍在 Assert.Contains("DefaultRecipientByEvent", Read("Const/S8/S8NotificationCatalog.cs")); Assert.Contains("ResolveDefaultRecipientType", Read("Service/S8/S8NotificationRecipientConfigService.cs")); Assert.Equal(7, S8NotificationCatalog.Events.Count); } }