S8TenantOnlyMainChainTests.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. using System.Reflection;
  2. using System.Text.RegularExpressions;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using SqlSugar;
  5. using Admin.NET.Plugin.AiDOP.Service.S8;
  6. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  8. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  9. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  10. using Xunit;
  11. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  12. /// <summary>
  13. /// S8-TENANT-ONLY-BATCH5:规则执行主链收敛为租户级作用域。
  14. ///
  15. /// <para><b>本批要消灭的是"伪 Factory Scope"</b>:取数从一开始就是租户级的
  16. /// (唯一 Provider 的 WHERE 只有 <c>tenant_id</c>),但规则归属、调度作用域、
  17. /// dedup 身份、抗抖状态唯一键却统统按工厂切。结果是两件事同时发生:</para>
  18. /// <list type="bullet">
  19. /// <item>零工厂 / 多工厂租户被形式门槛拒之门外,而放不放行对取出的数据毫无影响;</item>
  20. /// <item>Batch 2 供给出来的 <c>factory_id = 0</c> 运行策略永远不被调度,且不报任何错
  21. /// —— 页面显示已启用,实际从不执行。</item>
  22. /// </list>
  23. ///
  24. /// <para>本文件的多数断言是**反向的**("不得出现 factory"),因为这类回归的特征是
  25. /// 悄悄加回一个谓词就让一批规则静默失效,正向断言抓不到。</para>
  26. /// </summary>
  27. public class S8TenantOnlyMainChainTests
  28. {
  29. private const long TenantA = 838257186181189L;
  30. private const long TenantB = 838257212780613L;
  31. private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
  32. private static S8RuleDefinition Definition() =>
  33. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
  34. .GetRequired(Rule01);
  35. // ───────────────── T1:取数请求契约 ─────────────────
  36. [Fact]
  37. public void T1_MonitoringDataRequest_HasNoFactoryScope()
  38. {
  39. Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
  40. // 只缺租户才失败;工厂已不是作用域的一部分,无从缺失。
  41. var req = new S8MonitoringDataRequest
  42. {
  43. TenantId = TenantA, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT"
  44. };
  45. req.EnsureValid(); // 不抛即通过
  46. var invalid = new S8MonitoringDataRequest
  47. {
  48. TenantId = 0, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT"
  49. };
  50. var ex = Assert.Throws<S8RuleEvaluatorException>(() => invalid.EnsureValid());
  51. Assert.Equal("dataset_scope_invalid", ex.Reason);
  52. Assert.DoesNotContain("FactoryId", ex.Message, StringComparison.Ordinal);
  53. }
  54. // ───────────────── T2 / T4 / T6 / T16:FactoryId = 0 不再阻塞 ─────────────────
  55. /// <summary>
  56. /// Enable Gate 不得再因 <c>factory_id &lt;= 0</c> 拒绝规则。
  57. /// 这条直接决定 Batch 2 供给出来的运行策略能不能被启用。
  58. /// </summary>
  59. [Fact]
  60. public void T2_EnableGate_TakesTenantOnly_AndHasNoFactoryOverload()
  61. {
  62. var gate = typeof(S8DatasetEnableGate);
  63. Assert.NotNull(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long) }));
  64. Assert.Null(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) }));
  65. Assert.NotNull(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long) }));
  66. Assert.Null(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) }));
  67. }
  68. [Fact]
  69. public void T2_EnableGate_SourceHasNoInvalidFactoryScopeCheck()
  70. {
  71. var code = CodeOnly("Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs");
  72. Assert.DoesNotContain("InvalidFactoryScope", code);
  73. Assert.DoesNotContain("factoryId <= 0", code);
  74. // 反向对照:租户门槛必须还在,否则是把门整个拆了而不是收敛。
  75. Assert.Contains("tenantId <= 0", code);
  76. Assert.Contains("InvalidTenantScope", code);
  77. }
  78. /// <summary>
  79. /// T4 / T16:Batch 2 供给出来的行 <c>factory_id = 0</c>。
  80. /// 调度候选查询若还带工厂谓词,这些行会被静默排除 —— 断言源码里不存在该谓词。
  81. /// </summary>
  82. [Fact]
  83. public void T4_T16_ProvisionedFactoryZeroRule_IsNotExcludedByScheduler()
  84. {
  85. Assert.Equal(0L, S8RuleProvisioningService.CompatibilityFactoryId);
  86. var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
  87. Assert.DoesNotContain("AND r.factory_id > 0", code);
  88. Assert.DoesNotContain("x.FactoryId == factoryId", code);
  89. }
  90. // ───────────────── T3 / T5 / T6:Tenant → Rule ─────────────────
  91. [Fact]
  92. public void T3_Scheduler_EnumeratesTenants_ReturningPlainTenantIds()
  93. {
  94. var m = typeof(S8WatchSchedulerService).GetMethod("ListEnabledTenantsAsync");
  95. Assert.NotNull(m);
  96. Assert.Equal(typeof(Task<List<long>>), m!.ReturnType);
  97. Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync"));
  98. }
  99. /// <summary>
  100. /// T5 / T6:0 / 1 / N 个工厂的租户都只产生**一次**调度。
  101. ///
  102. /// <para>工厂数量根本不是调度遍历的输入 —— 这正是要断言的事实:
  103. /// 遍历返回的是租户 id 列表,一个租户在里面只可能出现一次(SQL 的 DISTINCT tenant_id),
  104. /// 因此无论它有几个工厂,规则都只跑一遍。</para>
  105. /// </summary>
  106. [Theory]
  107. [InlineData(0)]
  108. [InlineData(1)]
  109. [InlineData(3)]
  110. public void T5_T6_TenantIsDispatchedOnce_RegardlessOfFactoryCount(int factoryCount)
  111. {
  112. _ = factoryCount; // 不是输入,故意不用
  113. var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
  114. Assert.Contains("SELECT DISTINCT r.tenant_id", code);
  115. Assert.DoesNotContain("DISTINCT r.tenant_id AS TenantId, r.factory_id", code);
  116. // Job 侧按租户遍历,不再按 (租户, 工厂) 对。
  117. var job = CodeOnly("Job/S8WatchSchedulerJob.cs");
  118. Assert.Contains("foreach (var tenantId in tenantIds)", job);
  119. Assert.Contains("RunDispatchTickAsync(tenantId, BatchSize, lockedBy)", job);
  120. }
  121. // ───────────────── T7:Evaluator 契约 ─────────────────
  122. [Fact]
  123. public void T7_EvaluatorContract_HasNoFactoryScope()
  124. {
  125. var names = typeof(IS8RuleEvaluator).GetMethod(nameof(IS8RuleEvaluator.EvaluateAsync))!
  126. .GetParameters().Select(p => p.Name).ToArray();
  127. Assert.Equal(new[] { "tenantId", "rule", "cancellationToken" }, names);
  128. foreach (var impl in new[]
  129. { typeof(S8TimeoutRuleEvaluator), typeof(S8ShortageRuleEvaluator), typeof(S8OutOfRangeRuleEvaluator) })
  130. {
  131. var ps = impl.GetMethod("EvaluateAsync")!.GetParameters().Select(p => p.Name).ToArray();
  132. Assert.DoesNotContain("factoryId", ps);
  133. }
  134. }
  135. // ───────────────── T8 / T9 / T10:DedupKey vNext ─────────────────
  136. [Fact]
  137. public void T8_DedupKey_ContainsNoFactorySegment()
  138. {
  139. var key = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, Rule01, "PURCHASE_ORDER_LINE", "UATA-PO-001#1");
  140. Assert.Equal($"T{TenantA}:R{Rule01}:PURCHASE_ORDER_LINE:UATA-PO-001#1", key);
  141. Assert.DoesNotMatch(new Regex("^T[0-9]+:F[0-9]+:"), key);
  142. // 租户前缀必须完整保留 —— 迁移脚本首版曾把它写掉过(MySQL 反向引用要用 $1 而非 \\1)。
  143. Assert.StartsWith($"T{TenantA}:", key, StringComparison.Ordinal);
  144. }
  145. /// <summary>
  146. /// T9:同租户 / 同规则 / 同对象,无论工厂元数据是什么,dedup 身份必须相同。
  147. /// 三个 evaluator 的公式必须逐字一致,否则跨类型的历史迁移会出现两套格式。
  148. /// </summary>
  149. [Fact]
  150. public void T9_SameTenantRuleObject_YieldsSameDedup_AcrossEvaluators()
  151. {
  152. var t = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
  153. var s = S8ShortageRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
  154. var o = S8OutOfRangeRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
  155. Assert.Equal(t, s);
  156. Assert.Equal(t, o);
  157. }
  158. [Fact]
  159. public void T10_DifferentTenant_SameObject_YieldsDifferentDedup()
  160. {
  161. var a = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
  162. var b = S8TimeoutRuleEvaluator.BuildDedupKey(TenantB, "R", "ORDER", "ID");
  163. Assert.NotEqual(a, b);
  164. Assert.StartsWith($"T{TenantA}:", a, StringComparison.Ordinal);
  165. Assert.StartsWith($"T{TenantB}:", b, StringComparison.Ordinal);
  166. }
  167. [Fact]
  168. public void T8_DedupKey_StillFitsVarchar128()
  169. {
  170. // 去掉 F 段后前缀更短,余量只会变大;本断言防的是将来又往键里塞东西。
  171. var k = S8TimeoutRuleEvaluator.BuildDedupKey(
  172. 9999999999999999L, "VERY_LONG_RULE_CODE_FOR_PADDING_64____",
  173. "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________");
  174. Assert.True(k.Length <= 128, $"dedup_key length {k.Length} exceeds 128: {k}");
  175. }
  176. // ───────────────── T11:DetectionState identity ─────────────────
  177. [Fact]
  178. public void T11_DetectionStateUniqueKey_IsTenantRuleDedup()
  179. {
  180. var uk = typeof(AdoS8RuleDetectionState)
  181. .GetCustomAttributes<SugarIndexAttribute>()
  182. .Single(a => a.IndexName == "uk_s8_rule_detection_state_dedup");
  183. Assert.True(uk.IsUnique);
  184. Assert.Equal(new[] { "TenantId", "RuleCode", "DedupKey" }, uk.IndexFields.Keys.ToArray());
  185. Assert.DoesNotContain("FactoryId", uk.IndexFields.Keys);
  186. }
  187. [Fact]
  188. public void T11_DetectionStateUpsert_LooksUpWithoutFactory()
  189. {
  190. var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
  191. // 抗抖状态的 upsert / 累计必须只按 (tenant, rule_code, dedup_key)。
  192. Assert.Contains("x.RuleCode == rule.RuleCode", code);
  193. Assert.DoesNotContain("x.FactoryId == factoryId", code);
  194. }
  195. // ───────────────── T14 / T15:Rule scope ─────────────────
  196. [Fact]
  197. public void T14_WatchRuleLookup_IsTenantOnly()
  198. {
  199. var code = CodeOnly("Service/S8/S8WatchRuleService.cs");
  200. Assert.Contains("x.Id == id && x.TenantId == scope.TenantId", code);
  201. Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
  202. }
  203. [Fact]
  204. public void T15_WatchRuleUniqueIdentity_IsTenantAndRuleCode()
  205. {
  206. var uk = typeof(AdoS8WatchRule)
  207. .GetCustomAttributes<SugarIndexAttribute>()
  208. .Single(a => a.IndexName == "uk_s8_watch_rule_tenant_code");
  209. Assert.True(uk.IsUnique);
  210. Assert.Equal(new[] { "TenantId", "RuleCode" }, uk.IndexFields.Keys.ToArray());
  211. }
  212. // ───────────────── T13:Preview 与真跑同源 ─────────────────
  213. /// <summary>
  214. /// 预演必须与调度器用同一套作用域与 dedup 口径。
  215. /// 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信 —— 而那正是预演唯一的价值。
  216. /// </summary>
  217. [Fact]
  218. public void T13_Preview_UsesSameTenantOnlyScopeAsScheduler()
  219. {
  220. var code = CodeOnly("Service/S8/S8WatchRulePreviewService.cs");
  221. Assert.Contains("_gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows)", code);
  222. Assert.Contains("evaluator.EvaluateAsync(scope.TenantId, rule)", code);
  223. Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
  224. }
  225. // ───────────────── T17–T22:迁移脚本自身的守卫 ─────────────────
  226. /// <summary>
  227. /// 迁移脚本必须自带碰撞门禁:去掉 F 段后若两个旧键映射到同一个新键,
  228. /// 自动挑一条会静默丢数据,必须让脚本失败。
  229. /// </summary>
  230. [Fact]
  231. public void T17_Migration_HasCollisionPreflightGate()
  232. {
  233. var sql = MigrationSql();
  234. Assert.Contains("@collision_exception", sql);
  235. Assert.Contains("@collision_state", sql);
  236. Assert.Contains("@dup_watch_rule", sql);
  237. // 门禁靠具名 CHECK 约束;约束名即失败原因,会出现在 error_message 里。
  238. Assert.Contains("s8_tenant_only_BLOCKED_see_collision_table_1_0_493", sql);
  239. Assert.Contains("CHECK (probe = 0)", sql);
  240. }
  241. /// <summary>
  242. /// T18:改写必须用 <c>$1</c> 反向引用。
  243. ///
  244. /// <para>MySQL 的 <c>REGEXP_REPLACE</c> 用 ICU 语法,<c>\\1</c> 不是分组引用而会被吞成字面量 "1"
  245. /// —— 租户前缀整段丢失,得到既不是旧格式也不是新格式的坏键。
  246. /// 本地隔离库首次执行时实测踩到过,本断言防它复发。</para>
  247. /// </summary>
  248. [Fact]
  249. public void T18_Migration_UsesDollarBackreference_NotBackslash()
  250. {
  251. var sql = MigrationSql();
  252. Assert.Contains("'$1:'", sql);
  253. Assert.DoesNotContain("'\\\\1:'", sql);
  254. }
  255. /// <summary>T19:改写面必须锚定旧格式,不得做无界字符串替换。</summary>
  256. [Fact]
  257. public void T19_Migration_RewritesOnlyAnchoredLegacyFormat()
  258. {
  259. var sql = MigrationSql();
  260. Assert.Contains("'^T[0-9]+:F[0-9]+:R'", sql);
  261. // 无界替换会误伤任何恰好含 ":F" 的业务对象标识。
  262. Assert.DoesNotContain("REPLACE(dedup_key, ':F'", sql);
  263. // 归档表必须先于改写建立,否则失败后无从恢复。
  264. Assert.True(sql.IndexOf("_dedup_bak_1_0_493", StringComparison.Ordinal)
  265. < sql.IndexOf("UPDATE ado_s8_exception", StringComparison.Ordinal),
  266. "归档必须发生在改写之前");
  267. }
  268. [Fact]
  269. public void T20_T21_Migration_RebuildsTenantOnlyUniqueKeys()
  270. {
  271. var sql = MigrationSql();
  272. Assert.Contains("uk_s8_rule_detection_state_dedup (tenant_id, rule_code, dedup_key)", sql);
  273. Assert.Contains("uk_s8_watch_rule_tenant_code (tenant_id, rule_code)", sql);
  274. }
  275. /// <summary>
  276. /// T22:重复执行安全。改写谓词自带幂等(改完就不再匹配);
  277. /// DDL 全部由 information_schema 存在性守卫,不依赖事务回滚
  278. /// —— MySQL 的 DDL 多数自动提交,假设能回滚是危险的。
  279. /// </summary>
  280. [Fact]
  281. public void T22_Migration_IsRerunSafe()
  282. {
  283. var sql = MigrationSql();
  284. Assert.Contains("information_schema.STATISTICS", sql);
  285. Assert.Contains("INSERT IGNORE INTO ado_s8_exception_dedup_bak_1_0_493", sql);
  286. Assert.Contains("CREATE TABLE IF NOT EXISTS ado_s8_exception_dedup_bak_1_0_493", sql);
  287. // 执行器不支持自定义语句分隔符指令,脚本通篇不得出现该关键字(它做的是全文子串匹配)。
  288. Assert.DoesNotContain("DELIMITER", sql, StringComparison.OrdinalIgnoreCase);
  289. }
  290. /// <summary>迁移刻意不碰的对象:1.0.487 的还原点、异常表的工厂唯一键、以及任何 factory_id 列。</summary>
  291. [Fact]
  292. public void Migration_DoesNotTouchOutOfScopeObjects()
  293. {
  294. // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,
  295. // 那正是我们想要的留档,不该被当成触碰。
  296. var sql = ExecutableSql(MigrationSql());
  297. Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql);
  298. Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", sql);
  299. Assert.DoesNotContain("DROP COLUMN", sql);
  300. Assert.DoesNotContain("mdp_entity", sql);
  301. }
  302. /// <summary>去掉 <c>--</c> 注释行,只留可执行语句。</summary>
  303. private static string ExecutableSql(string sql) =>
  304. string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
  305. // ───────────────── helpers ─────────────────
  306. private static string MigrationSql()
  307. {
  308. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  309. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
  310. dir = dir.Parent;
  311. Assert.NotNull(dir);
  312. var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.493.sql");
  313. Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}");
  314. return File.ReadAllText(path);
  315. }
  316. private static string CodeOnly(string relative)
  317. {
  318. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  319. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  320. dir = dir.Parent;
  321. Assert.NotNull(dir);
  322. var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP",
  323. relative.Replace('/', Path.DirectorySeparatorChar));
  324. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  325. // 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。
  326. return string.Join('\n', File.ReadAllLines(full)
  327. .Where(l =>
  328. {
  329. var t = l.TrimStart();
  330. return !t.StartsWith("///", StringComparison.Ordinal)
  331. && !t.StartsWith("//", StringComparison.Ordinal);
  332. }));
  333. }
  334. }