| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153 |
- 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;
- /// <summary>
- /// S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:钉住 CFG_WATCH 的
- /// 「整实体 PUT 退役 + 新建 canonical 词表」契约。
- ///
- /// <para><b>不触任何数据库。</b> 退役守卫在**任何 DB 访问之前**抛出,词表校验是纯静态方法,
- /// 所以都不需要可用的仓储。用 <see cref="RuntimeHelpers.GetUninitializedObject"/> 绕开构造函数
- /// (其 <c>SqlSugarRepository<T></c> 参数的无参构造会触发 Furion.App 静态初始化,
- /// 在无宿主的 xunit 进程内必抛 TypeInitializationException)。
- /// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败——
- /// 这正是我们要的信号:<b>守卫必须前置</b>。</para>
- /// </summary>
- 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<S8WriteRetiredException>(
- () => { _ = Svc().UpdateAsync(1L, Body(), Scope); });
- Assert.Equal(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
- // 不能沿用 CFG_ALERT 的文案:那会让用户误以为「监控规则整体只读了」。
- Assert.NotEqual(S8WriteRetiredException.UserMessage, ex.Message);
- Assert.Contains("专用配置接口", ex.Message);
- }
- /// <summary>越权 / 不存在的 id 同样 410,不得因 id 不同回落 404 分支(避免存在性探测)。</summary>
- [Theory]
- [InlineData(0L)]
- [InlineData(187L)] // UAT 真实存在的 stale 行
- [InlineData(1329909460001L)] // Golden watch rule
- [InlineData(999999999L)] // 不存在
- public void UpdateAsync_IsRetired_RegardlessOfId(long id)
- {
- Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().UpdateAsync(id, Body(), Scope); });
- }
- /// <summary>退役异常必须继承 S8BizException,使只捕获基类的旧调用方安全降级为 400 而非 500。</summary>
- [Fact]
- public void RetiredException_DerivesFromBizException_AndIsNotNotFound()
- {
- Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8WriteRetiredException)));
- Assert.False(typeof(S8NotFoundException).IsAssignableFrom(typeof(S8WriteRetiredException)));
- }
- /// <summary>窄写入口与读路径必须保留——退役的只是整实体 PUT。</summary>
- [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<S8BizException>(() => 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<S8BizException>(() => Validate(b));
- }
- /// <summary>
- /// 关键回归:severity 必须在 Normalize **之前**校验。
- /// HIGH 正是 DB 中 id=187 那一行的实际值——它当年就是被 Normalize 静默吞掉才存进来的。
- /// </summary>
- [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<S8BizException>(() => Validate(b));
- Assert.Contains(severity, ex.Message);
- // 若校验被放到 Normalize 之后,HIGH 会被静默降级成 FOLLOW 而不是抛错。
- Assert.NotEqual(S8SeverityCode.Normalize(severity), severity);
- }
- /// <summary>canonical rule_type 集合必须与真实 evaluator 常量同源,不得手抄字面量。</summary>
- [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);
- }
- }
|