S0ConsumerAdoptionTests.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. using System.Text.RegularExpressions;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim;
  4. /// <summary>
  5. /// 消费者迁移契约(Wave 1:Supplier / Warehouse / Equipment)。
  6. ///
  7. /// <para>这些断言守的是**迁移方向不被悄悄回退**:
  8. /// 智慧运营看板筛选下拉的三个主档来源必须读 S0 标准层 <c>dim_*</c>,
  9. /// 而不是各自的源表。此前全仓 27 个标准层对象的业务消费者数为 <b>0</b>——
  10. /// 标准层建好了却没人读,这三条是第一批真实消费。</para>
  11. ///
  12. /// <para>用静态源码断言而非集成测试,是因为要守的性质是「SQL 文本读的是哪张表」,
  13. /// 集成测试只能证明「有结果」,证明不了「结果来自标准层」。</para>
  14. /// </summary>
  15. public class S0ConsumerAdoptionTests
  16. {
  17. private const StringComparison Ord = StringComparison.Ordinal;
  18. private const string ConsumerFile =
  19. "server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilterSearch.cs";
  20. /// <summary>取某个搜索方法的方法体(切到下一个同签名方法为止)。</summary>
  21. private static string BodyOf(string methodName)
  22. {
  23. var src = StripComments(ReadRepoFile(ConsumerFile));
  24. var sigIdx = src.IndexOf($"Task<List<SmartOpsFilterItemRow>> {methodName}(", Ord);
  25. Assert.True(sigIdx >= 0, $"未找到方法 {methodName}");
  26. var nextIdx = src.IndexOf("Task<List<SmartOpsFilterItemRow>> ", sigIdx + 40, Ord);
  27. return nextIdx < 0 ? src[sigIdx..] : src[sigIdx..nextIdx];
  28. }
  29. /// <summary>第一分支(主档)必须来自 dim_*,且源表名彻底消失在该方法内。</summary>
  30. [Theory]
  31. [InlineData("SearchSuppliersAsync", "dim_supplier", "SuppMaster")]
  32. [InlineData("SearchWarehousesAsync", "dim_location", "LocationMaster")]
  33. [InlineData("SearchEquipmentsAsync", "dim_work_center", "WorkCtrMaster")]
  34. public void Migrated_consumer_reads_standard_layer_not_source(
  35. string method, string dimTable, string sourceTable)
  36. {
  37. var body = BodyOf(method);
  38. Assert.Contains($"FROM {dimTable}", body, Ord);
  39. Assert.DoesNotContain($"FROM {sourceTable}", body, Ord);
  40. }
  41. /// <summary>
  42. /// 三条 dim 查询都必须带 <c>tenant_id = @TenantId</c>。
  43. /// tenant_id 是唯一强制的隔离边界,dim_* 是跨租户共表,漏了就是跨租户泄漏。
  44. /// </summary>
  45. [Theory]
  46. [InlineData("SearchSuppliersAsync", "dim_supplier")]
  47. [InlineData("SearchWarehousesAsync", "dim_location")]
  48. [InlineData("SearchEquipmentsAsync", "dim_work_center")]
  49. public void Migrated_consumer_filters_by_tenant(string method, string dimTable)
  50. {
  51. var stmt = DimStatement(BodyOf(method), dimTable);
  52. Assert.Contains("tenant_id = @TenantId", stmt, Ord);
  53. }
  54. /// <summary>
  55. /// 必须过滤停用行。实测省掉 <c>is_active</c> 会在租户 824585161322565
  56. /// 多放出 113 个已停用供应商 —— 这不是洁癖,是可观测的错数。
  57. /// </summary>
  58. [Theory]
  59. [InlineData("SearchSuppliersAsync", "dim_supplier")]
  60. [InlineData("SearchWarehousesAsync", "dim_location")]
  61. [InlineData("SearchEquipmentsAsync", "dim_work_center")]
  62. public void Migrated_consumer_excludes_inactive(string method, string dimTable)
  63. {
  64. var stmt = DimStatement(BodyOf(method), dimTable);
  65. Assert.Contains("is_active", stmt, Ord);
  66. }
  67. /// <summary>
  68. /// 必须 <c>GROUP BY</c> 业务码。dim_work_center / dim_location 的唯一键含
  69. /// <c>domain_code</c>,同码跨 domain 可合法出现两行(实测已有 2 个租户持多 domain);
  70. /// 不聚合时重复行会被下游 <c>seen</c> 去重,却已白占 <c>LIMIT</c> 名额,
  71. /// 表现为「下拉莫名少一项」这种极难归因的静默缺陷。
  72. /// </summary>
  73. [Theory]
  74. [InlineData("SearchSuppliersAsync", "dim_supplier", "supplier_code")]
  75. [InlineData("SearchWarehousesAsync", "dim_location", "location_code")]
  76. [InlineData("SearchEquipmentsAsync", "dim_work_center", "work_center_code")]
  77. public void Migrated_consumer_collapses_duplicate_codes(
  78. string method, string dimTable, string codeColumn)
  79. {
  80. var stmt = DimStatement(BodyOf(method), dimTable);
  81. Assert.Contains($"GROUP BY TRIM({codeColumn})", stmt, Ord);
  82. Assert.Matches(new Regex(@"MIN\(TRIM\(\w+_name\)\)\s+AS Name"), stmt);
  83. }
  84. /// <summary>
  85. /// 对外 Value 必须是业务码,绝不能是 <c>dim_*.id</c> 这类代理键。
  86. /// 代理键每次 FULL REPLACE 都会重编,泄漏出去就是前端筛选值随刷新失效。
  87. /// </summary>
  88. [Theory]
  89. [InlineData("SearchSuppliersAsync", "dim_supplier")]
  90. [InlineData("SearchWarehousesAsync", "dim_location")]
  91. [InlineData("SearchEquipmentsAsync", "dim_work_center")]
  92. public void Migrated_consumer_never_exposes_surrogate_id(string method, string dimTable)
  93. {
  94. var stmt = DimStatement(BodyOf(method), dimTable);
  95. Assert.DoesNotContain("id AS Code", stmt, Ord);
  96. Assert.DoesNotContain("`id`", stmt, Ord);
  97. Assert.Matches(new Regex(@"SELECT TRIM\(\w+_code\)\s+AS Code"), stmt);
  98. }
  99. /// <summary>
  100. /// dim 查询内不得 JOIN 回源表补数据 —— 那等于迁移没做完,
  101. /// 且会让标准层的缺口被源表悄悄填上、永远暴露不出来。
  102. /// </summary>
  103. [Theory]
  104. [InlineData("SearchSuppliersAsync", "dim_supplier")]
  105. [InlineData("SearchWarehousesAsync", "dim_location")]
  106. [InlineData("SearchEquipmentsAsync", "dim_work_center")]
  107. public void Migrated_consumer_does_not_join_back_to_source(string method, string dimTable)
  108. {
  109. var stmt = DimStatement(BodyOf(method), dimTable);
  110. Assert.DoesNotContain("JOIN", stmt, StringComparison.OrdinalIgnoreCase);
  111. Assert.DoesNotContain("COALESCE", stmt, StringComparison.OrdinalIgnoreCase);
  112. }
  113. /// <summary>
  114. /// 第二分支是「补充」不是「兜底」:它由 <c>rows.Count &gt;= limit</c> 短路,
  115. /// 而不是 <c>rows.Count == 0</c>。区别是实质性的——
  116. /// 兜底会在标准层返回 0 行时静默改读源表,让迁移失败看起来像成功。
  117. /// </summary>
  118. [Theory]
  119. [InlineData("SearchSuppliersAsync")]
  120. [InlineData("SearchWarehousesAsync")]
  121. [InlineData("SearchEquipmentsAsync")]
  122. public void Second_branch_is_supplement_not_fallback(string method)
  123. {
  124. var body = BodyOf(method);
  125. Assert.Contains("if (rows.Count >= limit) return rows;", body, Ord);
  126. Assert.DoesNotContain("if (rows.Count == 0)", body, Ord);
  127. }
  128. /// <summary>
  129. /// 取数失败不得静默。降级返回空列表可以保留(该 helper 被 11 个下拉共用,
  130. /// 改成抛出会波及 8 个尚未迁移的消费者),但必须留日志——
  131. /// 否则标准层掉线时前端只看到空下拉,排障无任何抓手。
  132. /// </summary>
  133. [Fact]
  134. public void Query_helpers_do_not_swallow_exceptions_silently()
  135. {
  136. var src = StripComments(ReadRepoFile(ConsumerFile));
  137. Assert.DoesNotContain("catch\n {", src, Ord);
  138. Assert.Equal(2, Regex.Matches(src, @"catch \(Exception ex\)").Count);
  139. Assert.Equal(2, Regex.Matches(src, @"Log\.Error\(").Count);
  140. }
  141. /// <summary>整个消费者文件里,三张已迁移的源主档表必须彻底不再出现。</summary>
  142. [Theory]
  143. [InlineData("SuppMaster")]
  144. [InlineData("LocationMaster")]
  145. [InlineData("WorkCtrMaster")]
  146. public void Migrated_source_tables_have_zero_references_in_consumer(string sourceTable)
  147. {
  148. Assert.DoesNotContain(sourceTable, StripComments(ReadRepoFile(ConsumerFile)), Ord);
  149. }
  150. /// <summary>切出某个 dim 表所在的那条 SQL 语句(从 SELECT 到 LIMIT)。</summary>
  151. private static string DimStatement(string body, string dimTable)
  152. {
  153. var from = body.IndexOf($"FROM {dimTable}", Ord);
  154. Assert.True(from >= 0, $"未找到 FROM {dimTable}");
  155. var select = body.LastIndexOf("SELECT", from, Ord);
  156. var end = body.IndexOf("LIMIT @Limit", from, Ord);
  157. Assert.True(select >= 0 && end > select, "SQL 语句边界解析失败");
  158. return body[select..(end + "LIMIT @Limit".Length)];
  159. }
  160. /// <summary>从仓库根读源文件做静态断言。测试进程工作目录在 bin 下,需向上回溯。</summary>
  161. private static string ReadRepoFile(string relative)
  162. {
  163. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  164. while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "server")))
  165. dir = dir.Parent;
  166. Assert.NotNull(dir);
  167. var path = Path.Combine(dir!.FullName, relative);
  168. Assert.True(File.Exists(path), $"未找到 {path}");
  169. return File.ReadAllText(path);
  170. }
  171. /// <summary>
  172. /// 剥掉 <c>//</c> 与 <c>///</c> 注释行后再做断言。
  173. /// 否则「不得出现 X」这类断言会被解释为什么不能出现 X 的注释本身打败 —— 已踩过一次。
  174. /// </summary>
  175. private static string StripComments(string source) =>
  176. string.Join("\n", source.Split('\n').Where(l => !l.TrimStart().StartsWith("//", Ord)));
  177. }