| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- using Admin.NET.Plugin.AiDOP.Const.S8;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-LEGACY-NOTIFY-DECOMMISSION-1:旧通知分层的 Runtime 退役证明。
- ///
- /// <para><b>本文件要钉死的一件事</b>:通知收件人从此<b>只有一个来源</b> ——
- /// <c>ado_s8_notification_recipient</c>(Rule + Event + RecipientType)。
- /// 没配置就是不发;<b>不会</b>有第二套 (scene, severity, level) 语义在背后接管。</para>
- ///
- /// <para><b>为什么这值得一整个文件的守卫</b>:双 Authority 是本模块最难自查的故障形态。
- /// 管理员在规则弹窗里把收件人删干净,通知却照发 —— 因为旧表还在兜底,
- /// 而那批人在新页面上根本看不见。退役之后,缺口会以「0 收件人 + Warning」暴露出来,
- /// 而不是被悄悄补上。<b>后来者若想恢复 fallback「让测试变绿」,请先读这段。</b></para>
- /// </summary>
- 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);
- }
- /// <summary>去注释 —— 注释里提到某个词不等于代码在用它。</summary>
- 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 · 退役核心 ══════════════════════
- /// <summary>T1:NEW 命中 → 正常解析并发送。</summary>
- [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);
- }
- /// <summary>
- /// T2:NEW 不存在 → <b>不得调用 Legacy resolver</b>,直接返回。
- /// <para>这是退役的核心断言:miss 之后没有第二条路。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// T3:NEW 配了但解析出 0 人 → 保持既有 fail-loud(Warning + return),<b>不回落</b>。
- /// <para>本批刻意<b>不重构</b>这一点:0 收件人必须可观测,而不是被旧配置补上。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// T4:<b>本批最关键的退役证明</b> —— 即便旧表里躺着能匹配的数据,
- /// NEW miss 时也绝不会去用它。
- ///
- /// <para>判据是「派发入口对旧表零引用」而不是「查询条件变了」:
- /// 只要还留着一行查询,将来某次「顺手加个开关」就能把它接回来。</para>
- /// </summary>
- [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);
- }
- /// <summary>T5:Legacy Seed 不再生成任何分层数据。</summary>
- [Fact]
- public void T5_LegacySeed_ProducesNothing()
- {
- var seed = CodeOnly(LegacySeed);
- Assert.Contains("HasData() => Array.Empty<AdoS8NotificationLayer>()", 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 · 新链路不得回归 ══════════════════════
- /// <summary>T6–T9:五种 RecipientType 的解析全部保留。</summary>
- [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);
- }
- /// <summary>HANDLER_POOL / ESCALATION_POOL 仍从 Rule 责任池解析,未被改成别的来源。</summary>
- [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);
- }
- /// <summary>T10:租户隔离不回归 —— 收件人最终仍过一遍同租户 + 启用校验。</summary>
- [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);
- }
- /// <summary>
- /// T11:legacy backend 定义<b>仍在</b>(P1-C3 才清),但 Runtime 消费者 = 0。
- /// </summary>
- [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));
- }
- /// <summary>
- /// T12:<b>人工提报未被擅自改成 '*' 方案</b>。
- ///
- /// <para>已知缺口(本批明确不修):主动提报的异常 <c>SourceRuleCode</c> 为 NULL,
- /// 新模型会以 <c>rule_code='*'</c> 为键查找,而 <c>'*'</c> 目前<b>没有任何业务写入路径</b>。
- /// fallback 切断后,这类异常的后续事件通知暂时无收件人来源。</para>
- ///
- /// <para>该缺口由「主动提报通知契约」独立梳理。本守卫防的是两种走偏:
- /// ① 为了补缺口而改 Manual Report 去塞一个假 RuleCode;
- /// ② 为了让测试变绿而把 legacy fallback 接回来。</para>
- /// </summary>
- [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));
- }
- /// <summary>
- /// 目录仍是 PROVISIONING 定位,没有被悄悄升格成 Runtime Authority。
- /// <para>判据:派发链路(入口 + 收件人解析)都不查 <c>DefaultRecipientByEvent</c>。</para>
- /// </summary>
- [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);
- }
- }
|