using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// S1 当前快照发布证据的契约。 /// /// 为什么需要这组测试:Rule03 的 Authority 健康判定要区分两种「零个当前批次」—— /// ①源侧本轮确实没数据,成功发布了一个空快照(合法,应放行恢复); /// ②发布压根没跑成(故障,必须拦截恢复)。 /// DWD 表本身分不出来:发布语句在零行时命中 0 行,不留任何痕迹。 /// /// 唯一的区分依据是生产侧写进运行日志的发布证据。它的可信度建立在一条隐式的语句顺序上: /// 发布必须严格早于「标记本轮成功」。这条顺序目前没有类型、事务或测试约束 —— /// 一旦有人把发布挪到标记成功之后,或在两者之间插入吞异常的步骤, /// 门禁会静默退化成假 HEALTHY,且没有任何报错。本文件就是钉死它的那颗钉子。 /// 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; } /// /// 核心不变量:发布必须早于「标记成功」。 /// /// 只有这条成立,status='SUCCESS' 才蕴含「发布已完整跑完」, /// 下游才敢把「SUCCESS + 发布证据 + 当前行数 0」当成合法空快照。 /// /// 三个入口(全量 / 贴源后 / 入站)各自都必须满足,逐个检查而不是只看第一个。 /// [Fact] public void PublishMustHappenBeforeMarkingRunSuccessful() { var code = S1Service(); // BuildDwdAsync 内部:发布是 DWD 阶段的最后一步。 var dwdBody = code[At(code, "private async Task BuildDwdAsync")..At(code, "private async Task 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 将不再蕴含发布完成"); } } /// 发布证据只在成功分支落盘;失败分支不得写,否则「失败但有证据」会骗过下游。 [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)]); } /// /// 发布证据必须带批次号、工厂号与当前行数三项,缺一不可。 /// 批次号用于确认证据出自最近这一次成功;工厂号是因为运行日志表没有工厂列, /// 而快照的真实作用域是 (租户, 工厂);当前行数才是「空还是非空」的答案。 /// [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); } /// /// 当前行数必须是复核查询得来,不能拿发布语句的影响行数充数。 /// 那条 UPDATE 是 CASE WHEN … THEN 1 ELSE 0 END,影响行数同时包含 /// 「本批被翻成 1」和「旧批被翻成 0」,与「当前有几行」不是一回事。 /// [Fact] public void PublishedRowCountComesFromVerificationQuery_NotAffectedRows() { var code = S1Service(); var publishBody = code[At(code, "private static async Task PublishCurrentSnapshotAsync")..At(code, "private async Task 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); } /// /// 消费侧:Resolver 必须读发布证据,且只在证据齐全且指向同一批次时才认定空快照合法。 /// 没有证据的历史行必须维持原来的保守判定 —— 升级前后行为一致,可灰度。 /// [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 Indexes(string code, string needle) { var list = new List(); for (var i = 0; (i = code.IndexOf(needle, i, StringComparison.Ordinal)) >= 0; i += needle.Length) list.Add(i); return list; } }