S8WatchRulePreviewContractTests.cs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. using System.Reflection;
  2. using Microsoft.AspNetCore.Mvc;
  3. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  4. using Admin.NET.Plugin.AiDOP.Service.S8;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  7. /// <summary>
  8. /// S8-RULE01-INTEGRATION-PREVIEW-1:预演端点的**零副作用**契约。
  9. ///
  10. /// 预演的全部价值建立在一条保证上:<b>它什么都不写</b>。
  11. /// 一旦有人给它加一个"顺便把命中落库"的分支,它就从"启用前的安全窗口"
  12. /// 变成"另一条没人审过的建单入口" —— 而且因为名字叫 preview,没人会去审它。
  13. /// 因此本文件用结构性断言把这条钉死,而不是依赖注释和自觉。
  14. ///
  15. /// 不接 DB、不接 DI:全部断言基于类型结构与依赖形态。
  16. /// </summary>
  17. public class S8WatchRulePreviewContractTests
  18. {
  19. private static readonly Type Service = typeof(S8WatchRulePreviewService);
  20. /// <summary>
  21. /// 预演服务只允许持有「取数 + 判定 + 读既有异常」所需的依赖。
  22. /// 出现任何建单 / 通知 / 流转类服务都意味着它可能产生副作用。
  23. /// </summary>
  24. [Fact]
  25. public void PreviewService_HoldsNoWriteCapableDependency()
  26. {
  27. var deps = Assert.Single(Service.GetConstructors())
  28. .GetParameters().Select(p => p.ParameterType.Name).ToArray();
  29. foreach (var forbidden in new[]
  30. {
  31. "S8ManualReportService", // 建单
  32. "S8TaskFlowService", // 状态流转
  33. "S8NotificationLayerResolver",// 通知
  34. "S8WatchSchedulerService", // 会写 detection / 推进租约
  35. "S8ExceptionService" // 异常写入口
  36. })
  37. Assert.DoesNotContain(forbidden, deps);
  38. // 正向:取数网关与两个只读仓储必须在,否则它没法走生产同款链路。
  39. Assert.Contains("S8MonitoringDataGateway", deps);
  40. }
  41. /// <summary>
  42. /// 预演服务不得出现任何写方法。命名约定在本仓是稳定的
  43. /// (Create/Update/Delete/Save/Insert/Write/Execute…),逐个排除比读注释可靠。
  44. /// </summary>
  45. [Fact]
  46. public void PreviewService_ExposesOnlyPreview()
  47. {
  48. var publicMethods = Service
  49. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  50. .Select(m => m.Name)
  51. .ToArray();
  52. Assert.Equal(new[] { "PreviewAsync" }, publicMethods);
  53. foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Write", "Run", "Execute" })
  54. Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal));
  55. }
  56. /// <summary>
  57. /// 端点必须是 GET。用 POST 会让调用方以为"点一下会发生点什么",
  58. /// 也会让它在审计与网关限流里被归入写操作那一类。
  59. /// </summary>
  60. [Fact]
  61. public void PreviewEndpoint_IsHttpGet_AndScoped()
  62. {
  63. var action = Assert.Single(
  64. typeof(AdoS8ConfigWatchRulesController)
  65. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  66. .Where(m => m.Name == "PreviewAsync"));
  67. var get = Assert.Single(action.GetCustomAttributes<HttpGetAttribute>());
  68. Assert.Equal("{id:long}/preview", get.Template);
  69. Assert.Empty(action.GetCustomAttributes<HttpPostAttribute>());
  70. Assert.Empty(action.GetCustomAttributes<HttpPutAttribute>());
  71. Assert.Empty(action.GetCustomAttributes<HttpDeleteAttribute>());
  72. // 必须带权限;未登记路由会退回平台的「默认放行」。
  73. Assert.NotEmpty(action.GetCustomAttributes()
  74. .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
  75. }
  76. /// <summary>
  77. /// 预演必须能对 enabled=false 的规则工作 —— 这正是它存在的理由:
  78. /// 先看清会命中什么,再决定启不启用。若签名或结果里带上"仅启用规则"的约束,
  79. /// 它就退化成了事后观察工具。
  80. /// </summary>
  81. [Fact]
  82. public void PreviewResult_ReportsEnabledState_WithoutRequiringIt()
  83. {
  84. var enabled = typeof(S8WatchRulePreviewResult).GetProperty("Enabled");
  85. Assert.NotNull(enabled);
  86. Assert.Equal(typeof(bool), enabled!.PropertyType);
  87. // 结果必须能回答"会新建多少 / 会命中既有多少"——只给总命中数无法评估污染规模。
  88. foreach (var required in new[]
  89. { "CandidateRows", "HitCount", "NoHitRows", "WouldCreateCount", "WouldMatchExistingCount" })
  90. Assert.NotNull(typeof(S8WatchRulePreviewResult).GetProperty(required));
  91. }
  92. /// <summary>
  93. /// 单条命中必须带 DedupKey 与既有异常 id:
  94. /// 没有这两项就无法回答"真跑一次会不会重复建单",而那正是启用前最该确认的事。
  95. /// </summary>
  96. [Fact]
  97. public void PreviewHit_CarriesIdentityAndDedupContext()
  98. {
  99. foreach (var required in new[]
  100. {
  101. "SourceObjectType", "SourceObjectId", "RelatedObjectCode",
  102. "ExceptionTypeCode", "Severity", "DedupKey", "ExistingActiveExceptionId"
  103. })
  104. Assert.NotNull(typeof(S8WatchRulePreviewHit).GetProperty(required));
  105. Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType);
  106. }
  107. }