S1SnapshotPublicationContractTests.cs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  3. /// <summary>
  4. /// S1 当前快照发布证据的契约。
  5. ///
  6. /// <para><b>为什么需要这组测试</b>:Rule03 的 Authority 健康判定要区分两种「零个当前批次」——
  7. /// ①源侧本轮确实没数据,成功发布了一个空快照(合法,应放行恢复);
  8. /// ②发布压根没跑成(故障,必须拦截恢复)。
  9. /// DWD 表本身分不出来:发布语句在零行时命中 0 行,不留任何痕迹。</para>
  10. ///
  11. /// <para>唯一的区分依据是生产侧写进运行日志的发布证据。它的可信度建立在<b>一条隐式的语句顺序</b>上:
  12. /// 发布必须严格早于「标记本轮成功」。这条顺序目前没有类型、事务或测试约束 ——
  13. /// 一旦有人把发布挪到标记成功之后,或在两者之间插入吞异常的步骤,
  14. /// 门禁会静默退化成假 HEALTHY,且没有任何报错。本文件就是钉死它的那颗钉子。</para>
  15. /// </summary>
  16. public class S1SnapshotPublicationContractTests
  17. {
  18. private static string Source(params string[] parts)
  19. {
  20. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  21. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  22. dir = dir.Parent;
  23. Assert.NotNull(dir);
  24. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  25. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  26. return File.ReadAllText(full);
  27. }
  28. private static string S1Service() =>
  29. Source("Order", "S1MdpSyncTransformService.cs");
  30. private static string Resolver() =>
  31. Source("Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs");
  32. private static int At(string code, string anchor)
  33. {
  34. var i = code.IndexOf(anchor, StringComparison.Ordinal);
  35. Assert.True(i >= 0, $"未找到锚点:{anchor}");
  36. return i;
  37. }
  38. /// <summary>
  39. /// <b>核心不变量</b>:发布必须早于「标记成功」。
  40. ///
  41. /// <para>只有这条成立,<c>status='SUCCESS'</c> 才蕴含「发布已完整跑完」,
  42. /// 下游才敢把「SUCCESS + 发布证据 + 当前行数 0」当成合法空快照。</para>
  43. ///
  44. /// <para>三个入口(全量 / 贴源后 / 入站)各自都必须满足,逐个检查而不是只看第一个。</para>
  45. /// </summary>
  46. [Fact]
  47. public void PublishMustHappenBeforeMarkingRunSuccessful()
  48. {
  49. var code = S1Service();
  50. // BuildDwdAsync 内部:发布是 DWD 阶段的最后一步。
  51. var dwdBody = code[At(code, "private async Task<int> BuildDwdAsync")..At(code, "private async Task<int> BuildS1KpiValuesAsync")];
  52. Assert.True(At(dwdBody, "BuildDwdCommands") < At(dwdBody, "PublishCurrentSnapshotAsync"),
  53. "发布必须在全部 DWD 写入之后,否则读者会看到没灌完的批次被标成 current");
  54. Assert.True(At(dwdBody, "PublishCurrentSnapshotAsync") < At(dwdBody, "result.Publication ="),
  55. "发布证据必须由发布本身产出,不能凭空构造");
  56. // 三个入口:BuildDwdAsync(内含发布)必须早于 MarkTransformRunSuccessAsync。
  57. var buildCalls = Indexes(code, "await BuildDwdAsync(");
  58. var markCalls = Indexes(code, "await MarkTransformRunSuccessAsync(");
  59. Assert.Equal(3, buildCalls.Count);
  60. Assert.Equal(3, markCalls.Count);
  61. for (var i = 0; i < 3; i++)
  62. {
  63. Assert.True(buildCalls[i] < markCalls[i],
  64. $"第 {i + 1} 个入口把标记成功写在了发布之前 —— SUCCESS 将不再蕴含发布完成");
  65. }
  66. }
  67. /// <summary>发布证据只在成功分支落盘;失败分支不得写,否则「失败但有证据」会骗过下游。</summary>
  68. [Fact]
  69. public void PublicationEvidenceIsWrittenOnlyOnSuccess()
  70. {
  71. var code = S1Service();
  72. var successBody = code[At(code, "private async Task MarkTransformRunSuccessAsync")..At(code, "private async Task MarkTransformRunFailedAsync")];
  73. Assert.Contains("summary_json=@SummaryJson", successBody);
  74. Assert.Contains("BuildRunSummaryJson(result)", successBody);
  75. var failedBody = code[At(code, "private async Task MarkTransformRunFailedAsync")..];
  76. Assert.DoesNotContain("BuildRunSummaryJson", failedBody[..Math.Min(2000, failedBody.Length)]);
  77. }
  78. /// <summary>
  79. /// 发布证据必须带批次号、工厂号与当前行数三项,缺一不可。
  80. /// <para>批次号用于确认证据出自最近这一次成功;工厂号是因为运行日志表<b>没有工厂列</b>,
  81. /// 而快照的真实作用域是 (租户, 工厂);当前行数才是「空还是非空」的答案。</para>
  82. /// </summary>
  83. [Fact]
  84. public void PublicationMarkerCarriesBatchFactoryAndRowCount()
  85. {
  86. var code = S1Service();
  87. var summaryBody = code[At(code, "private static string BuildRunSummaryJson")..At(code, "private static string ResolveKpiValueTable")];
  88. Assert.Contains("publish = result.Publication is null", summaryBody);
  89. Assert.Contains("batchId = result.Publication.BatchId", summaryBody);
  90. Assert.Contains("factoryId = result.Publication.FactoryId", summaryBody);
  91. Assert.Contains("currentRows = result.Publication.CurrentRows", summaryBody);
  92. // 不再用字符串插值拼 JSON。
  93. Assert.Contains("JsonSerializer.Serialize", summaryBody);
  94. }
  95. /// <summary>
  96. /// 当前行数必须是<b>复核查询</b>得来,不能拿发布语句的影响行数充数。
  97. /// <para>那条 UPDATE 是 <c>CASE WHEN … THEN 1 ELSE 0 END</c>,影响行数同时包含
  98. /// 「本批被翻成 1」和「旧批被翻成 0」,与「当前有几行」不是一回事。</para>
  99. /// </summary>
  100. [Fact]
  101. public void PublishedRowCountComesFromVerificationQuery_NotAffectedRows()
  102. {
  103. var code = S1Service();
  104. var publishBody = code[At(code, "private static async Task<int> PublishCurrentSnapshotAsync")..At(code, "private async Task<int> BuildS1KpiValuesAsync")];
  105. Assert.Contains("SELECT COUNT(*) FROM dwd_requirement_examine_detail", publishBody);
  106. Assert.Contains("is_current_flag=1", publishBody);
  107. Assert.Contains("calc_batch_id=@BatchId", publishBody);
  108. }
  109. /// <summary>
  110. /// 消费侧:Resolver 必须读发布证据,且只在<b>证据齐全且指向同一批次</b>时才认定空快照合法。
  111. /// <para>没有证据的历史行必须维持原来的保守判定 —— 升级前后行为一致,可灰度。</para>
  112. /// </summary>
  113. [Fact]
  114. public void ResolverConsumesPublicationEvidence_AndStaysConservativeWithout()
  115. {
  116. var resolver = Resolver();
  117. Assert.Contains("'$.publish.batchId'", resolver);
  118. Assert.Contains("'$.publish.currentRows'", resolver);
  119. var zeroBranch = resolver[At(resolver, "if (currentBatches == 0)")..At(resolver, "if (currentBatches > 1)")];
  120. // 认定合法空快照的三个条件缺一不可。
  121. Assert.Contains("!string.IsNullOrWhiteSpace(publishedBatchId)", zeroBranch);
  122. Assert.Contains("string.Equals(publishedBatchId, lastSuccessBatchId", zeroBranch);
  123. Assert.Contains("publishedRows == 0", zeroBranch);
  124. Assert.Contains("S8SnapshotInvariant.Satisfied", zeroBranch);
  125. // 缺证据时仍走 Unconfirmed(= UNKNOWN = 拦截恢复)。
  126. Assert.Contains("S8SnapshotInvariant.Unconfirmed", zeroBranch);
  127. }
  128. private static List<int> Indexes(string code, string needle)
  129. {
  130. var list = new List<int>();
  131. for (var i = 0; (i = code.IndexOf(needle, i, StringComparison.Ordinal)) >= 0; i += needle.Length)
  132. list.Add(i);
  133. return list;
  134. }
  135. }