| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
- /// <summary>
- /// S1 当前快照发布证据的契约。
- ///
- /// <para><b>为什么需要这组测试</b>:Rule03 的 Authority 健康判定要区分两种「零个当前批次」——
- /// ①源侧本轮确实没数据,成功发布了一个空快照(合法,应放行恢复);
- /// ②发布压根没跑成(故障,必须拦截恢复)。
- /// DWD 表本身分不出来:发布语句在零行时命中 0 行,不留任何痕迹。</para>
- ///
- /// <para>唯一的区分依据是生产侧写进运行日志的发布证据。它的可信度建立在<b>一条隐式的语句顺序</b>上:
- /// 发布必须严格早于「标记本轮成功」。这条顺序目前没有类型、事务或测试约束 ——
- /// 一旦有人把发布挪到标记成功之后,或在两者之间插入吞异常的步骤,
- /// 门禁会静默退化成假 HEALTHY,且没有任何报错。本文件就是钉死它的那颗钉子。</para>
- /// </summary>
- public class S1SnapshotPublicationContractTests
- {
- private static string Source(params string[] parts)
- {
- var dir = new DirectoryInfo(AppContext.BaseDirectory);
- while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
- dir = dir.Parent;
- Assert.NotNull(dir);
- var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
- Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
- return File.ReadAllText(full);
- }
- private static string S1Service() =>
- Source("Order", "S1MdpSyncTransformService.cs");
- private static string Resolver() =>
- Source("Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs");
- private static int At(string code, string anchor)
- {
- var i = code.IndexOf(anchor, StringComparison.Ordinal);
- Assert.True(i >= 0, $"未找到锚点:{anchor}");
- return i;
- }
- /// <summary>
- /// <b>核心不变量</b>:发布必须早于「标记成功」。
- ///
- /// <para>只有这条成立,<c>status='SUCCESS'</c> 才蕴含「发布已完整跑完」,
- /// 下游才敢把「SUCCESS + 发布证据 + 当前行数 0」当成合法空快照。</para>
- ///
- /// <para>三个入口(全量 / 贴源后 / 入站)各自都必须满足,逐个检查而不是只看第一个。</para>
- /// </summary>
- [Fact]
- public void PublishMustHappenBeforeMarkingRunSuccessful()
- {
- var code = S1Service();
- // BuildDwdAsync 内部:发布是 DWD 阶段的最后一步。
- var dwdBody = code[At(code, "private async Task<int> BuildDwdAsync")..At(code, "private async Task<int> BuildS1KpiValuesAsync")];
- Assert.True(At(dwdBody, "BuildDwdCommands") < At(dwdBody, "PublishCurrentSnapshotAsync"),
- "发布必须在全部 DWD 写入之后,否则读者会看到没灌完的批次被标成 current");
- Assert.True(At(dwdBody, "PublishCurrentSnapshotAsync") < At(dwdBody, "result.Publication ="),
- "发布证据必须由发布本身产出,不能凭空构造");
- // 三个入口:BuildDwdAsync(内含发布)必须早于 MarkTransformRunSuccessAsync。
- var buildCalls = Indexes(code, "await BuildDwdAsync(");
- var markCalls = Indexes(code, "await MarkTransformRunSuccessAsync(");
- Assert.Equal(3, buildCalls.Count);
- Assert.Equal(3, markCalls.Count);
- for (var i = 0; i < 3; i++)
- {
- Assert.True(buildCalls[i] < markCalls[i],
- $"第 {i + 1} 个入口把标记成功写在了发布之前 —— SUCCESS 将不再蕴含发布完成");
- }
- }
- /// <summary>发布证据只在成功分支落盘;失败分支不得写,否则「失败但有证据」会骗过下游。</summary>
- [Fact]
- public void PublicationEvidenceIsWrittenOnlyOnSuccess()
- {
- var code = S1Service();
- var successBody = code[At(code, "private async Task MarkTransformRunSuccessAsync")..At(code, "private async Task MarkTransformRunFailedAsync")];
- Assert.Contains("summary_json=@SummaryJson", successBody);
- Assert.Contains("BuildRunSummaryJson(result)", successBody);
- var failedBody = code[At(code, "private async Task MarkTransformRunFailedAsync")..];
- Assert.DoesNotContain("BuildRunSummaryJson", failedBody[..Math.Min(2000, failedBody.Length)]);
- }
- /// <summary>
- /// 发布证据必须带批次号、工厂号与当前行数三项,缺一不可。
- /// <para>批次号用于确认证据出自最近这一次成功;工厂号是因为运行日志表<b>没有工厂列</b>,
- /// 而快照的真实作用域是 (租户, 工厂);当前行数才是「空还是非空」的答案。</para>
- /// </summary>
- [Fact]
- public void PublicationMarkerCarriesBatchFactoryAndRowCount()
- {
- var code = S1Service();
- var summaryBody = code[At(code, "private static string BuildRunSummaryJson")..At(code, "private static string ResolveKpiValueTable")];
- Assert.Contains("publish = result.Publication is null", summaryBody);
- Assert.Contains("batchId = result.Publication.BatchId", summaryBody);
- Assert.Contains("factoryId = result.Publication.FactoryId", summaryBody);
- Assert.Contains("currentRows = result.Publication.CurrentRows", summaryBody);
- // 不再用字符串插值拼 JSON。
- Assert.Contains("JsonSerializer.Serialize", summaryBody);
- }
- /// <summary>
- /// 当前行数必须是<b>复核查询</b>得来,不能拿发布语句的影响行数充数。
- /// <para>那条 UPDATE 是 <c>CASE WHEN … THEN 1 ELSE 0 END</c>,影响行数同时包含
- /// 「本批被翻成 1」和「旧批被翻成 0」,与「当前有几行」不是一回事。</para>
- /// </summary>
- [Fact]
- public void PublishedRowCountComesFromVerificationQuery_NotAffectedRows()
- {
- var code = S1Service();
- var publishBody = code[At(code, "private static async Task<int> PublishCurrentSnapshotAsync")..At(code, "private async Task<int> BuildS1KpiValuesAsync")];
- Assert.Contains("SELECT COUNT(*) FROM dwd_requirement_examine_detail", publishBody);
- Assert.Contains("is_current_flag=1", publishBody);
- Assert.Contains("calc_batch_id=@BatchId", publishBody);
- }
- /// <summary>
- /// 消费侧:Resolver 必须读发布证据,且只在<b>证据齐全且指向同一批次</b>时才认定空快照合法。
- /// <para>没有证据的历史行必须维持原来的保守判定 —— 升级前后行为一致,可灰度。</para>
- /// </summary>
- [Fact]
- public void ResolverConsumesPublicationEvidence_AndStaysConservativeWithout()
- {
- var resolver = Resolver();
- Assert.Contains("'$.publish.batchId'", resolver);
- Assert.Contains("'$.publish.currentRows'", resolver);
- var zeroBranch = resolver[At(resolver, "if (currentBatches == 0)")..At(resolver, "if (currentBatches > 1)")];
- // 认定合法空快照的三个条件缺一不可。
- Assert.Contains("!string.IsNullOrWhiteSpace(publishedBatchId)", zeroBranch);
- Assert.Contains("string.Equals(publishedBatchId, lastSuccessBatchId", zeroBranch);
- Assert.Contains("publishedRows == 0", zeroBranch);
- Assert.Contains("S8SnapshotInvariant.Satisfied", zeroBranch);
- // 缺证据时仍走 Unconfirmed(= UNKNOWN = 拦截恢复)。
- Assert.Contains("S8SnapshotInvariant.Unconfirmed", zeroBranch);
- }
- private static List<int> Indexes(string code, string needle)
- {
- var list = new List<int>();
- for (var i = 0; (i = code.IndexOf(needle, i, StringComparison.Ordinal)) >= 0; i += needle.Length)
- list.Add(i);
- return list;
- }
- }
|