S8NotificationLayerLiveDataTests.cs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. using Admin.NET.Plugin.AiDOP.Service.S8;
  6. using SqlSugar;
  7. using Xunit;
  8. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  9. /// <summary>
  10. /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:通知分层生效集合的**只读**真库校验。
  11. ///
  12. /// <para><b>门禁</b>:仅当 <c>AIDOP_IT=1</c> 时执行(无 DB 的普通 CI 直接跳过,不失败)。
  13. /// 沿用仓内既有 Pattern B(见 S5/MaterialWarehouse/L0LiveLoaderIntegrationTests):
  14. /// 连接串从 Admin.NET.Application/Configuration/Database.json 现取,**凭据不写入测试源码**。</para>
  15. ///
  16. /// <para><b>严格只读</b>:本文件只执行 SELECT。
  17. /// CLAUDE.md 第八节规定业务/配置数据写入一律走 API,禁止直连写库绕过应用层的租户隔离、
  18. /// 字段校验与审计——因此这里**不 INSERT / UPDATE / DELETE 任何一行**,
  19. /// 「新建工厂覆盖行后生效集合如何变化」改由内存候选集拼装来验证
  20. /// (拼装用的平台默认行是真库实数据,工厂行是内存构造的假想行)。</para>
  21. ///
  22. /// <para><b>为什么不在这里做端到端派发测试</b>:
  23. /// <c>SqlSugarRepository&lt;T&gt;</c> 的无参构造会触碰 <c>Furion.App.HttpContext</c>,
  24. /// 而 <c>Furion.App</c> 的静态构造在无宿主的 xunit 进程中直接抛 TypeInitializationException
  25. /// (实测:App.GetAssemblies → App.Settings → NRE)。故 S8NotificationLayerService /
  26. /// S8NotificationLayerResolver / S8NotificationPushAdapter **无法在纯测试进程内实例化**,
  27. /// 派发链路与通知日志写入的验证必须在真实运行的服务里做(页面 / 接口),不在本文件冒充。</para>
  28. /// </summary>
  29. [Trait("Category", "Integration")]
  30. public class S8NotificationLayerLiveDataTests
  31. {
  32. /// <summary>UAT 可信作用域(只读查询用)。</summary>
  33. private const long UatTenantId = 838257186181189L;
  34. private const long UatFactoryId = 838257186320453L;
  35. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  36. private static ISqlSugarClient? BuildAidopdev()
  37. {
  38. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  39. if (!File.Exists(path)) return null;
  40. var cs = File.ReadLines(path)
  41. .Select(l => l.Trim())
  42. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
  43. && l.Contains("Database=aidopdev"))
  44. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  45. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  46. if (string.IsNullOrEmpty(cs)) return null;
  47. return new SqlSugarScope(new ConnectionConfig
  48. {
  49. ConfigId = "aidopdev-s8-notify-it",
  50. DbType = DbType.MySql,
  51. ConnectionString = cs,
  52. IsAutoCloseConnection = true,
  53. });
  54. }
  55. /// <summary>只读取回 UAT 视角的候选集:平台默认 (0,0) ∪ 本工厂行。与生产 ListAsync 的取数条件一致。</summary>
  56. private static async Task<List<AdoS8NotificationLayer>> CandidatesAsync(ISqlSugarClient db) =>
  57. await db.Queryable<AdoS8NotificationLayer>()
  58. .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
  59. || (x.TenantId == UatTenantId && x.FactoryId == UatFactoryId))
  60. .ToListAsync();
  61. /// <summary>真库里的平台默认基线:14 条,S1–S7 × {FOLLOW, SERIOUS},均为 L1_OPERATOR。</summary>
  62. [Fact]
  63. public async Task PlatformDefaultBaseline_IsIntactAndCanonical()
  64. {
  65. if (!Enabled) return;
  66. var db = BuildAidopdev();
  67. Assert.NotNull(db);
  68. var globals = (await CandidatesAsync(db!))
  69. .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
  70. .ToList();
  71. Assert.Equal(14, globals.Count);
  72. Assert.Equal(
  73. new[] { "S1", "S2", "S3", "S4", "S5", "S6", "S7" },
  74. globals.Select(x => x.SceneCode).Distinct().OrderBy(x => x, StringComparer.Ordinal));
  75. Assert.All(globals, g =>
  76. {
  77. Assert.Contains(S8SeverityCode.Normalize(g.Severity), new[] { "FOLLOW", "SERIOUS" });
  78. Assert.Equal("L1_OPERATOR", g.LevelCode);
  79. // 渠道仍限定在无外发能力的两个渠道内(log 不投递,SignalR 为进程内推送)。
  80. Assert.All(
  81. (g.NotifyChannel ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
  82. c => Assert.Contains(c, new[] { "log", "SignalR" }));
  83. });
  84. }
  85. /// <summary>
  86. /// 零残留守卫:本批开发过程中曾短暂存在一版会写库的测试(在构造 SqlSugarRepository 时即抛
  87. /// Furion TypeInitializationException,未走到任何 INSERT)。此用例长期断言
  88. /// 通知分层表内不存在任何合成测试作用域的行,确保没有测试残渣被当成业务配置。
  89. /// </summary>
  90. [Fact]
  91. public async Task NoSyntheticTestRowsLeakedIntoConfigTable()
  92. {
  93. if (!Enabled) return;
  94. var db = BuildAidopdev();
  95. Assert.NotNull(db);
  96. // 95xxxxxxxxxxxxx 是仓内测试作用域惯用号段,正式业务不使用。
  97. var residue = await db!.Queryable<AdoS8NotificationLayer>()
  98. .Where(x => x.TenantId >= 950000000000000L && x.TenantId < 960000000000000L)
  99. .CountAsync();
  100. Assert.Equal(0, residue);
  101. var syntheticScenes = await db.Queryable<AdoS8NotificationLayer>()
  102. .Where(x => x.SceneCode.StartsWith("S8_IT_"))
  103. .CountAsync();
  104. Assert.Equal(0, syntheticScenes);
  105. }
  106. /// <summary>UAT 当前无工厂覆盖行 → 生效集合应等于平台默认基线本身。</summary>
  107. [Fact]
  108. public async Task UatEffectiveSet_EqualsPlatformDefault_WhenNoFactoryOverrideExists()
  109. {
  110. if (!Enabled) return;
  111. var db = BuildAidopdev();
  112. Assert.NotNull(db);
  113. var candidates = await CandidatesAsync(db!);
  114. var effective = S8NotificationLayerMerge.Effective(candidates);
  115. var factoryRows = candidates.Count(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId));
  116. if (factoryRows > 0) return; // 有人正在配工厂覆盖,本断言前提不成立,跳过而非误报
  117. Assert.Equal(14, effective.Count);
  118. Assert.All(effective, r => Assert.Equal("GLOBAL", r.Scope));
  119. }
  120. /// <summary>
  121. /// N-1 回归(真库平台默认 + 内存假想工厂行,全程不写库):
  122. /// 工厂只覆盖 S1/FOLLOW 的 L2_MANAGER 时,平台默认的 S1/FOLLOW/L1_OPERATOR 必须仍然生效,
  123. /// 且其余 13 条平台默认一条都不能少。修正前这里会丢掉 S1/FOLLOW 的平台默认行。
  124. /// </summary>
  125. [Fact]
  126. public async Task HypotheticalFactoryOverrideOnL2_DoesNotSuppressPlatformDefaultOnL1()
  127. {
  128. if (!Enabled) return;
  129. var db = BuildAidopdev();
  130. Assert.NotNull(db);
  131. var candidates = await CandidatesAsync(db!);
  132. if (candidates.Any(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))) return;
  133. var hypothetical = new AdoS8NotificationLayer
  134. {
  135. Id = -1, // 内存假想行,绝不入库
  136. TenantId = UatTenantId,
  137. FactoryId = UatFactoryId,
  138. SceneCode = "S1",
  139. Severity = "FOLLOW",
  140. LevelCode = "L2_MANAGER",
  141. TargetRoleIds = "ROLE_ORDER_PLANNER",
  142. NotifyChannel = "log,SignalR"
  143. };
  144. var effective = S8NotificationLayerMerge.Effective(candidates.Append(hypothetical));
  145. // 平台默认 14 条一条不少,外加工厂新增的那一层
  146. Assert.Equal(15, effective.Count);
  147. var s1Follow = effective
  148. .Where(r => r.SceneCode == "S1" && S8SeverityCode.Normalize(r.Severity) == "FOLLOW")
  149. .ToList();
  150. Assert.Equal(2, s1Follow.Count);
  151. Assert.Contains(s1Follow, r => r.LevelCode == "L1_OPERATOR" && r.Scope == "GLOBAL");
  152. Assert.Contains(s1Follow, r => r.LevelCode == "L2_MANAGER" && r.Scope == "FACTORY");
  153. // 其它 scene/severity 完全不受影响:14 条平台默认里,除 S1+FOLLOW 那条外的 13 条全部原样保留
  154. Assert.Equal(13, effective.Count(r => r.Scope == "GLOBAL"
  155. && !(r.SceneCode == "S1"
  156. && S8SeverityCode.Normalize(r.Severity) == "FOLLOW")));
  157. }
  158. /// <summary>同层级的假想工厂行则应当替换掉该层平台默认,总数不变。</summary>
  159. [Fact]
  160. public async Task HypotheticalFactoryOverrideOnSameLevel_ReplacesPlatformDefault()
  161. {
  162. if (!Enabled) return;
  163. var db = BuildAidopdev();
  164. Assert.NotNull(db);
  165. var candidates = await CandidatesAsync(db!);
  166. if (candidates.Any(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))) return;
  167. var hypothetical = new AdoS8NotificationLayer
  168. {
  169. Id = -2,
  170. TenantId = UatTenantId,
  171. FactoryId = UatFactoryId,
  172. SceneCode = "S1",
  173. Severity = "FOLLOW",
  174. LevelCode = "L1_OPERATOR",
  175. TargetRoleIds = "ROLE_ORDER_PLANNER",
  176. NotifyChannel = "SignalR"
  177. };
  178. var effective = S8NotificationLayerMerge.Effective(candidates.Append(hypothetical));
  179. Assert.Equal(14, effective.Count);
  180. var row = effective.Single(r => r.SceneCode == "S1"
  181. && S8SeverityCode.Normalize(r.Severity) == "FOLLOW");
  182. Assert.Equal("FACTORY", row.Scope);
  183. Assert.Equal(-2, row.Id);
  184. }
  185. }