S8WatchRuleWriteGuardTests.cs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. using System.Reflection;
  2. using System.Runtime.CompilerServices;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Admin.NET.Plugin.AiDOP.Service.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  10. /// <summary>
  11. /// S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:钉住 CFG_WATCH 的
  12. /// 「整实体 PUT 退役 + 新建 canonical 词表」契约。
  13. ///
  14. /// <para><b>不触任何数据库。</b> 退役守卫在**任何 DB 访问之前**抛出,词表校验是纯静态方法,
  15. /// 所以都不需要可用的仓储。用 <see cref="RuntimeHelpers.GetUninitializedObject"/> 绕开构造函数
  16. /// (其 <c>SqlSugarRepository&lt;T&gt;</c> 参数的无参构造会触发 Furion.App 静态初始化,
  17. /// 在无宿主的 xunit 进程内必抛 TypeInitializationException)。
  18. /// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败——
  19. /// 这正是我们要的信号:<b>守卫必须前置</b>。</para>
  20. /// </summary>
  21. public class S8WatchRuleWriteGuardTests
  22. {
  23. private static S8WatchRuleService Svc() =>
  24. (S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
  25. private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
  26. private static AdoS8WatchRule Body() => new()
  27. {
  28. RuleCode = "TEST-WATCH-X",
  29. SceneCode = "S1",
  30. RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
  31. Severity = S8SeverityCode.Follow,
  32. DataSourceId = 5,
  33. WatchObjectType = "ORDER",
  34. PollIntervalSeconds = 300,
  35. };
  36. // ---------------- P0:整实体 PUT 退役 ----------------
  37. [Fact]
  38. public void UpdateAsync_IsRetired_WithWatchSpecificMessage()
  39. {
  40. var ex = Assert.Throws<S8WriteRetiredException>(
  41. () => { _ = Svc().UpdateAsync(1L, Body(), Scope); });
  42. Assert.Equal(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
  43. // 不能沿用 CFG_ALERT 的文案:那会让用户误以为「监控规则整体只读了」。
  44. Assert.NotEqual(S8WriteRetiredException.UserMessage, ex.Message);
  45. Assert.Contains("专用配置接口", ex.Message);
  46. }
  47. /// <summary>越权 / 不存在的 id 同样 410,不得因 id 不同回落 404 分支(避免存在性探测)。</summary>
  48. [Theory]
  49. [InlineData(0L)]
  50. [InlineData(187L)] // UAT 真实存在的 stale 行
  51. [InlineData(1329909460001L)] // Golden watch rule
  52. [InlineData(999999999L)] // 不存在
  53. public void UpdateAsync_IsRetired_RegardlessOfId(long id)
  54. {
  55. Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().UpdateAsync(id, Body(), Scope); });
  56. }
  57. /// <summary>退役异常必须继承 S8BizException,使只捕获基类的旧调用方安全降级为 400 而非 500。</summary>
  58. [Fact]
  59. public void RetiredException_DerivesFromBizException_AndIsNotNotFound()
  60. {
  61. Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8WriteRetiredException)));
  62. Assert.False(typeof(S8NotFoundException).IsAssignableFrom(typeof(S8WriteRetiredException)));
  63. }
  64. /// <summary>窄写入口与读路径必须保留——退役的只是整实体 PUT。</summary>
  65. [Fact]
  66. public void NarrowWritePathsAndReadPath_ArePreserved()
  67. {
  68. var t = typeof(S8WatchRuleService);
  69. Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
  70. Assert.NotNull(t.GetMethod("UpdateParamsAsync", new[] { typeof(long), typeof(S8WatchRuleParamsPayload), typeof(S8TrustedScope) }));
  71. Assert.NotNull(t.GetMethod("UpdateScheduleAsync", new[] { typeof(long), typeof(S8WatchRuleSchedulePayload), typeof(S8TrustedScope) }));
  72. Assert.NotNull(t.GetMethod("PauseAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
  73. Assert.NotNull(t.GetMethod("ResumeAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
  74. Assert.NotNull(t.GetMethod("RunNowAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
  75. }
  76. // ---------------- P1:新建 canonical 词表 ----------------
  77. private static void Validate(AdoS8WatchRule body)
  78. {
  79. var m = typeof(S8WatchRuleService).GetMethod(
  80. "ValidateVocabularyForCreate", BindingFlags.NonPublic | BindingFlags.Static)!;
  81. try { m.Invoke(null, new object[] { body }); }
  82. catch (TargetInvocationException tie) { throw tie.InnerException!; }
  83. }
  84. [Fact]
  85. public void Create_AcceptsCanonicalVocabulary()
  86. {
  87. Validate(Body()); // 反向对照:合法值必须通过,否则「全拒」可能是误伤
  88. }
  89. [Theory]
  90. [InlineData("timeout")] // 大小写:调度器 switch 是 ordinal 敏感的
  91. [InlineData("NOT_A_TYPE")]
  92. [InlineData("")]
  93. [InlineData(null)]
  94. public void Create_RejectsNonCanonicalRuleType(string? ruleType)
  95. {
  96. var b = Body(); b.RuleType = ruleType;
  97. Assert.Throws<S8BizException>(() => Validate(b));
  98. }
  99. [Theory]
  100. [InlineData("S2S6_PRODUCTION")] // legacy 复合场景
  101. [InlineData("S8_DEMO_DEFAULT")]
  102. [InlineData("NOT_A_SCENE")]
  103. public void Create_RejectsNonCanonicalScene(string scene)
  104. {
  105. var b = Body(); b.SceneCode = scene;
  106. Assert.Throws<S8BizException>(() => Validate(b));
  107. }
  108. /// <summary>
  109. /// 关键回归:severity 必须在 Normalize **之前**校验。
  110. /// HIGH 正是 DB 中 id=187 那一行的实际值——它当年就是被 Normalize 静默吞掉才存进来的。
  111. /// </summary>
  112. [Theory]
  113. [InlineData("HIGH")]
  114. [InlineData("LOW")]
  115. [InlineData("MEDIUM")]
  116. [InlineData("CRITICAL")]
  117. [InlineData("bogus")]
  118. public void Create_RejectsLegacyAndInvalidSeverity_BeforeNormalize(string severity)
  119. {
  120. var b = Body(); b.Severity = severity;
  121. var ex = Assert.Throws<S8BizException>(() => Validate(b));
  122. Assert.Contains(severity, ex.Message);
  123. // 若校验被放到 Normalize 之后,HIGH 会被静默降级成 FOLLOW 而不是抛错。
  124. Assert.NotEqual(S8SeverityCode.Normalize(severity), severity);
  125. }
  126. /// <summary>canonical rule_type 集合必须与真实 evaluator 常量同源,不得手抄字面量。</summary>
  127. [Fact]
  128. public void CanonicalRuleTypes_MatchEvaluatorRegistry()
  129. {
  130. var f = typeof(S8WatchRuleService).GetField(
  131. "CanonicalRuleTypes", BindingFlags.NonPublic | BindingFlags.Static)!;
  132. var actual = (string[])f.GetValue(null)!;
  133. Assert.Equal(
  134. new[] { S8TimeoutRuleEvaluator.RuleTypeCode, S8ShortageRuleEvaluator.RuleTypeCode, S8OutOfRangeRuleEvaluator.RuleTypeCode },
  135. actual);
  136. }
  137. }