using System.Reflection; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// Rule01「当前快照」的发布契约。 /// /// 这一条守的是一个只在「合法的空」上才现形的缺陷。 /// Provider 原来按「最新的那一天」挑当前快照。源侧被清空的那一轮产出 0 行 DWD, /// 于是当天根本不存在 stat_date 分区,中台的同日淘汰也无行可删(它只能删「今天」), /// 「最新的那一天」就回落到上一天,把一批早已作废的行当成当前态返回。 /// /// 本地沙箱实测:租户 838257212780613 源侧 10 → 0、STD 10 → 0、S3 跑批 SUCCESS, /// 而 DWD 当前快照仍返回前一日的 10 行。行数、状态、批次号全都「看起来正常」, /// 没有任何一处报错 —— 这类缺陷只能靠契约守住。 /// public class S8Rule01PublicationContractTests { 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 S3Service() => Source("Supply", "S3MdpSyncTransformService.cs"); private static string Resolver() => Source("Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs"); private static S8AuthoritySpec Rule01Spec() { var spec = new S8BusinessDatasetDefinitions() .GetDefinitions() .Single(d => d.DatasetCode == S8BusinessDatasetDefinitions.PurchaseDeliveryCode) .AuthoritySpec; Assert.NotNull(spec); return spec!; } // ══════════════════ 生产者侧:发布证据 ══════════════════ /// /// 发布证据只在全量路径产生。 /// /// 增量路径(RunInboundAsync)同样以 SUCCESS 收尾,但它只处理变更行。 /// 把这样一个批次指认成「当前快照」,读者拿到的就是一个残缺集合 —— /// 而且它会以「有发布证据、状态成功」的样子出现,比没有证据更危险。 /// [Fact] public void Publication_IsProducedOnlyOnFullPath() { var code = S3Service(); var publishAt = code.IndexOf("result.Publication = await PublishCurrentSnapshotAsync", StringComparison.Ordinal); Assert.True(publishAt > 0, "全量路径必须写发布证据"); // 发布语句必须落在 if (retireStale) 之内 —— 取其之前最近的一段代码验证。 var guardAt = code.LastIndexOf("if (retireStale)", publishAt, StringComparison.Ordinal); Assert.True(guardAt > 0 && publishAt - guardAt < 400, "发布必须被 retireStale 守卫包住,否则增量批次会被指认成当前快照"); } /// /// 发布证据必须在 DWD 的全部写入命令执行完之后才产生。 /// /// 顺序颠倒的后果不是报错,是读者拿到一个还没灌完(或还混着上一批残留)的快照, /// 而运行日志会把它记成一次正常发布。 /// /// 比对的是执行顺序,不是源码顺序:同日淘汰是 /// BuildDwdCommands 这个迭代器 yield 出来的最后一条命令, /// 而该方法在文件里排在 BuildDwdAsync 之后 —— 按源码位置比会得出相反的结论。 /// 真正的保证是:发布语句排在命令循环之后。淘汰在命令序列内部的位置, /// 由 S3PurchaseAuthorityContractTests.DwdBuild_RetiresSameDayStaleRows_AfterInsert 守。 /// [Fact] public void Publication_HappensAfterAllDwdCommandsExecuted() { var code = S3Service(); var bodyAt = code.IndexOf("private async Task BuildDwdAsync(", StringComparison.Ordinal); Assert.True(bodyAt > 0, "BuildDwdAsync 不存在"); var loopAt = code.IndexOf("foreach (var command in BuildDwdCommands(", bodyAt, StringComparison.Ordinal); var shortageAt = code.IndexOf("InsertMaterialShortageAsync(batchId, now)", bodyAt, StringComparison.Ordinal); var publishAt = code.IndexOf("result.Publication = await PublishCurrentSnapshotAsync", bodyAt, StringComparison.Ordinal); Assert.True(loopAt > bodyAt, "未找到 DWD 命令执行循环"); Assert.True(publishAt > loopAt, "发布必须在 DWD 命令循环之后"); Assert.True(publishAt > shortageAt, "发布必须在 DWD 阶段的全部写入之后"); } /// /// 发布行数必须用复核查询得到,不能用 INSERT 的影响行数。 /// ODKU 的影响行数(插入 1 / 更新 2 / 无变化 0)从来不等于「当前有几行」。 /// [Fact] public void Publication_CountsRowsByVerificationQuery() { var code = S3Service(); Assert.Contains("SELECT COUNT(*) FROM dwd_supplier_delivery", code); Assert.Contains("AND sync_batch_id=@BatchId", code); Assert.Contains("AND stat_date=@StatDate", code); } /// /// 运行摘要必须带上定位当前快照所需的全部字段,且用序列化器生成而非字符串拼接。 /// currentRows = 0 是合法值 —— 它正是「成功发布了一个空快照」的表达, /// 下游据此把「正常的空」与「没发布成」区分开。 /// [Fact] public void RunSummaryJson_CarriesPublicationEvidence() { var code = S3Service(); Assert.Contains("JsonSerializer.Serialize", code); foreach (var field in new[] { "table =", "batchId =", "statDate =", "factoryId =", "currentRows =" }) Assert.Contains(field, code); // 没发布时必须是 null,不能退化成一个 currentRows=0 的假证据 —— // 那会把「没发布」伪装成「发布了空快照」,正好抹掉两者的区别。 Assert.Contains("publish = result.Publication is null", code); } // ══════════════════ 读者侧:Provider ══════════════════ /// /// Provider 必须按发布证据取数,且结构上不存在回落到其它 stat_date 的路径。 /// [Fact] public void Provider_ReadsPublishedSnapshot_WithNoFallbackPath() { var sql = S8PurchaseDeliveryDataProvider.Sql; Assert.Contains("WITH pub AS", sql); Assert.Contains("'$.publish.batchId'", sql); Assert.Contains("'$.publish.statDate'", sql); Assert.Contains("d.sync_batch_id = p.batch_id", sql); // 只认终态成功的生产运行:该表有大量永不回收的 RUNNING 孤儿行。 Assert.Contains("r.status = 'SUCCESS'", sql); Assert.Contains("r.end_time IS NOT NULL", sql); // 旧的「取最新那一天」写法必须彻底消失 —— 留着它就等于留着回落路径。 Assert.DoesNotContain("ORDER BY x.stat_date DESC", sql); Assert.DoesNotContain("MAX(stat_date)", sql); Assert.DoesNotContain("MAX(d.stat_date)", sql); } /// /// 发布证据的租户过滤必须是严格相等。 /// mdp_transform_run_log 确实存在 tenant_id = 0 的平台行; /// 写成 OR tenant_id = 0 会让一条平台行同时充当所有租户的发布证据。 /// [Fact] public void Provider_PublicationLookup_IsStrictlyTenantScoped() { var sql = S8PurchaseDeliveryDataProvider.Sql; Assert.Contains("r.tenant_id = @TenantId", sql); Assert.DoesNotContain("r.tenant_id = 0", sql); Assert.DoesNotContain("OR tenant_id = 0", sql); } /// /// 生产者作业码只有一个来源:目录与取数各写一份必然漂移, /// 而漂移的表现是「健康判定看着 A 作业、取数却认 B 作业的发布」,两边各自都不报错。 /// [Fact] public void ProducerJobCode_HasSingleSource() { Assert.Equal( S8BusinessDatasetDefinitions.PurchaseDeliveryProducerJobCode, Rule01Spec().ProducerJobCode); var provider = Source("Service", "S8", "Rules", "DataAccess", "Providers", "S8PurchaseDeliveryDataProvider.cs"); Assert.Contains("S8BusinessDatasetDefinitions.PurchaseDeliveryProducerJobCode", provider); Assert.DoesNotContain("\"S3_MDP_SYNC_TRANSFORM\"", provider); } // ══════════════════ 健康判定侧 ══════════════════ /// Rule01 的当前态必须由发布证据指认,不得退回按时间兜底。 [Fact] public void Rule01Spec_RequiresPublicationEvidence() { var spec = Rule01Spec(); Assert.True(spec.RequiresPublicationEvidence); Assert.True(spec.ProducerTrusted); Assert.Equal(S8AuthorityKind.CurrentStateMirror, spec.AuthorityKind); } /// /// 需要发布证据时,「最近一次成功」只认带证据的那一次。 /// 否则一次增量成功就会把锚点从上一轮完整发布挪走,新鲜度与失败锚点全部算错。 /// [Fact] public void Resolver_LastSuccess_FiltersOnPublicationWhenRequired() { var code = Resolver(); Assert.Contains("AND JSON_EXTRACT(r.summary_json, '$.publish.batchId') IS NOT NULL", code); Assert.Contains("spec.RequiresPublicationEvidence", code); Assert.Contains("S8AuthorityHealthReason.NoPublication", code); } /// /// 「没有发布证据」与「压根没成功过」是两件事,必须有各自的原因码。 /// 结论同为 UNKNOWN、同样拦恢复;分开只是为了排查时一眼看出是哪一种。 /// [Fact] public void NoPublicationReason_IsDistinct_AndBlocksRecovery() { Assert.NotEqual(S8AuthorityHealthReason.NoSuccessfulRun, S8AuthorityHealthReason.NoPublication); Assert.NotEqual(S8AuthorityHealthReason.Healthy, S8AuthorityHealthReason.NoPublication); var result = new S8AuthorityHealthResult { State = S8AuthorityHealthState.Unknown, ReasonCode = S8AuthorityHealthReason.NoPublication }; Assert.False(result.AllowsRecovery); } /// /// 空快照不是一种新状态。 /// 成功发布的空快照就是 HEALTHY,恢复照常放行;缺的是证据时才判 UNKNOWN。 /// 词表里绝不能冒出 HEALTHY_EMPTY 这种第四态 —— 那会让每个读方各自决定它算不算健康。 /// [Fact] public void StateVocabulary_HasNoHealthyEmpty() { var states = typeof(S8AuthorityHealthState) .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) .Select(f => (string)f.GetRawConstantValue()!) .ToArray(); Assert.Equal(3, states.Length); Assert.DoesNotContain("HEALTHY_EMPTY", states); } }