| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- 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;
- /// <summary>
- /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:通知分层生效集合的**只读**真库校验。
- ///
- /// <para><b>门禁</b>:仅当 <c>AIDOP_IT=1</c> 时执行(无 DB 的普通 CI 直接跳过,不失败)。
- /// 沿用仓内既有 Pattern B(见 S5/MaterialWarehouse/L0LiveLoaderIntegrationTests):
- /// 连接串从 Admin.NET.Application/Configuration/Database.json 现取,**凭据不写入测试源码**。</para>
- ///
- /// <para><b>严格只读</b>:本文件只执行 SELECT。
- /// CLAUDE.md 第八节规定业务/配置数据写入一律走 API,禁止直连写库绕过应用层的租户隔离、
- /// 字段校验与审计——因此这里**不 INSERT / UPDATE / DELETE 任何一行**,
- /// 「新建工厂覆盖行后生效集合如何变化」改由内存候选集拼装来验证
- /// (拼装用的平台默认行是真库实数据,工厂行是内存构造的假想行)。</para>
- ///
- /// <para><b>为什么不在这里做端到端派发测试</b>:
- /// <c>SqlSugarRepository<T></c> 的无参构造会触碰 <c>Furion.App.HttpContext</c>,
- /// 而 <c>Furion.App</c> 的静态构造在无宿主的 xunit 进程中直接抛 TypeInitializationException
- /// (实测:App.GetAssemblies → App.Settings → NRE)。故 S8NotificationLayerService /
- /// S8NotificationLayerResolver / S8NotificationPushAdapter **无法在纯测试进程内实例化**,
- /// 派发链路与通知日志写入的验证必须在真实运行的服务里做(页面 / 接口),不在本文件冒充。</para>
- /// </summary>
- [Trait("Category", "Integration")]
- public class S8NotificationLayerLiveDataTests
- {
- /// <summary>UAT 可信作用域(只读查询用)。</summary>
- 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,
- });
- }
- /// <summary>只读取回 UAT 视角的候选集:平台默认 (0,0) ∪ 本工厂行。与生产 ListAsync 的取数条件一致。</summary>
- private static async Task<List<AdoS8NotificationLayer>> CandidatesAsync(ISqlSugarClient db) =>
- await db.Queryable<AdoS8NotificationLayer>()
- .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
- || (x.TenantId == UatTenantId && x.FactoryId == UatFactoryId))
- .ToListAsync();
- /// <summary>真库里的平台默认基线:14 条,S1–S7 × {FOLLOW, SERIOUS},均为 L1_OPERATOR。</summary>
- [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" }));
- });
- }
- /// <summary>
- /// 零残留守卫:本批开发过程中曾短暂存在一版会写库的测试(在构造 SqlSugarRepository 时即抛
- /// Furion TypeInitializationException,未走到任何 INSERT)。此用例长期断言
- /// 通知分层表内不存在任何合成测试作用域的行,确保没有测试残渣被当成业务配置。
- /// </summary>
- [Fact]
- public async Task NoSyntheticTestRowsLeakedIntoConfigTable()
- {
- if (!Enabled) return;
- var db = BuildAidopdev();
- Assert.NotNull(db);
- // 95xxxxxxxxxxxxx 是仓内测试作用域惯用号段,正式业务不使用。
- var residue = await db!.Queryable<AdoS8NotificationLayer>()
- .Where(x => x.TenantId >= 950000000000000L && x.TenantId < 960000000000000L)
- .CountAsync();
- Assert.Equal(0, residue);
- var syntheticScenes = await db.Queryable<AdoS8NotificationLayer>()
- .Where(x => x.SceneCode.StartsWith("S8_IT_"))
- .CountAsync();
- Assert.Equal(0, syntheticScenes);
- }
- /// <summary>UAT 当前无工厂覆盖行 → 生效集合应等于平台默认基线本身。</summary>
- [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));
- }
- /// <summary>
- /// N-1 回归(真库平台默认 + 内存假想工厂行,全程不写库):
- /// 工厂只覆盖 S1/FOLLOW 的 L2_MANAGER 时,平台默认的 S1/FOLLOW/L1_OPERATOR 必须仍然生效,
- /// 且其余 13 条平台默认一条都不能少。修正前这里会丢掉 S1/FOLLOW 的平台默认行。
- /// </summary>
- [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")));
- }
- /// <summary>同层级的假想工厂行则应当替换掉该层平台默认,总数不变。</summary>
- [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);
- }
- }
|