using System.Reflection;
using System.Runtime.CompilerServices;
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 Admin.NET.Plugin.AiDOP.Service.S8.Rules;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:钉住 CFG_WATCH 的
/// 「整实体 PUT 退役 + 新建 canonical 词表」契约。
///
/// 不触任何数据库。 退役守卫在**任何 DB 访问之前**抛出,词表校验是纯静态方法,
/// 所以都不需要可用的仓储。用 绕开构造函数
/// (其 SqlSugarRepository<T> 参数的无参构造会触发 Furion.App 静态初始化,
/// 在无宿主的 xunit 进程内必抛 TypeInitializationException)。
/// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败——
/// 这正是我们要的信号:守卫必须前置。
///
public class S8WatchRuleWriteGuardTests
{
private static S8WatchRuleService Svc() =>
(S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
private static AdoS8WatchRule Body() => new()
{
RuleCode = "TEST-WATCH-X",
SceneCode = "S1",
RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
Severity = S8SeverityCode.Follow,
DataSourceId = 5,
WatchObjectType = "ORDER",
PollIntervalSeconds = 300,
};
// ---------------- P0:整实体 PUT 退役 ----------------
[Fact]
public void UpdateAsync_IsRetired_WithWatchSpecificMessage()
{
var ex = Assert.Throws(
() => { _ = Svc().UpdateAsync(1L, Body(), Scope); });
Assert.Equal(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
// 不能沿用 CFG_ALERT 的文案:那会让用户误以为「监控规则整体只读了」。
Assert.NotEqual(S8WriteRetiredException.UserMessage, ex.Message);
Assert.Contains("专用配置接口", ex.Message);
}
/// 越权 / 不存在的 id 同样 410,不得因 id 不同回落 404 分支(避免存在性探测)。
[Theory]
[InlineData(0L)]
[InlineData(187L)] // UAT 真实存在的 stale 行
[InlineData(1329909460001L)] // Golden watch rule
[InlineData(999999999L)] // 不存在
public void UpdateAsync_IsRetired_RegardlessOfId(long id)
{
Assert.Throws(() => { _ = Svc().UpdateAsync(id, Body(), Scope); });
}
/// 退役异常必须继承 S8BizException,使只捕获基类的旧调用方安全降级为 400 而非 500。
[Fact]
public void RetiredException_DerivesFromBizException_AndIsNotNotFound()
{
Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8WriteRetiredException)));
Assert.False(typeof(S8NotFoundException).IsAssignableFrom(typeof(S8WriteRetiredException)));
}
/// 窄写入口与读路径必须保留——退役的只是整实体 PUT。
[Fact]
public void NarrowWritePathsAndReadPath_ArePreserved()
{
var t = typeof(S8WatchRuleService);
Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
Assert.NotNull(t.GetMethod("UpdateParamsAsync", new[] { typeof(long), typeof(S8WatchRuleParamsPayload), typeof(S8TrustedScope) }));
Assert.NotNull(t.GetMethod("UpdateScheduleAsync", new[] { typeof(long), typeof(S8WatchRuleSchedulePayload), typeof(S8TrustedScope) }));
Assert.NotNull(t.GetMethod("PauseAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
Assert.NotNull(t.GetMethod("ResumeAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
Assert.NotNull(t.GetMethod("RunNowAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
}
// ---------------- P1:新建 canonical 词表 ----------------
private static void Validate(AdoS8WatchRule body)
{
var m = typeof(S8WatchRuleService).GetMethod(
"ValidateVocabularyForCreate", BindingFlags.NonPublic | BindingFlags.Static)!;
try { m.Invoke(null, new object[] { body }); }
catch (TargetInvocationException tie) { throw tie.InnerException!; }
}
[Fact]
public void Create_AcceptsCanonicalVocabulary()
{
Validate(Body()); // 反向对照:合法值必须通过,否则「全拒」可能是误伤
}
[Theory]
[InlineData("timeout")] // 大小写:调度器 switch 是 ordinal 敏感的
[InlineData("NOT_A_TYPE")]
[InlineData("")]
[InlineData(null)]
public void Create_RejectsNonCanonicalRuleType(string? ruleType)
{
var b = Body(); b.RuleType = ruleType;
Assert.Throws(() => Validate(b));
}
[Theory]
[InlineData("S2S6_PRODUCTION")] // legacy 复合场景
[InlineData("S8_DEMO_DEFAULT")]
[InlineData("NOT_A_SCENE")]
public void Create_RejectsNonCanonicalScene(string scene)
{
var b = Body(); b.SceneCode = scene;
Assert.Throws(() => Validate(b));
}
///
/// 关键回归:severity 必须在 Normalize **之前**校验。
/// HIGH 正是 DB 中 id=187 那一行的实际值——它当年就是被 Normalize 静默吞掉才存进来的。
///
[Theory]
[InlineData("HIGH")]
[InlineData("LOW")]
[InlineData("MEDIUM")]
[InlineData("CRITICAL")]
[InlineData("bogus")]
public void Create_RejectsLegacyAndInvalidSeverity_BeforeNormalize(string severity)
{
var b = Body(); b.Severity = severity;
var ex = Assert.Throws(() => Validate(b));
Assert.Contains(severity, ex.Message);
// 若校验被放到 Normalize 之后,HIGH 会被静默降级成 FOLLOW 而不是抛错。
Assert.NotEqual(S8SeverityCode.Normalize(severity), severity);
}
/// canonical rule_type 集合必须与真实 evaluator 常量同源,不得手抄字面量。
[Fact]
public void CanonicalRuleTypes_MatchEvaluatorRegistry()
{
var f = typeof(S8WatchRuleService).GetField(
"CanonicalRuleTypes", BindingFlags.NonPublic | BindingFlags.Static)!;
var actual = (string[])f.GetValue(null)!;
Assert.Equal(
new[] { S8TimeoutRuleEvaluator.RuleTypeCode, S8ShortageRuleEvaluator.RuleTypeCode, S8OutOfRangeRuleEvaluator.RuleTypeCode },
actual);
}
}