using System.Runtime.CompilerServices; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Service.S8; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-STEP6D-CFG-ALERT-LEGACY-WRITE-GUARD-CERT-1:钉住 CFG_ALERT 的 /// 「历史兼容只读 + 写入口退役」契约。 /// /// 不触任何数据库。 这不是靠 mock 硬凑出来的——写方法在**任何 DB 访问之前** /// 就抛出,所以根本不需要可用的仓储。用 /// 绕开构造函数(其参数 SqlSugarRepository<T> 的无参构造会触发 Furion.App /// 静态初始化,在无宿主的 xunit 进程内必抛 TypeInitializationException)。 /// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 而失败—— /// 这正是我们想要的信号:**守卫必须前置**。 /// public class S8AlertRuleWriteRetiredTests { private static S8AlertRuleService Svc() => (S8AlertRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8AlertRuleService)); private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L); private static AdoS8ConfigAlertRulesController Controller() => new(Svc(), null!); private static void AssertGone(IActionResult result) { var objectResult = Assert.IsType(result); Assert.Equal(StatusCodes.Status410Gone, objectResult.StatusCode); var message = objectResult.Value!.GetType().GetProperty("message")!.GetValue(objectResult.Value) as string; Assert.Equal(S8WriteRetiredException.UserMessage, message); } [Fact] public void CreateAsync_IsRetired() { var ex = Assert.Throws( () => { _ = Svc().CreateAsync(new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }, Scope); }); Assert.Equal(S8WriteRetiredException.UserMessage, ex.Message); } [Fact] public void UpdateAsync_IsRetired() { Assert.Throws( () => { _ = Svc().UpdateAsync(1L, new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }, Scope); }); } [Fact] public void DeleteAsync_IsRetired() { Assert.Throws(() => { _ = Svc().DeleteAsync(1L, Scope); }); } [Fact] public async Task Controller_Create_Returns410_WithoutScopeOrRepositoryAccess() { AssertGone(await Controller().CreateAsync(new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" })); } [Theory] [InlineData(1L)] [InlineData(999999999L)] public async Task Controller_UpdateAndDelete_Return410_RegardlessOfId(long id) { var controller = Controller(); AssertGone(await controller.UpdateAsync(id, new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" })); AssertGone(await controller.DeleteAsync(id)); } /// 越权 / 不存在的 Id 同样返回「已退役」,不得因 Id 不同而走回 404 分支。 [Theory] [InlineData(0L)] [InlineData(1L)] // Demo 租户实际存在的 G01_TEST_ALERT [InlineData(999999999L)] // 不存在 public void Writes_AreRetired_RegardlessOfId(long id) { Assert.Throws(() => { _ = Svc().DeleteAsync(id, Scope); }); Assert.Throws( () => { _ = Svc().UpdateAsync(id, new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }, Scope); }); } /// 读路径必须保留:历史兼容数据仍要能查。 [Fact] public void ReadPath_IsPreserved() { Assert.NotNull(typeof(S8AlertRuleService).GetMethod("ListAsync", new[] { typeof(long), typeof(long) })); } /// /// 继承关系是防御性设计:只捕获 S8BizException 的旧调用方会安全降级为 400,而不是 500。 /// [Fact] public void RetiredException_DerivesFromBizException() { Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8WriteRetiredException))); Assert.False(typeof(S8NotFoundException).IsAssignableFrom(typeof(S8WriteRetiredException))); } /// 用户文案必须同时说明「只读」与「当前权威是监控规则」,且不含内部实现细节。 [Fact] public void UserMessage_StatesReadOnlyAndWatchRuleAuthority() { var m = S8WriteRetiredException.UserMessage; Assert.Contains("只读", m); Assert.Contains("监控规则", m); foreach (var leak in new[] { "Exception", "SqlSugar", "ado_s8_", "Service", "null" }) Assert.DoesNotContain(leak, m, StringComparison.OrdinalIgnoreCase); } }