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