| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- 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;
- /// <summary>
- /// Rule01「当前快照」的发布契约。
- ///
- /// <para><b>这一条守的是一个只在「合法的空」上才现形的缺陷。</b>
- /// Provider 原来按「最新的那一天」挑当前快照。源侧被清空的那一轮产出 0 行 DWD,
- /// 于是当天根本不存在 <c>stat_date</c> 分区,中台的同日淘汰也无行可删(它只能删「今天」),
- /// 「最新的那一天」就回落到<b>上一天</b>,把一批早已作废的行当成当前态返回。</para>
- ///
- /// <para>本地沙箱实测:租户 838257212780613 源侧 10 → 0、STD 10 → 0、S3 跑批 SUCCESS,
- /// 而 DWD 当前快照仍返回前一日的 10 行。行数、状态、批次号全都「看起来正常」,
- /// 没有任何一处报错 —— 这类缺陷只能靠契约守住。</para>
- /// </summary>
- 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!;
- }
- // ══════════════════ 生产者侧:发布证据 ══════════════════
- /// <summary>
- /// 发布证据只在<b>全量</b>路径产生。
- ///
- /// <para>增量路径(<c>RunInboundAsync</c>)同样以 SUCCESS 收尾,但它只处理变更行。
- /// 把这样一个批次指认成「当前快照」,读者拿到的就是一个残缺集合 ——
- /// 而且它会以「有发布证据、状态成功」的样子出现,比没有证据更危险。</para>
- /// </summary>
- [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 守卫包住,否则增量批次会被指认成当前快照");
- }
- /// <summary>
- /// 发布证据必须在 DWD 的全部写入命令执行完之后才产生。
- ///
- /// <para>顺序颠倒的后果不是报错,是读者拿到一个还没灌完(或还混着上一批残留)的快照,
- /// 而运行日志会把它记成一次正常发布。</para>
- ///
- /// <para><b>比对的是执行顺序,不是源码顺序</b>:同日淘汰是
- /// <c>BuildDwdCommands</c> 这个迭代器 yield 出来的最后一条命令,
- /// 而该方法在文件里排在 <c>BuildDwdAsync</c> 之后 —— 按源码位置比会得出相反的结论。
- /// 真正的保证是:发布语句排在<b>命令循环之后</b>。淘汰在命令序列内部的位置,
- /// 由 <c>S3PurchaseAuthorityContractTests.DwdBuild_RetiresSameDayStaleRows_AfterInsert</c> 守。</para>
- /// </summary>
- [Fact]
- public void Publication_HappensAfterAllDwdCommandsExecuted()
- {
- var code = S3Service();
- var bodyAt = code.IndexOf("private async Task<int> 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 阶段的全部写入之后");
- }
- /// <summary>
- /// 发布行数必须用<b>复核查询</b>得到,不能用 INSERT 的影响行数。
- /// <para>ODKU 的影响行数(插入 1 / 更新 2 / 无变化 0)从来不等于「当前有几行」。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 运行摘要必须带上定位当前快照所需的全部字段,且用序列化器生成而非字符串拼接。
- /// <para><c>currentRows = 0</c> 是合法值 —— 它正是「成功发布了一个空快照」的表达,
- /// 下游据此把「正常的空」与「没发布成」区分开。</para>
- /// </summary>
- [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 ══════════════════
- /// <summary>
- /// Provider 必须按发布证据取数,且<b>结构上</b>不存在回落到其它 stat_date 的路径。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 发布证据的租户过滤必须是<b>严格相等</b>。
- /// <para><c>mdp_transform_run_log</c> 确实存在 <c>tenant_id = 0</c> 的平台行;
- /// 写成 <c>OR tenant_id = 0</c> 会让一条平台行同时充当所有租户的发布证据。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 生产者作业码只有一个来源:目录与取数各写一份必然漂移,
- /// 而漂移的表现是「健康判定看着 A 作业、取数却认 B 作业的发布」,两边各自都不报错。
- /// </summary>
- [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);
- }
- // ══════════════════ 健康判定侧 ══════════════════
- /// <summary>Rule01 的当前态必须由发布证据指认,不得退回按时间兜底。</summary>
- [Fact]
- public void Rule01Spec_RequiresPublicationEvidence()
- {
- var spec = Rule01Spec();
- Assert.True(spec.RequiresPublicationEvidence);
- Assert.True(spec.ProducerTrusted);
- Assert.Equal(S8AuthorityKind.CurrentStateMirror, spec.AuthorityKind);
- }
- /// <summary>
- /// 需要发布证据时,「最近一次成功」只认<b>带证据</b>的那一次。
- /// <para>否则一次增量成功就会把锚点从上一轮完整发布挪走,新鲜度与失败锚点全部算错。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 「没有发布证据」与「压根没成功过」是两件事,必须有各自的原因码。
- /// <para>结论同为 UNKNOWN、同样拦恢复;分开只是为了排查时一眼看出是哪一种。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 空快照<b>不是</b>一种新状态。
- /// <para>成功发布的空快照就是 HEALTHY,恢复照常放行;缺的是证据时才判 UNKNOWN。
- /// 词表里绝不能冒出 HEALTHY_EMPTY 这种第四态 —— 那会让每个读方各自决定它算不算健康。</para>
- /// </summary>
- [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);
- }
- }
|