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