S8PurchaseDeliveryProviderMappingTests.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  5. using Microsoft.Extensions.Logging.Abstractions;
  6. using Xunit;
  7. namespace Admin.NET.Test.S8;
  8. /// <summary>
  9. /// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 的取数作用域、canonical 映射与 TIMEOUT 判定契约。
  10. /// 锁定项与失败后果:
  11. ///
  12. /// A. 作用域只能按 tenant → Phase 1 拍板:Security Boundary = Tenant。若有人日后往 SQL 里补回
  13. /// factory_id 过滤,规则会恒返 0 行且不报错(dwd_supplier_delivery.factory_id
  14. /// 是写死的常量 1,与 S8 侧真实 SysOrg.Id 永不相等)——静默失效,最难发现。
  15. /// B. 快照按 tenant 各自收敛 → 用全局 MAX(stat_date) 会让落后一天的租户整租户静默丢数据。
  16. /// C. 身份稳定且唯一 → source_object_id 进 DedupKey,格式一变历史异常与新异常直接断代。
  17. /// D. DedupKey 预算守卫 → 预算比 source_object_id 列宽(64)更紧;落在预算与列宽之间的键
  18. /// 会过得了列校验、卡在 DedupKey 上,数据库静默截断会把两个 PO Line 折叠成一条异常。
  19. /// E. due_at 双分支 → need_date 为 NULL 时回落 due_date;两者实测 18/36 行不同,不是等价列。
  20. /// F. 判定归 evaluator → Provider 不滤 status,"逾期但已完成不命中"必须由 evaluator 判出来。
  21. /// G. STANDARD 绝不回落 → 取数失败偷偷执行 rule.Expression 会让迁移失败被静默隐藏:
  22. /// 规则看起来在正常跑,实际跑的还是旧 SQL。
  23. ///
  24. /// 不接 DB、不接 DI、不触发任何真实规则。SQL 在真实库上的行为已由实施前的只读核验覆盖
  25. /// (4 租户身份唯一、跨租户交集 0、外层走 idx_stat_date),本文件锁定的是结构契约。
  26. /// </summary>
  27. public class S8PurchaseDeliveryProviderMappingTests
  28. {
  29. private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  30. private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
  31. private static S8MonitoringDataRequest Request(
  32. long tenantId = 838257186181189, long factoryId = 838257186320453) => new()
  33. {
  34. TenantId = tenantId,
  35. FactoryId = factoryId,
  36. DatasetCode = PurchaseDelivery,
  37. RuleCode = RuleCode,
  38. RuleType = "TIMEOUT"
  39. };
  40. private static AdoS8WatchRule NewRule() => new()
  41. {
  42. Id = 900101,
  43. RuleCode = RuleCode,
  44. SceneCode = "S4",
  45. WatchObjectType = "PURCHASE_ORDER_LINE",
  46. SourceObjectType = "PURCHASE_ORDER_LINE",
  47. Severity = "SERIOUS",
  48. RuleType = "TIMEOUT",
  49. DataAccessMode = S8DataAccessMode.StandardDataset,
  50. DatasetCode = PurchaseDelivery
  51. };
  52. private static S8TimeoutParams Params() => S8TimeoutParams.Parse(
  53. """
  54. {"dueAtField":"due_at","statusField":"status",
  55. "completedStates":["COMPLETED","CLOSED","CANCELLED"],
  56. "objectCodeField":"related_object_code","objectIdField":"source_object_id",
  57. "graceMinutes":0,"exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL"}
  58. """);
  59. /// <summary>真实 DWD 行样本(取自 UAT 租户 2026-09-05 快照)。</summary>
  60. private static S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow Row(
  61. string poNo, string poLine, DateTime? needDate, DateTime? dueDate, string status,
  62. string supplierName = "珩睿", string supplierCode = "10003232") => new()
  63. {
  64. SourceObjectId = $"{poNo}#{poLine}",
  65. RelatedObjectCode = poNo,
  66. RelatedObjectName = string.IsNullOrEmpty(supplierName) ? supplierCode : supplierName,
  67. DueAt = needDate ?? dueDate,
  68. Status = status,
  69. DueAtSource = needDate == null ? "due_date" : "need_date",
  70. PoNo = poNo,
  71. PoLine = poLine,
  72. PoType = "po",
  73. SupplierCode = supplierCode,
  74. SupplierName = supplierName,
  75. ItemCode = "ITM-001",
  76. ItemName = "物料",
  77. OrderQty = 3000m,
  78. RemainingQty = 3000m,
  79. NeedDate = needDate,
  80. DueDate = dueDate,
  81. RiskLevel = "HIGH",
  82. StatDate = new DateTime(2026, 9, 5)
  83. };
  84. private static S8MonitoringRowSet RowSet(params S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow[] rows) =>
  85. S8MonitoringRowSet.FromRows(
  86. rows.Select(r => S8MonitoringRow.FromValues(S8PurchaseDeliveryDataProvider.ToCanonicalValues(r))));
  87. // ============================================================
  88. // A/B. 作用域与快照收敛(SQL 结构契约)
  89. // ============================================================
  90. [Fact]
  91. public void T4_Sql_FiltersByTenant_InBothOuterQueryAndSnapshotSubquery()
  92. {
  93. // 外层与快照子查询都必须带租户,否则会拿到别的租户的最新快照日期。
  94. var sql = S8PurchaseDeliveryDataProvider.Sql;
  95. Assert.Contains("d.tenant_id = @TenantId", sql);
  96. Assert.Contains("x.tenant_id = @TenantId", sql);
  97. }
  98. [Fact]
  99. public void T5_Sql_DoesNotUseFactoryAsDatasetFilter()
  100. {
  101. // Phase 1 拍板:Factory 是业务上下文,不是本数据集的过滤条件。
  102. // 这条断言是防回归的主锁——补回 factory 过滤会让规则恒返 0 行且不报任何错。
  103. Assert.DoesNotContain("factory_id", S8PurchaseDeliveryDataProvider.Sql);
  104. }
  105. [Fact]
  106. public void T5b_FactoryId_DoesNotAlterQuery_ButTenantIdDoes()
  107. {
  108. // 同一租户下换工厂:SQL 与唯一参数都不受影响(工厂根本不进 SQL)。
  109. // 换租户:参数随之改变。二者共同证明"隔离维度只有租户"。
  110. var sameTenantOtherFactory = Request(factoryId: 999_999_999);
  111. var otherTenant = Request(tenantId: 838257212780613);
  112. Assert.Equal(Request().TenantId, sameTenantOtherFactory.TenantId);
  113. Assert.NotEqual(Request().TenantId, otherTenant.TenantId);
  114. // 工厂不在 SQL 中,因此不可能成为取数差异的来源。
  115. Assert.DoesNotContain("@FactoryId", S8PurchaseDeliveryDataProvider.Sql);
  116. }
  117. [Fact]
  118. public void T6_Sql_ConvergesSnapshot_PerTenantLatest_NotGlobalMax()
  119. {
  120. var sql = S8PurchaseDeliveryDataProvider.Sql;
  121. // 反向索引扫描而非聚合:大租户上实测 143ms → 2.7ms。
  122. Assert.Contains("ORDER BY x.stat_date DESC", sql);
  123. Assert.Contains("LIMIT 1", sql);
  124. Assert.DoesNotContain("MAX(", sql);
  125. }
  126. [Fact]
  127. public void Sql_ExcludesDegenerateIdentity_AndDoesNotFilterOnBusinessState()
  128. {
  129. var sql = S8PurchaseDeliveryDataProvider.Sql;
  130. // 身份任一段为空会产生退化的 "#",必须排除。
  131. Assert.Contains("d.po_no <> ''", sql);
  132. Assert.Contains("d.po_line <> ''", sql);
  133. // 判定归 evaluator:Provider 不得按状态或不可信的 remaining_qty 预筛。
  134. Assert.DoesNotContain("remaining_qty >", sql);
  135. Assert.DoesNotContain("delivery_status <>", sql);
  136. Assert.DoesNotContain("delivery_status !=", sql);
  137. }
  138. [Fact]
  139. public async Task LoadAsync_ValidatesScope_BeforeTouchingDatabase()
  140. {
  141. // 故意注入 null 数据库:若作用域校验不是第一步,这里会得到 NullReferenceException。
  142. var provider = new S8PurchaseDeliveryDataProvider(
  143. null, NullLogger<S8PurchaseDeliveryDataProvider>.Instance);
  144. var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
  145. provider.LoadAsync(Request(tenantId: 0)));
  146. Assert.Equal("dataset_scope_invalid", ex.Reason);
  147. var ex2 = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
  148. provider.LoadAsync(Request(factoryId: 0)));
  149. Assert.Equal("dataset_scope_invalid", ex2.Reason);
  150. }
  151. // ============================================================
  152. // C/D. 身份与 DedupKey 预算
  153. // ============================================================
  154. [Fact]
  155. public void T7_Identity_IsStableComposite_UniquePerLine()
  156. {
  157. var line3 = Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED");
  158. var line4 = Row("PO202608260002", "4", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
  159. var v3 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line3);
  160. var v4 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line4);
  161. Assert.Equal("PO202608260002#3", v3[S8CanonicalColumns.SourceObjectId]);
  162. Assert.Equal("PO202608260002", v3[S8CanonicalColumns.RelatedObjectCode]);
  163. // 同单不同行必须是不同对象,否则同一采购单只会产出一条异常。
  164. Assert.NotEqual(v3[S8CanonicalColumns.SourceObjectId], v4[S8CanonicalColumns.SourceObjectId]);
  165. }
  166. [Fact]
  167. public void T7b_Identity_IsInvariantAcrossSnapshots()
  168. {
  169. // 身份只由 po_no + po_line 决定,与 stat_date 无关;否则每天换一次身份,
  170. // 昨天的异常今天会被判恢复、并新建一条。
  171. var day1 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
  172. var day2 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
  173. day2.StatDate = new DateTime(2026, 9, 6);
  174. Assert.Equal(
  175. S8PurchaseDeliveryDataProvider.ToCanonicalValues(day1)[S8CanonicalColumns.SourceObjectId],
  176. S8PurchaseDeliveryDataProvider.ToCanonicalValues(day2)[S8CanonicalColumns.SourceObjectId]);
  177. }
  178. [Fact]
  179. public void T7c_DedupKey_StaysWithinColumnLimit_ForRealWorstCase()
  180. {
  181. // 实测该租户最长 source_object_id 为 16 字符;此处按真实规则编码与作用域算完整 DedupKey。
  182. var key = S8TimeoutRuleEvaluator.BuildDedupKey(
  183. 838257186181189, 838257186320453, RuleCode, "PURCHASE_ORDER_LINE", "PO202608260002#4");
  184. Assert.True(key.Length <= S8PurchaseDeliveryDataProvider.DedupKeyMaxLength,
  185. $"DedupKey 长度 {key.Length} 超过 {S8PurchaseDeliveryDataProvider.DedupKeyMaxLength}:{key}");
  186. }
  187. [Fact]
  188. public void T7d_Budget_IsTighterThanSourceObjectIdColumnWidth()
  189. {
  190. // 关键认知:预算(128 − 前缀)比 source_object_id 的列宽 64 更紧。
  191. // 落在预算与 64 之间的键会过得了列校验、卡在 DedupKey 上。
  192. var budget = S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request());
  193. Assert.True(budget > 0);
  194. Assert.True(budget < 64, $"预期预算紧于列宽 64,实际 {budget}");
  195. // 预算 + 前缀必须正好等于上限,证明算式与 BuildDedupKey 的格式一致。
  196. var probe = new string('x', budget);
  197. var key = S8TimeoutRuleEvaluator.BuildDedupKey(
  198. Request().TenantId, Request().FactoryId, RuleCode, "PURCHASE_ORDER_LINE", probe);
  199. Assert.Equal(S8PurchaseDeliveryDataProvider.DedupKeyMaxLength, key.Length);
  200. }
  201. [Fact]
  202. public void T7e_Budget_ShrinksWhenRuleCodeGrows()
  203. {
  204. // 预算按 request 现算而非写死;换更长的规则编码必须自动收紧。
  205. var longRule = new S8MonitoringDataRequest
  206. {
  207. TenantId = Request().TenantId,
  208. FactoryId = Request().FactoryId,
  209. DatasetCode = PurchaseDelivery,
  210. RuleCode = RuleCode + "_MUCH_LONGER_SUFFIX",
  211. RuleType = "TIMEOUT"
  212. };
  213. Assert.True(
  214. S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(longRule)
  215. < S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request()));
  216. }
  217. // ============================================================
  218. // E. Canonical 映射
  219. // ============================================================
  220. [Fact]
  221. public void T8_Canonical_Mapping_IsComplete_WhenNeedDatePresent()
  222. {
  223. var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  224. Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  225. Assert.Equal("PO202608260002#3", values[S8CanonicalColumns.SourceObjectId]);
  226. Assert.Equal("PO202608260002", values[S8CanonicalColumns.RelatedObjectCode]);
  227. Assert.Equal(new DateTime(2026, 8, 17, 4, 33, 7), values[S8CanonicalColumns.DueAt]);
  228. Assert.Equal("DELAYED", values[S8CanonicalColumns.Status]);
  229. Assert.Equal("need_date", values["due_at_source"]);
  230. }
  231. [Fact]
  232. public void T8b_DueAt_FallsBackToDueDate_WhenNeedDateIsNull()
  233. {
  234. // 真实样本 PW202608280001#1:need_date 为 NULL,是唯一能验回落分支的行。
  235. var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  236. Row("PW202608280001", "1", null, new DateTime(2026, 8, 29), "DELAYED", supplierName: "怡祥"));
  237. Assert.Equal(new DateTime(2026, 8, 29), values[S8CanonicalColumns.DueAt]);
  238. // due_at 是两种业务日期的混合,必须逐行自证用的是哪个基准。
  239. Assert.Equal("due_date", values["due_at_source"]);
  240. }
  241. [Fact]
  242. public void T8c_Canonical_DoesNotFabricateBusinessJudgements()
  243. {
  244. // Provider 不得构造 is_delivery_complete / is_active_obligation / severity ——
  245. // 上一轮数据契约审计已判这三项 NOT_PROVEN,凭空构造等于把中台缺口埋进 S8。
  246. var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  247. Row("PO202608260002", "1", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "COMPLETED"));
  248. Assert.DoesNotContain("is_delivery_complete", values.Keys);
  249. Assert.DoesNotContain("is_active_obligation", values.Keys);
  250. Assert.DoesNotContain("severity", values.Keys);
  251. }
  252. // ============================================================
  253. // F. TIMEOUT 判定(由 evaluator 判,不由 Provider 预筛)
  254. // ============================================================
  255. [Fact]
  256. public void T9_Timeout_Hits_WhenOverdueAndNotCompleted()
  257. {
  258. var rowSet = RowSet(Row("PO202608260002", "3",
  259. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  260. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  261. rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  262. var hit = Assert.Single(hits);
  263. Assert.Equal("PO202608260002#3", hit.SourceObjectId);
  264. Assert.Equal("PO202608260002", hit.RelatedObjectCode);
  265. Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
  266. }
  267. [Fact]
  268. public void T10_Timeout_NoHit_WhenNotYetDue()
  269. {
  270. // 真实样本 PO202608280001#1:due 2026-09-17,状态 OPEN。
  271. var rowSet = RowSet(Row("PO202608280001", "1",
  272. new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN", supplierName: "怡祥"));
  273. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  274. rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  275. Assert.Empty(hits);
  276. }
  277. [Fact]
  278. public void T11_Timeout_NoHit_WhenOverdueButCompleted()
  279. {
  280. // 真实样本 PO202608260002#1:已逾期但 COMPLETED。
  281. // 这一条必须由 evaluator 判出来 —— 若 Provider 在 SQL 里滤掉已完成行,
  282. // 本测试会"通过"但测的是过滤而非判定,且该行不在 hits 里会被误判为恢复。
  283. var rowSet = RowSet(Row("PO202608260002", "1",
  284. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
  285. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  286. rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  287. Assert.Empty(hits);
  288. }
  289. [Fact]
  290. public void T11b_Timeout_NoHit_ForClosedAndCancelled()
  291. {
  292. // dwd_supplier_delivery 有三个写入方,词表不一致:批量转换产 COMPLETED/DELAYED/OPEN,
  293. // 增量写入方产 CANCELLED/CLOSED/PARTIAL。当前库中后两值 0 行,这是零行为变更的安全余量。
  294. var rowSet = RowSet(
  295. Row("PO-CLOSED", "1", new DateTime(2026, 8, 1), null, "CLOSED"),
  296. Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
  297. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  298. rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  299. Assert.Empty(hits);
  300. }
  301. [Fact]
  302. public void T11c_Timeout_Hits_ForPartial_BecausePartialIsNotTerminal()
  303. {
  304. // PARTIAL 刻意不列入 completedStates:部分收货不是终态,仍需继续监控。
  305. var rowSet = RowSet(Row("PO-PARTIAL", "1", new DateTime(2026, 8, 1), null, "PARTIAL"));
  306. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  307. rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  308. Assert.Single(hits);
  309. }
  310. [Fact]
  311. public void T13_Recovery_Contract_HitSetShrinksWhenStatusBecomesCompleted()
  312. {
  313. // 恢复判定的输入是"本轮 hits 的 dedup_key 集合"(见 ReconcileRecoveriesForRuleAsync)。
  314. // 这里证明:同一对象在状态转为 COMPLETED 后不再出现在 hits 中,
  315. // 从而进入恢复候选。真实 recovered_at 写入属运行态,由 Batch B 验证。
  316. var before = RowSet(Row("PO202608260002", "2",
  317. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  318. var after = RowSet(Row("PO202608260002", "2",
  319. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
  320. var hitsBefore = S8TimeoutRuleEvaluator.EvaluateRows(
  321. before, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  322. var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
  323. after, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
  324. var dedupKey = Assert.Single(hitsBefore).DedupKey;
  325. Assert.Empty(hitsAfter);
  326. Assert.DoesNotContain(dedupKey, hitsAfter.Select(h => h.DedupKey));
  327. }
  328. [Fact]
  329. public void T14_Hit_CarriesExceptionTypeAndDiagnosticPayload()
  330. {
  331. var rowSet = RowSet(Row("PO202608260002", "3",
  332. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  333. var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
  334. rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5)));
  335. Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
  336. Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
  337. Assert.Equal("PO202608260002#3", hit.SourceObjectId);
  338. Assert.Equal("T838257186181189:F838257186320453:RRULE_S4_PURCHASE_DELIVERY_DATE_DELAY:PURCHASE_ORDER_LINE:PO202608260002#3",
  339. hit.DedupKey);
  340. // 诊断列必须随 payload 落盘:remaining_qty 供后续数据治理观察,
  341. // due_at_source 用于追溯每行实际使用了 need_date 还是 due_date。
  342. Assert.Contains("remaining_qty", hit.SourcePayload);
  343. Assert.Contains("due_at_source", hit.SourcePayload);
  344. Assert.Contains("stat_date", hit.SourcePayload);
  345. }
  346. // ============================================================
  347. // G. STANDARD_DATASET 绝不回落 Legacy
  348. // ============================================================
  349. [Fact]
  350. public async Task T12_StandardDataset_NeverFallsBackToLegacySql()
  351. {
  352. var legacy = new SpyLegacy();
  353. var gateway = new S8MonitoringDataGateway(
  354. legacy,
  355. new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new EmptyPurchaseDeliveryProvider() }),
  356. new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
  357. NullLogger<S8MonitoringDataGateway>.Instance);
  358. var rule = NewRule();
  359. // 历史遗留字段里即使躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
  360. rule.Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN";
  361. rule.DataSourceId = 99;
  362. var result = await gateway.LoadAsync(838257186181189, 838257186320453, rule, "TIMEOUT", 60, 1000);
  363. Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
  364. Assert.Equal(0, legacy.CallCount);
  365. Assert.Null(legacy.LastExpression);
  366. }
  367. [Fact]
  368. public async Task T12b_ProviderFailure_SurfacesAsQueryFailed_WithoutLegacyFallback()
  369. {
  370. var legacy = new SpyLegacy();
  371. var gateway = new S8MonitoringDataGateway(
  372. legacy,
  373. new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new ThrowingPurchaseDeliveryProvider() }),
  374. new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
  375. NullLogger<S8MonitoringDataGateway>.Instance);
  376. var rule = NewRule();
  377. rule.Expression = "SELECT SHOULD_NEVER_RUN";
  378. var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
  379. gateway.LoadAsync(838257186181189, 838257186320453, rule, "TIMEOUT", 60, 1000));
  380. Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
  381. Assert.Equal(0, legacy.CallCount); // ← 本测试的核心断言
  382. }
  383. private sealed class SpyLegacy : IS8LegacySqlDataProvider
  384. {
  385. public int CallCount { get; private set; }
  386. public string LastExpression { get; private set; }
  387. public Task<S8MonitoringDataResult> LoadAsync(
  388. long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
  389. int timeoutSeconds, CancellationToken cancellationToken = default)
  390. {
  391. CallCount++;
  392. LastExpression = rule.Expression;
  393. return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
  394. }
  395. }
  396. private sealed class EmptyPurchaseDeliveryProvider : IS8MonitoringDataProvider
  397. {
  398. public string DatasetCode => PurchaseDelivery;
  399. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  400. public Task<S8MonitoringRowSet> LoadAsync(
  401. S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  402. {
  403. request.EnsureValid();
  404. return Task.FromResult(S8MonitoringRowSet.Empty);
  405. }
  406. }
  407. private sealed class ThrowingPurchaseDeliveryProvider : IS8MonitoringDataProvider
  408. {
  409. public string DatasetCode => PurchaseDelivery;
  410. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  411. public Task<S8MonitoringRowSet> LoadAsync(
  412. S8MonitoringDataRequest request, CancellationToken cancellationToken = default) =>
  413. throw new InvalidOperationException("dwd unavailable");
  414. }
  415. }