S8Rule01PublicationContractTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. using System.Reflection;
  2. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  6. /// <summary>
  7. /// Rule01「当前快照」的发布契约。
  8. ///
  9. /// <para><b>这一条守的是一个只在「合法的空」上才现形的缺陷。</b>
  10. /// Provider 原来按「最新的那一天」挑当前快照。源侧被清空的那一轮产出 0 行 DWD,
  11. /// 于是当天根本不存在 <c>stat_date</c> 分区,中台的同日淘汰也无行可删(它只能删「今天」),
  12. /// 「最新的那一天」就回落到<b>上一天</b>,把一批早已作废的行当成当前态返回。</para>
  13. ///
  14. /// <para>本地沙箱实测:租户 838257212780613 源侧 10 → 0、STD 10 → 0、S3 跑批 SUCCESS,
  15. /// 而 DWD 当前快照仍返回前一日的 10 行。行数、状态、批次号全都「看起来正常」,
  16. /// 没有任何一处报错 —— 这类缺陷只能靠契约守住。</para>
  17. /// </summary>
  18. public class S8Rule01PublicationContractTests
  19. {
  20. private static string Source(params string[] parts)
  21. {
  22. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  23. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  24. dir = dir.Parent;
  25. Assert.NotNull(dir);
  26. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  27. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  28. return File.ReadAllText(full);
  29. }
  30. private static string S3Service() => Source("Supply", "S3MdpSyncTransformService.cs");
  31. private static string Resolver() =>
  32. Source("Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs");
  33. private static S8AuthoritySpec Rule01Spec()
  34. {
  35. var spec = new S8BusinessDatasetDefinitions()
  36. .GetDefinitions()
  37. .Single(d => d.DatasetCode == S8BusinessDatasetDefinitions.PurchaseDeliveryCode)
  38. .AuthoritySpec;
  39. Assert.NotNull(spec);
  40. return spec!;
  41. }
  42. // ══════════════════ 生产者侧:发布证据 ══════════════════
  43. /// <summary>
  44. /// 发布证据只在<b>全量</b>路径产生。
  45. ///
  46. /// <para>增量路径(<c>RunInboundAsync</c>)同样以 SUCCESS 收尾,但它只处理变更行。
  47. /// 把这样一个批次指认成「当前快照」,读者拿到的就是一个残缺集合 ——
  48. /// 而且它会以「有发布证据、状态成功」的样子出现,比没有证据更危险。</para>
  49. /// </summary>
  50. [Fact]
  51. public void Publication_IsProducedOnlyOnFullPath()
  52. {
  53. var code = S3Service();
  54. var publishAt = code.IndexOf("result.Publication = await PublishCurrentSnapshotAsync", StringComparison.Ordinal);
  55. Assert.True(publishAt > 0, "全量路径必须写发布证据");
  56. // 发布语句必须落在 if (retireStale) 之内 —— 取其之前最近的一段代码验证。
  57. var guardAt = code.LastIndexOf("if (retireStale)", publishAt, StringComparison.Ordinal);
  58. Assert.True(guardAt > 0 && publishAt - guardAt < 400,
  59. "发布必须被 retireStale 守卫包住,否则增量批次会被指认成当前快照");
  60. }
  61. /// <summary>
  62. /// 发布证据必须在 DWD 的全部写入命令执行完之后才产生。
  63. ///
  64. /// <para>顺序颠倒的后果不是报错,是读者拿到一个还没灌完(或还混着上一批残留)的快照,
  65. /// 而运行日志会把它记成一次正常发布。</para>
  66. ///
  67. /// <para><b>比对的是执行顺序,不是源码顺序</b>:同日淘汰是
  68. /// <c>BuildDwdCommands</c> 这个迭代器 yield 出来的最后一条命令,
  69. /// 而该方法在文件里排在 <c>BuildDwdAsync</c> 之后 —— 按源码位置比会得出相反的结论。
  70. /// 真正的保证是:发布语句排在<b>命令循环之后</b>。淘汰在命令序列内部的位置,
  71. /// 由 <c>S3PurchaseAuthorityContractTests.DwdBuild_RetiresSameDayStaleRows_AfterInsert</c> 守。</para>
  72. /// </summary>
  73. [Fact]
  74. public void Publication_HappensAfterAllDwdCommandsExecuted()
  75. {
  76. var code = S3Service();
  77. var bodyAt = code.IndexOf("private async Task<int> BuildDwdAsync(", StringComparison.Ordinal);
  78. Assert.True(bodyAt > 0, "BuildDwdAsync 不存在");
  79. var loopAt = code.IndexOf("foreach (var command in BuildDwdCommands(", bodyAt, StringComparison.Ordinal);
  80. var shortageAt = code.IndexOf("InsertMaterialShortageAsync(batchId, now)", bodyAt, StringComparison.Ordinal);
  81. var publishAt = code.IndexOf("result.Publication = await PublishCurrentSnapshotAsync", bodyAt, StringComparison.Ordinal);
  82. Assert.True(loopAt > bodyAt, "未找到 DWD 命令执行循环");
  83. Assert.True(publishAt > loopAt, "发布必须在 DWD 命令循环之后");
  84. Assert.True(publishAt > shortageAt, "发布必须在 DWD 阶段的全部写入之后");
  85. }
  86. /// <summary>
  87. /// 发布行数必须用<b>复核查询</b>得到,不能用 INSERT 的影响行数。
  88. /// <para>ODKU 的影响行数(插入 1 / 更新 2 / 无变化 0)从来不等于「当前有几行」。</para>
  89. /// </summary>
  90. [Fact]
  91. public void Publication_CountsRowsByVerificationQuery()
  92. {
  93. var code = S3Service();
  94. Assert.Contains("SELECT COUNT(*) FROM dwd_supplier_delivery", code);
  95. Assert.Contains("AND sync_batch_id=@BatchId", code);
  96. Assert.Contains("AND stat_date=@StatDate", code);
  97. }
  98. /// <summary>
  99. /// 运行摘要必须带上定位当前快照所需的全部字段,且用序列化器生成而非字符串拼接。
  100. /// <para><c>currentRows = 0</c> 是合法值 —— 它正是「成功发布了一个空快照」的表达,
  101. /// 下游据此把「正常的空」与「没发布成」区分开。</para>
  102. /// </summary>
  103. [Fact]
  104. public void RunSummaryJson_CarriesPublicationEvidence()
  105. {
  106. var code = S3Service();
  107. Assert.Contains("JsonSerializer.Serialize", code);
  108. foreach (var field in new[] { "table =", "batchId =", "statDate =", "factoryId =", "currentRows =" })
  109. Assert.Contains(field, code);
  110. // 没发布时必须是 null,不能退化成一个 currentRows=0 的假证据 ——
  111. // 那会把「没发布」伪装成「发布了空快照」,正好抹掉两者的区别。
  112. Assert.Contains("publish = result.Publication is null", code);
  113. }
  114. // ══════════════════ 读者侧:Provider ══════════════════
  115. /// <summary>
  116. /// Provider 必须按发布证据取数,且<b>结构上</b>不存在回落到其它 stat_date 的路径。
  117. /// </summary>
  118. [Fact]
  119. public void Provider_ReadsPublishedSnapshot_WithNoFallbackPath()
  120. {
  121. var sql = S8PurchaseDeliveryDataProvider.Sql;
  122. Assert.Contains("WITH pub AS", sql);
  123. Assert.Contains("'$.publish.batchId'", sql);
  124. Assert.Contains("'$.publish.statDate'", sql);
  125. Assert.Contains("d.sync_batch_id = p.batch_id", sql);
  126. // 只认终态成功的生产运行:该表有大量永不回收的 RUNNING 孤儿行。
  127. Assert.Contains("r.status = 'SUCCESS'", sql);
  128. Assert.Contains("r.end_time IS NOT NULL", sql);
  129. // 旧的「取最新那一天」写法必须彻底消失 —— 留着它就等于留着回落路径。
  130. Assert.DoesNotContain("ORDER BY x.stat_date DESC", sql);
  131. Assert.DoesNotContain("MAX(stat_date)", sql);
  132. Assert.DoesNotContain("MAX(d.stat_date)", sql);
  133. }
  134. /// <summary>
  135. /// 发布证据的租户过滤必须是<b>严格相等</b>。
  136. /// <para><c>mdp_transform_run_log</c> 确实存在 <c>tenant_id = 0</c> 的平台行;
  137. /// 写成 <c>OR tenant_id = 0</c> 会让一条平台行同时充当所有租户的发布证据。</para>
  138. /// </summary>
  139. [Fact]
  140. public void Provider_PublicationLookup_IsStrictlyTenantScoped()
  141. {
  142. var sql = S8PurchaseDeliveryDataProvider.Sql;
  143. Assert.Contains("r.tenant_id = @TenantId", sql);
  144. Assert.DoesNotContain("r.tenant_id = 0", sql);
  145. Assert.DoesNotContain("OR tenant_id = 0", sql);
  146. }
  147. /// <summary>
  148. /// 生产者作业码只有一个来源:目录与取数各写一份必然漂移,
  149. /// 而漂移的表现是「健康判定看着 A 作业、取数却认 B 作业的发布」,两边各自都不报错。
  150. /// </summary>
  151. [Fact]
  152. public void ProducerJobCode_HasSingleSource()
  153. {
  154. Assert.Equal(
  155. S8BusinessDatasetDefinitions.PurchaseDeliveryProducerJobCode,
  156. Rule01Spec().ProducerJobCode);
  157. var provider = Source("Service", "S8", "Rules", "DataAccess", "Providers",
  158. "S8PurchaseDeliveryDataProvider.cs");
  159. Assert.Contains("S8BusinessDatasetDefinitions.PurchaseDeliveryProducerJobCode", provider);
  160. Assert.DoesNotContain("\"S3_MDP_SYNC_TRANSFORM\"", provider);
  161. }
  162. // ══════════════════ 健康判定侧 ══════════════════
  163. /// <summary>Rule01 的当前态必须由发布证据指认,不得退回按时间兜底。</summary>
  164. [Fact]
  165. public void Rule01Spec_RequiresPublicationEvidence()
  166. {
  167. var spec = Rule01Spec();
  168. Assert.True(spec.RequiresPublicationEvidence);
  169. Assert.True(spec.ProducerTrusted);
  170. Assert.Equal(S8AuthorityKind.CurrentStateMirror, spec.AuthorityKind);
  171. }
  172. /// <summary>
  173. /// 需要发布证据时,「最近一次成功」只认<b>带证据</b>的那一次。
  174. /// <para>否则一次增量成功就会把锚点从上一轮完整发布挪走,新鲜度与失败锚点全部算错。</para>
  175. /// </summary>
  176. [Fact]
  177. public void Resolver_LastSuccess_FiltersOnPublicationWhenRequired()
  178. {
  179. var code = Resolver();
  180. Assert.Contains("AND JSON_EXTRACT(r.summary_json, '$.publish.batchId') IS NOT NULL", code);
  181. Assert.Contains("spec.RequiresPublicationEvidence", code);
  182. Assert.Contains("S8AuthorityHealthReason.NoPublication", code);
  183. }
  184. /// <summary>
  185. /// 「没有发布证据」与「压根没成功过」是两件事,必须有各自的原因码。
  186. /// <para>结论同为 UNKNOWN、同样拦恢复;分开只是为了排查时一眼看出是哪一种。</para>
  187. /// </summary>
  188. [Fact]
  189. public void NoPublicationReason_IsDistinct_AndBlocksRecovery()
  190. {
  191. Assert.NotEqual(S8AuthorityHealthReason.NoSuccessfulRun, S8AuthorityHealthReason.NoPublication);
  192. Assert.NotEqual(S8AuthorityHealthReason.Healthy, S8AuthorityHealthReason.NoPublication);
  193. var result = new S8AuthorityHealthResult
  194. {
  195. State = S8AuthorityHealthState.Unknown,
  196. ReasonCode = S8AuthorityHealthReason.NoPublication
  197. };
  198. Assert.False(result.AllowsRecovery);
  199. }
  200. /// <summary>
  201. /// 空快照<b>不是</b>一种新状态。
  202. /// <para>成功发布的空快照就是 HEALTHY,恢复照常放行;缺的是证据时才判 UNKNOWN。
  203. /// 词表里绝不能冒出 HEALTHY_EMPTY 这种第四态 —— 那会让每个读方各自决定它算不算健康。</para>
  204. /// </summary>
  205. [Fact]
  206. public void StateVocabulary_HasNoHealthyEmpty()
  207. {
  208. var states = typeof(S8AuthorityHealthState)
  209. .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
  210. .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
  211. .Select(f => (string)f.GetRawConstantValue()!)
  212. .ToArray();
  213. Assert.Equal(3, states.Length);
  214. Assert.DoesNotContain("HEALTHY_EMPTY", states);
  215. }
  216. }