|
@@ -0,0 +1,217 @@
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
|
|
+using Xunit;
|
|
|
|
|
+
|
|
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1 · N-1 回归。
|
|
|
|
|
+///
|
|
|
|
|
+/// 覆盖 <see cref="S8NotificationLayerMerge"/> 的逐层覆盖语义。这是纯函数测试,不触 DB、不触 pusher。
|
|
|
|
|
+/// runtime resolver 与配置页 ListAsync 都调用同一个 Effective(),因此本文件同时守住两处。
|
|
|
|
|
+///
|
|
|
|
|
+/// 被守住的缺陷(修正前真实存在):工厂只为某一层建行,平台默认在该 scene+severity 下的
|
|
|
|
|
+/// **其余层级会整体消失** —— 工厂本意「补一层」,实际「换掉整组」,且页面上不可见。
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class S8NotificationLayerMergeTests
|
|
|
|
|
+{
|
|
|
|
|
+ private const long GlobalTenant = 0L;
|
|
|
|
|
+ private const long GlobalFactory = 0L;
|
|
|
|
|
+ private const long FactoryTenant = 838257186181189L;
|
|
|
|
|
+ private const long FactoryFactory = 838257186320453L;
|
|
|
|
|
+
|
|
|
|
|
+ private static AdoS8NotificationLayer Row(
|
|
|
|
|
+ long id, long tenantId, long factoryId, string scene, string severity, string level) =>
|
|
|
|
|
+ new()
|
|
|
|
|
+ {
|
|
|
|
|
+ Id = id,
|
|
|
|
|
+ TenantId = tenantId,
|
|
|
|
|
+ FactoryId = factoryId,
|
|
|
|
|
+ SceneCode = scene,
|
|
|
|
|
+ Severity = severity,
|
|
|
|
|
+ LevelCode = level,
|
|
|
|
|
+ TargetRoleIds = "ROLE_X",
|
|
|
|
|
+ NotifyChannel = "log,SignalR"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ private static AdoS8NotificationLayer Global(long id, string scene, string sev, string level) =>
|
|
|
|
|
+ Row(id, GlobalTenant, GlobalFactory, scene, sev, level);
|
|
|
|
|
+
|
|
|
|
|
+ private static AdoS8NotificationLayer Factory(long id, string scene, string sev, string level) =>
|
|
|
|
|
+ Row(id, FactoryTenant, FactoryFactory, scene, sev, level);
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// N-1 的核心回归:工厂只覆盖 L2,平台默认的 L1 / L3 必须**继续生效**。
|
|
|
|
|
+ /// 修正前此处会只剩 1 行(工厂的 L2),L1/L3 静默消失。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void FactoryOverrideOnOneLevel_KeepsPlatformDefaultsOnOtherLevels()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Global(2, "S1", "FOLLOW", "L2_MANAGER"),
|
|
|
|
|
+ Global(3, "S1", "FOLLOW", "L3_DIRECTOR"),
|
|
|
|
|
+ Factory(100, "S1", "FOLLOW", "L2_MANAGER"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(3, effective.Count);
|
|
|
|
|
+ Assert.Equal(new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" }, effective.Select(x => x.LevelCode));
|
|
|
|
|
+ // L1 / L3 仍来自平台默认,L2 被工厂接管
|
|
|
|
|
+ Assert.Equal(GlobalTenant, effective[0].TenantId);
|
|
|
|
|
+ Assert.Equal(FactoryTenant, effective[1].TenantId);
|
|
|
|
|
+ Assert.Equal(100, effective[1].Id);
|
|
|
|
|
+ Assert.Equal(GlobalTenant, effective[2].TenantId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>同层级有工厂行时,该层级的平台默认必须被替换掉(不能两行都发)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void FactoryRow_ReplacesPlatformDefault_OnSameLevel()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Single(effective);
|
|
|
|
|
+ Assert.Equal(100, effective[0].Id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>覆盖不跨 scene / severity:S1 的工厂行不得影响 S2,FOLLOW 的不得影响 SERIOUS。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Override_DoesNotLeakAcrossSceneOrSeverity()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Global(2, "S1", "SERIOUS", "L1_OPERATOR"),
|
|
|
|
|
+ Global(3, "S2", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(3, effective.Count);
|
|
|
|
|
+ Assert.Equal(100, effective.Single(x => x.SceneCode == "S1" && x.Severity == "FOLLOW").Id);
|
|
|
|
|
+ Assert.Equal(2, effective.Single(x => x.SceneCode == "S1" && x.Severity == "SERIOUS").Id);
|
|
|
|
|
+ Assert.Equal(3, effective.Single(x => x.SceneCode == "S2").Id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// severity 参与分组前必须归一:平台默认写 FOLLOW、工厂行写 legacy 的 LOW,
|
|
|
|
|
+ /// 二者是同一档,必须归到同一组由工厂接管,而不是各发一条。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void SeverityIsNormalizedBeforeGrouping()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Factory(100, "S1", "LOW", "L1_OPERATOR"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Single(effective);
|
|
|
|
|
+ Assert.Equal(100, effective[0].Id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>level_code 归一:大小写 / 空白差异不得让工厂行与平台默认分到两组。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void LevelIsNormalizedBeforeGrouping()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Factory(100, "S1", "FOLLOW", " l1_operator "),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Single(effective);
|
|
|
|
|
+ Assert.Equal(100, effective[0].Id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 历史脏数据:level_code = '' 的行自成一组,既不吞掉合法层级、也不被合法层级吞掉。
|
|
|
|
|
+ /// (库中实测存在这样的行。)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void EmptyLevelCode_FormsItsOwnGroup()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "SERIOUS", "L1_OPERATOR"),
|
|
|
|
|
+ Factory(100, "S1", "SERIOUS", ""),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(2, effective.Count);
|
|
|
|
|
+ Assert.Contains(effective, x => x.Id == 1);
|
|
|
|
|
+ Assert.Contains(effective, x => x.Id == 100);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 无唯一索引,同键可能重复行:必须全部保留(丢弃会让 runtime 与页面不一致),
|
|
|
|
|
+ /// 且顺序按 Id 稳定,不依赖 DB 返回序。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void DuplicateRowsOnSameKey_AreAllKept_InStableIdOrder()
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidates = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Factory(300, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var effective = S8NotificationLayerMerge.Effective(candidates);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(new[] { 100L, 300L }, effective.Select(x => x.Id));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>输入顺序不影响输出:稳定确定序是 scene → severity → level → id。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void OrderingIsDeterministic_RegardlessOfInputOrder()
|
|
|
|
|
+ {
|
|
|
|
|
+ var rows = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(3, "S2", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Global(2, "S1", "SERIOUS", "L1_OPERATOR"),
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L2_MANAGER"),
|
|
|
|
|
+ Global(4, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var forward = S8NotificationLayerMerge.Effective(rows).Select(x => x.Id).ToList();
|
|
|
|
|
+ var reversed = S8NotificationLayerMerge.Effective(rows.Reverse().ToList()).Select(x => x.Id).ToList();
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(new[] { 4L, 1L, 2L, 3L }, forward);
|
|
|
|
|
+ Assert.Equal(forward, reversed);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>只有平台默认时全部生效;只有工厂行时同理。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void SingleSidedCandidates_PassThrough()
|
|
|
|
|
+ {
|
|
|
|
|
+ var globalsOnly = S8NotificationLayerMerge.Effective(new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ Global(2, "S1", "FOLLOW", "L2_MANAGER"),
|
|
|
|
|
+ });
|
|
|
|
|
+ Assert.Equal(2, globalsOnly.Count);
|
|
|
|
|
+
|
|
|
|
|
+ var factoryOnly = S8NotificationLayerMerge.Effective(new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
|
|
|
|
|
+ });
|
|
|
|
|
+ Assert.Single(factoryOnly);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Empty(S8NotificationLayerMerge.Effective(Array.Empty<AdoS8NotificationLayer>()));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|