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);
}
}