using System.Text.RegularExpressions; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using SqlSugar; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:通知分层生效集合的**只读**真库校验。 /// /// 门禁:仅当 AIDOP_IT=1 时执行(无 DB 的普通 CI 直接跳过,不失败)。 /// 沿用仓内既有 Pattern B(见 S5/MaterialWarehouse/L0LiveLoaderIntegrationTests): /// 连接串从 Admin.NET.Application/Configuration/Database.json 现取,**凭据不写入测试源码**。 /// /// 严格只读:本文件只执行 SELECT。 /// CLAUDE.md 第八节规定业务/配置数据写入一律走 API,禁止直连写库绕过应用层的租户隔离、 /// 字段校验与审计——因此这里**不 INSERT / UPDATE / DELETE 任何一行**, /// 「新建工厂覆盖行后生效集合如何变化」改由内存候选集拼装来验证 /// (拼装用的平台默认行是真库实数据,工厂行是内存构造的假想行)。 /// /// 为什么不在这里做端到端派发测试: /// SqlSugarRepository<T> 的无参构造会触碰 Furion.App.HttpContext, /// 而 Furion.App 的静态构造在无宿主的 xunit 进程中直接抛 TypeInitializationException /// (实测:App.GetAssemblies → App.Settings → NRE)。故 S8NotificationLayerService / /// S8NotificationLayerResolver / S8NotificationPushAdapter **无法在纯测试进程内实例化**, /// 派发链路与通知日志写入的验证必须在真实运行的服务里做(页面 / 接口),不在本文件冒充。 /// [Trait("Category", "Integration")] public class S8NotificationLayerLiveDataTests { /// UAT 可信作用域(只读查询用)。 private const long UatTenantId = 838257186181189L; private const long UatFactoryId = 838257186320453L; private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1"; private static ISqlSugarClient? BuildAidopdev() { const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json"; if (!File.Exists(path)) return null; var cs = File.ReadLines(path) .Select(l => l.Trim()) .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev")) .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value) .FirstOrDefault(v => !string.IsNullOrEmpty(v)); if (string.IsNullOrEmpty(cs)) return null; return new SqlSugarScope(new ConnectionConfig { ConfigId = "aidopdev-s8-notify-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true, }); } /// 只读取回 UAT 视角的候选集:平台默认 (0,0) ∪ 本工厂行。与生产 ListAsync 的取数条件一致。 private static async Task> CandidatesAsync(ISqlSugarClient db) => await db.Queryable() .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId) || (x.TenantId == UatTenantId && x.FactoryId == UatFactoryId)) .ToListAsync(); /// 真库里的平台默认基线:14 条,S1–S7 × {FOLLOW, SERIOUS},均为 L1_OPERATOR。 [Fact] public async Task PlatformDefaultBaseline_IsIntactAndCanonical() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var globals = (await CandidatesAsync(db!)) .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)) .ToList(); Assert.Equal(14, globals.Count); Assert.Equal( new[] { "S1", "S2", "S3", "S4", "S5", "S6", "S7" }, globals.Select(x => x.SceneCode).Distinct().OrderBy(x => x, StringComparer.Ordinal)); Assert.All(globals, g => { Assert.Contains(S8SeverityCode.Normalize(g.Severity), new[] { "FOLLOW", "SERIOUS" }); Assert.Equal("L1_OPERATOR", g.LevelCode); // 渠道仍限定在无外发能力的两个渠道内(log 不投递,SignalR 为进程内推送)。 Assert.All( (g.NotifyChannel ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), c => Assert.Contains(c, new[] { "log", "SignalR" })); }); } /// /// 零残留守卫:本批开发过程中曾短暂存在一版会写库的测试(在构造 SqlSugarRepository 时即抛 /// Furion TypeInitializationException,未走到任何 INSERT)。此用例长期断言 /// 通知分层表内不存在任何合成测试作用域的行,确保没有测试残渣被当成业务配置。 /// [Fact] public async Task NoSyntheticTestRowsLeakedIntoConfigTable() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); // 95xxxxxxxxxxxxx 是仓内测试作用域惯用号段,正式业务不使用。 var residue = await db!.Queryable() .Where(x => x.TenantId >= 950000000000000L && x.TenantId < 960000000000000L) .CountAsync(); Assert.Equal(0, residue); var syntheticScenes = await db.Queryable() .Where(x => x.SceneCode.StartsWith("S8_IT_")) .CountAsync(); Assert.Equal(0, syntheticScenes); } /// UAT 当前无工厂覆盖行 → 生效集合应等于平台默认基线本身。 [Fact] public async Task UatEffectiveSet_EqualsPlatformDefault_WhenNoFactoryOverrideExists() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var candidates = await CandidatesAsync(db!); var effective = S8NotificationLayerMerge.Effective(candidates); var factoryRows = candidates.Count(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)); if (factoryRows > 0) return; // 有人正在配工厂覆盖,本断言前提不成立,跳过而非误报 Assert.Equal(14, effective.Count); Assert.All(effective, r => Assert.Equal("GLOBAL", r.Scope)); } /// /// N-1 回归(真库平台默认 + 内存假想工厂行,全程不写库): /// 工厂只覆盖 S1/FOLLOW 的 L2_MANAGER 时,平台默认的 S1/FOLLOW/L1_OPERATOR 必须仍然生效, /// 且其余 13 条平台默认一条都不能少。修正前这里会丢掉 S1/FOLLOW 的平台默认行。 /// [Fact] public async Task HypotheticalFactoryOverrideOnL2_DoesNotSuppressPlatformDefaultOnL1() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var candidates = await CandidatesAsync(db!); if (candidates.Any(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))) return; var hypothetical = new AdoS8NotificationLayer { Id = -1, // 内存假想行,绝不入库 TenantId = UatTenantId, FactoryId = UatFactoryId, SceneCode = "S1", Severity = "FOLLOW", LevelCode = "L2_MANAGER", TargetRoleIds = "ROLE_ORDER_PLANNER", NotifyChannel = "log,SignalR" }; var effective = S8NotificationLayerMerge.Effective(candidates.Append(hypothetical)); // 平台默认 14 条一条不少,外加工厂新增的那一层 Assert.Equal(15, effective.Count); var s1Follow = effective .Where(r => r.SceneCode == "S1" && S8SeverityCode.Normalize(r.Severity) == "FOLLOW") .ToList(); Assert.Equal(2, s1Follow.Count); Assert.Contains(s1Follow, r => r.LevelCode == "L1_OPERATOR" && r.Scope == "GLOBAL"); Assert.Contains(s1Follow, r => r.LevelCode == "L2_MANAGER" && r.Scope == "FACTORY"); // 其它 scene/severity 完全不受影响:14 条平台默认里,除 S1+FOLLOW 那条外的 13 条全部原样保留 Assert.Equal(13, effective.Count(r => r.Scope == "GLOBAL" && !(r.SceneCode == "S1" && S8SeverityCode.Normalize(r.Severity) == "FOLLOW"))); } /// 同层级的假想工厂行则应当替换掉该层平台默认,总数不变。 [Fact] public async Task HypotheticalFactoryOverrideOnSameLevel_ReplacesPlatformDefault() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var candidates = await CandidatesAsync(db!); if (candidates.Any(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))) return; var hypothetical = new AdoS8NotificationLayer { Id = -2, TenantId = UatTenantId, FactoryId = UatFactoryId, SceneCode = "S1", Severity = "FOLLOW", LevelCode = "L1_OPERATOR", TargetRoleIds = "ROLE_ORDER_PLANNER", NotifyChannel = "SignalR" }; var effective = S8NotificationLayerMerge.Effective(candidates.Append(hypothetical)); Assert.Equal(14, effective.Count); var row = effective.Single(r => r.SceneCode == "S1" && S8SeverityCode.Normalize(r.Severity) == "FOLLOW"); Assert.Equal("FACTORY", row.Scope); Assert.Equal(-2, row.Id); } }