S8RuleProvisioningIdempotencyTests.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. using System.Reflection;
  2. using Microsoft.AspNetCore.Mvc;
  3. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Admin.NET.Plugin.AiDOP.Service.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  8. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  9. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  10. using Xunit;
  11. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  12. /// <summary>
  13. /// S8-RULE-GOVERNANCE-BATCH2:租户运行策略供给契约。
  14. ///
  15. /// <para>本文件要证明的三条不变量:</para>
  16. /// <list type="number">
  17. /// <item><b>身份 = (TenantId, RuleCode)</b>。Factory 不参与 —— 0 / 1 / N 个工厂都只产出一条策略;</item>
  18. /// <item><b>租户隔离</b>。同一条规则定义在不同租户下是彼此独立的策略,改 A 不影响 B;</item>
  19. /// <item><b>不覆盖租户已调整的参数与运行态</b>。供给只纠正定义投影。</item>
  20. /// </list>
  21. ///
  22. /// <para>测试打在纯决策函数 <c>S8RuleProvisioningService.Plan</c> 上,不接数据库 ——
  23. /// 这三条不变量必须能被逐字段断言,而不是"跑一遍看起来对"。</para>
  24. /// </summary>
  25. public class S8RuleProvisioningIdempotencyTests
  26. {
  27. private const long TenantA = 838257186181189L;
  28. private const long TenantB = 838257212780613L;
  29. private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
  30. private static readonly DateTime Now = new(2026, 9, 6, 14, 0, 0);
  31. private static List<S8RuleDefinition> Definitions() =>
  32. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
  33. .Definitions.ToList();
  34. private static S8RuleProvisioningPlan Plan(
  35. IReadOnlyCollection<long> tenants,
  36. IReadOnlyCollection<AdoS8WatchRule> existing,
  37. IReadOnlyCollection<S8RuleDefinition>? definitions = null) =>
  38. S8RuleProvisioningService.Plan(tenants, definitions ?? Definitions(), existing, Now);
  39. /// <summary>一条租户已经调整过、并且跑过一段时间的既有策略行。</summary>
  40. private static AdoS8WatchRule TunedRow(long tenantId, long id = 1000, string ruleCode = Rule01) => new()
  41. {
  42. Id = id,
  43. TenantId = tenantId,
  44. FactoryId = 838257186320453L, // 历史值,供给不得改写
  45. RuleCode = ruleCode,
  46. // 定义投影:与代码定义一致
  47. DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
  48. RuleType = "TIMEOUT",
  49. SourceObjectType = S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType,
  50. WatchObjectType = S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType,
  51. SceneCode = "S4",
  52. StageCode = "S4",
  53. OrderFlowCode = "MATERIAL_PURCHASE",
  54. RuleMechanism = "DATE",
  55. // 租户调过的参数
  56. Enabled = true,
  57. Severity = S8SeverityCode.Follow,
  58. PollIntervalSeconds = 900,
  59. TriggerCountRequired = 3,
  60. RecoverCountRequired = 5,
  61. ParamsJson = """{"graceMinutes":60,"defaultOccurrenceDeptId":245}""",
  62. // 运行态
  63. NextRunAt = new DateTime(2026, 9, 6, 13, 50, 0),
  64. LastRunAt = new DateTime(2026, 9, 6, 13, 40, 0),
  65. LastStatus = "SUCCESS",
  66. LastError = "prev-error",
  67. LastDurationMs = 255,
  68. LastRunId = "0da4435a127c47dd",
  69. LockToken = "tok",
  70. LockedBy = "host-1",
  71. LockUntil = new DateTime(2026, 9, 6, 13, 45, 0),
  72. RunningStartedAt = new DateTime(2026, 9, 6, 13, 40, 0),
  73. ConsecutiveFailureCount = 2,
  74. PausedUntil = new DateTime(2026, 9, 6, 12, 0, 0),
  75. PauseReason = "MANUAL_PAUSED",
  76. CreatedAt = new DateTime(2026, 9, 5, 11, 24, 43)
  77. };
  78. // ───────────────────────── T1 / T2:建行与幂等 ─────────────────────────
  79. [Fact]
  80. public void T1_MissingTenantRule_CreatesExactlyOne()
  81. {
  82. var plan = Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>());
  83. var row = Assert.Single(plan.Inserts);
  84. Assert.Equal(TenantA, row.TenantId);
  85. Assert.Equal(Rule01, row.RuleCode);
  86. Assert.Empty(plan.Updates);
  87. Assert.Equal(0, plan.UnchangedCount);
  88. }
  89. [Fact]
  90. public void T2_SecondAndThirdRun_CreateNothing()
  91. {
  92. var created = Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts;
  93. created[0].Id = 1; // 模拟落库后回填主键
  94. var second = Plan(new[] { TenantA }, created);
  95. Assert.Empty(second.Inserts);
  96. Assert.Empty(second.Updates);
  97. Assert.Equal(1, second.UnchangedCount);
  98. var third = Plan(new[] { TenantA }, created);
  99. Assert.Empty(third.Inserts);
  100. Assert.Empty(third.Updates);
  101. }
  102. // ───────────────────────── T3 / T4 / T5:绝不覆盖租户侧 ─────────────────────────
  103. [Fact]
  104. public void T3_ExistingRuntimeParameters_AreUntouched()
  105. {
  106. var row = TunedRow(TenantA);
  107. Plan(new[] { TenantA }, new[] { row });
  108. Assert.Equal(900, row.PollIntervalSeconds);
  109. Assert.Equal(3, row.TriggerCountRequired);
  110. Assert.Equal(5, row.RecoverCountRequired);
  111. Assert.Equal(S8SeverityCode.Follow, row.Severity);
  112. Assert.Equal("""{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", row.ParamsJson);
  113. }
  114. [Fact]
  115. public void T4_ExistingEnabled_IsUntouched()
  116. {
  117. var enabled = TunedRow(TenantA);
  118. Plan(new[] { TenantA }, new[] { enabled });
  119. Assert.True(enabled.Enabled);
  120. var disabled = TunedRow(TenantA);
  121. disabled.Enabled = false;
  122. Plan(new[] { TenantA }, new[] { disabled });
  123. Assert.False(disabled.Enabled);
  124. }
  125. [Fact]
  126. public void T5_ExistingRuntimeState_IsUntouched()
  127. {
  128. var row = TunedRow(TenantA);
  129. Plan(new[] { TenantA }, new[] { row });
  130. Assert.Equal(new DateTime(2026, 9, 6, 13, 50, 0), row.NextRunAt);
  131. Assert.Equal(new DateTime(2026, 9, 6, 13, 40, 0), row.LastRunAt);
  132. Assert.Equal("SUCCESS", row.LastStatus);
  133. Assert.Equal("prev-error", row.LastError);
  134. Assert.Equal(255, row.LastDurationMs);
  135. Assert.Equal("0da4435a127c47dd", row.LastRunId);
  136. Assert.Equal("tok", row.LockToken);
  137. Assert.Equal("host-1", row.LockedBy);
  138. Assert.Equal(new DateTime(2026, 9, 6, 13, 45, 0), row.LockUntil);
  139. Assert.Equal(new DateTime(2026, 9, 6, 13, 40, 0), row.RunningStartedAt);
  140. Assert.Equal(2, row.ConsecutiveFailureCount);
  141. Assert.Equal(new DateTime(2026, 9, 6, 12, 0, 0), row.PausedUntil);
  142. Assert.Equal("MANUAL_PAUSED", row.PauseReason);
  143. Assert.Equal(838257186320453L, row.FactoryId); // FactoryId 同样不得改写
  144. }
  145. // ───────────────────────── T6:投影漂移被纠正 ─────────────────────────
  146. [Fact]
  147. public void T6_WrongDefinitionProjection_IsRefreshed_WithoutTouchingTenantPolicy()
  148. {
  149. var row = TunedRow(TenantA);
  150. row.DatasetCode = "WRONG_DATASET";
  151. row.RuleType = "OUT_OF_RANGE";
  152. row.SceneCode = "S1";
  153. row.SourceObjectType = "EVIL_OBJECT";
  154. row.WatchObjectType = "EVIL_OBJECT";
  155. row.StageCode = null;
  156. row.OrderFlowCode = null;
  157. row.RuleMechanism = null;
  158. var plan = Plan(new[] { TenantA }, new[] { row });
  159. Assert.Same(row, Assert.Single(plan.Updates));
  160. Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, row.DatasetCode);
  161. Assert.Equal("TIMEOUT", row.RuleType);
  162. Assert.Equal("S4", row.SceneCode);
  163. Assert.Equal("S4", row.StageCode);
  164. Assert.Equal("MATERIAL_PURCHASE", row.OrderFlowCode);
  165. Assert.Equal("DATE", row.RuleMechanism);
  166. Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, row.SourceObjectType);
  167. Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, row.WatchObjectType);
  168. // 代码定义是权威,但它只管定义 —— 租户策略与运行态原封不动。
  169. Assert.True(row.Enabled);
  170. Assert.Equal(900, row.PollIntervalSeconds);
  171. Assert.Equal(S8SeverityCode.Follow, row.Severity);
  172. Assert.Equal("""{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", row.ParamsJson);
  173. Assert.Equal("SUCCESS", row.LastStatus);
  174. Assert.Equal(838257186320453L, row.FactoryId);
  175. }
  176. // ───────────────────────── T7 / T8:ParamsJson 不重写 ─────────────────────────
  177. [Fact]
  178. public void T7_LegacyMixedParamsJson_IsNotRewritten()
  179. {
  180. const string legacy = """
  181. {"dueAtField":"due_at","statusField":"status","completedStates":["COMPLETED"],
  182. "objectIdField":"source_object_id","exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL",
  183. "graceMinutes":30,"defaultOccurrenceDeptId":245}
  184. """;
  185. var row = TunedRow(TenantA);
  186. row.ParamsJson = legacy;
  187. row.DatasetCode = "WRONG"; // 同时触发一次投影刷新,证明刷新不会顺手动 params
  188. Plan(new[] { TenantA }, new[] { row });
  189. Assert.Equal(legacy, row.ParamsJson);
  190. }
  191. /// <summary>
  192. /// G1 造成的历史状态:params_json = NULL。
  193. /// 供给**不得**自动补写默认 JSON —— Batch 1 已证明 NULL + 代码定义可以正常运行,
  194. /// 悄悄补写反而是在未经请求的情况下改变租户策略。
  195. /// </summary>
  196. [Fact]
  197. public void T8_NullParamsJson_IsNotBackfilled()
  198. {
  199. var row = TunedRow(TenantA);
  200. row.ParamsJson = null;
  201. row.RuleType = "WRONG"; // 触发投影刷新
  202. var plan = Plan(new[] { TenantA }, new[] { row });
  203. Assert.Single(plan.Updates);
  204. Assert.Null(row.ParamsJson);
  205. }
  206. // ───────────────────────── T9 / T10:租户隔离(本批核心) ─────────────────────────
  207. [Fact]
  208. public void T9_TwoTenants_GetTwoIndependentRows()
  209. {
  210. var plan = Plan(new[] { TenantA, TenantB }, Array.Empty<AdoS8WatchRule>());
  211. Assert.Equal(2, plan.Inserts.Count);
  212. Assert.Equal(new[] { TenantA, TenantB }, plan.Inserts.Select(r => r.TenantId).ToArray());
  213. Assert.All(plan.Inserts, r => Assert.Equal(Rule01, r.RuleCode));
  214. // 两行必须是不同实例:共享引用会让一个租户改参数波及另一个。
  215. Assert.NotSame(plan.Inserts[0], plan.Inserts[1]);
  216. }
  217. [Fact]
  218. public void T10_TenantParametersDoNotLeakAcrossTenants()
  219. {
  220. var a = TunedRow(TenantA, id: 1);
  221. a.ParamsJson = """{"graceMinutes":60}""";
  222. var b = TunedRow(TenantB, id: 2);
  223. b.ParamsJson = """{"graceMinutes":0}""";
  224. b.Enabled = false;
  225. b.PollIntervalSeconds = 300;
  226. Plan(new[] { TenantA, TenantB }, new[] { a, b });
  227. Assert.Equal("""{"graceMinutes":60}""", a.ParamsJson);
  228. Assert.Equal("""{"graceMinutes":0}""", b.ParamsJson);
  229. Assert.True(a.Enabled);
  230. Assert.False(b.Enabled);
  231. Assert.Equal(900, a.PollIntervalSeconds);
  232. Assert.Equal(300, b.PollIntervalSeconds);
  233. }
  234. [Fact]
  235. public void T10_SyncingOneTenant_DoesNotTouchAnother()
  236. {
  237. var a = TunedRow(TenantA, id: 1);
  238. var b = TunedRow(TenantB, id: 2);
  239. b.DatasetCode = "WRONG_BUT_OUT_OF_SCOPE";
  240. // 只对 A 做对账;B 的行即使投影是错的也不该进入方案。
  241. var plan = Plan(new[] { TenantA }, new[] { a, b });
  242. Assert.Empty(plan.Inserts);
  243. Assert.Empty(plan.Updates);
  244. Assert.Equal("WRONG_BUT_OUT_OF_SCOPE", b.DatasetCode);
  245. }
  246. // ───────────────────────── T11 / T12:Factory 与身份无关 ─────────────────────────
  247. /// <summary>
  248. /// 防回归:多工厂租户绝不能产出多条策略。
  249. /// 这是最容易被后来者"顺手改回去"的地方 —— 一旦有人把 factory 加进身份,本用例立刻失败。
  250. /// </summary>
  251. [Theory]
  252. [InlineData(0)] // 零工厂租户
  253. [InlineData(1)] // 单工厂租户
  254. [InlineData(3)] // 多工厂租户
  255. public void T11_T12_TenantAlwaysGetsExactlyOnePolicyPerRule_RegardlessOfFactoryCount(int factoryCount)
  256. {
  257. // 工厂数量对 Plan 而言根本不是输入 —— 这正是要断言的事实:
  258. // 供给的输入只有租户与定义,工厂无从参与。
  259. _ = factoryCount;
  260. var plan = Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>());
  261. var row = Assert.Single(plan.Inserts);
  262. Assert.Equal(S8RuleProvisioningService.CompatibilityFactoryId, row.FactoryId);
  263. }
  264. /// <summary>
  265. /// 历史上按工厂各建了一条 Rule 01(同 tenant + 同 rule_code,不同 factory_id)。
  266. /// 供给必须把它们视为**同一个身份**:只刷新 Id 最小的一行、不再新建、多余行上报但不删。
  267. /// </summary>
  268. [Fact]
  269. public void T11_PerFactoryLegacyRows_AreTreatedAsOneIdentity()
  270. {
  271. var f1 = TunedRow(TenantA, id: 10); f1.FactoryId = 111; f1.DatasetCode = "WRONG";
  272. var f2 = TunedRow(TenantA, id: 20); f2.FactoryId = 222; f2.DatasetCode = "WRONG";
  273. var f3 = TunedRow(TenantA, id: 30); f3.FactoryId = 333; f3.DatasetCode = "WRONG";
  274. var plan = Plan(new[] { TenantA }, new[] { f2, f3, f1 });
  275. Assert.Empty(plan.Inserts); // 不因"工厂缺行"而新建
  276. Assert.Same(f1, Assert.Single(plan.Updates)); // 只刷新 Id 最小的一行
  277. Assert.Equal(2, plan.DuplicateCount); // 其余上报
  278. Assert.Equal("WRONG", f2.DatasetCode); // 不动
  279. Assert.Equal("WRONG", f3.DatasetCode);
  280. Assert.Equal(111, f1.FactoryId); // 既有 FactoryId 保持
  281. }
  282. [Fact]
  283. public void T12_NewRowUsesCompatibilityFactoryId_NotAResolvedFactory()
  284. {
  285. var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts);
  286. // 0 = 无工厂作用域的兼容 metadata。绝不是"解析出来的唯一工厂"——
  287. // 那样零工厂 / 多工厂租户会直接供给失败。
  288. Assert.Equal(0L, row.FactoryId);
  289. }
  290. // ───────────────────────── T13 / T14:停用租户与孤儿 ─────────────────────────
  291. /// <summary>
  292. /// 停用租户不进入租户列表(<c>SysTenant.Status = 1</c> 过滤发生在 <c>SyncAsync</c>),
  293. /// 因此不会产生新行。本用例从 Plan 层确认:不在列表里 = 不产出。
  294. /// </summary>
  295. [Fact]
  296. public void T13_TenantNotInActiveList_GetsNoNewRow()
  297. {
  298. var plan = Plan(Array.Empty<long>(), Array.Empty<AdoS8WatchRule>());
  299. Assert.Empty(plan.Inserts);
  300. Assert.Empty(plan.Updates);
  301. }
  302. [Fact]
  303. public void T13_ExistingRowOfInactiveTenant_IsNotDeletedNorDisabled()
  304. {
  305. var inactive = TunedRow(TenantB, id: 77);
  306. // 只对 A 对账(B 停用故不在列表)。B 的行必须原封不动。
  307. Plan(new[] { TenantA }, new[] { inactive });
  308. Assert.True(inactive.Enabled);
  309. Assert.Equal(S8SeverityCode.Follow, inactive.Severity);
  310. }
  311. [Fact]
  312. public void T14_OrphanRule_IsReportedButNeverDeletedOrChanged()
  313. {
  314. var orphan = TunedRow(TenantA, id: 55, ruleCode: "RULE_THAT_NO_LONGER_EXISTS");
  315. orphan.DatasetCode = "SOME_OLD_DATASET";
  316. var plan = Plan(new[] { TenantA }, new[] { orphan });
  317. Assert.Equal(1, plan.OrphanedCount);
  318. Assert.DoesNotContain(orphan, plan.Updates);
  319. Assert.Equal("SOME_OLD_DATASET", orphan.DatasetCode);
  320. Assert.True(orphan.Enabled);
  321. // 孤儿不阻碍正常供给:Rule 01 仍会被建出来。
  322. Assert.Equal(Rule01, Assert.Single(plan.Inserts).RuleCode);
  323. }
  324. // ───────────────────────── T15:新增定义自动铺开 ─────────────────────────
  325. [Fact]
  326. public void T15_NewDefinition_IsProvisionedToEveryExistingTenant()
  327. {
  328. var definitions = Definitions();
  329. definitions.Add(FakeRule02());
  330. var existingA = TunedRow(TenantA, id: 1);
  331. var existingB = TunedRow(TenantB, id: 2);
  332. var plan = Plan(new[] { TenantA, TenantB }, new[] { existingA, existingB }, definitions);
  333. Assert.Equal(2, plan.Inserts.Count);
  334. Assert.All(plan.Inserts, r => Assert.Equal("RULE_S4_FAKE_FOR_TEST", r.RuleCode));
  335. Assert.Equal(new[] { TenantA, TenantB }, plan.Inserts.Select(r => r.TenantId).ToArray());
  336. // 既有 Rule 01 不受影响。
  337. Assert.Equal(2, plan.UnchangedCount);
  338. }
  339. // ───────────────────────── 新行契约 ─────────────────────────
  340. [Fact]
  341. public void NewRow_UsesDefinitionDefaults_AndNoFakedRunHistory()
  342. {
  343. var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts);
  344. // 定义投影
  345. Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, row.DatasetCode);
  346. Assert.Equal("TIMEOUT", row.RuleType);
  347. Assert.Equal("DATE", row.RuleMechanism);
  348. Assert.Equal("S4", row.SceneCode);
  349. Assert.Equal("S4", row.StageCode);
  350. Assert.Equal("MATERIAL_PURCHASE", row.OrderFlowCode);
  351. // 运行参数默认值来自定义策略
  352. Assert.False(row.Enabled);
  353. Assert.Equal(S8SeverityCode.Serious, row.Severity);
  354. Assert.Equal(300, row.PollIntervalSeconds);
  355. Assert.Equal(1, row.TriggerCountRequired);
  356. Assert.Equal(2, row.RecoverCountRequired);
  357. Assert.Equal("""{"graceMinutes":0}""", row.ParamsJson);
  358. // 运行态:一条尚未运行的规则该有的样子,不伪造历史
  359. Assert.Null(row.NextRunAt);
  360. Assert.Null(row.LastRunAt);
  361. Assert.Null(row.LastStatus);
  362. Assert.Null(row.LastError);
  363. Assert.Null(row.LastDurationMs);
  364. Assert.Null(row.LastRunId);
  365. Assert.Null(row.LockToken);
  366. Assert.Null(row.LockedBy);
  367. Assert.Null(row.LockUntil);
  368. Assert.Null(row.RunningStartedAt);
  369. Assert.Equal(0, row.ConsecutiveFailureCount);
  370. Assert.Null(row.PausedUntil);
  371. Assert.Null(row.PauseReason);
  372. Assert.Null(row.UpdatedAt);
  373. Assert.Equal(0, row.Id);
  374. }
  375. /// <summary>新行的 params_json 只能是 B 类运行参数,绝不能带任何判定语义。</summary>
  376. [Fact]
  377. public void NewRow_ParamsJson_CarriesNoJudgementSemantics()
  378. {
  379. var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts);
  380. foreach (var forbidden in new[]
  381. { "dueAtField", "statusField", "completedStates", "objectIdField", "objectCodeField", "exceptionTypeCode" })
  382. Assert.DoesNotContain(forbidden, row.ParamsJson!, StringComparison.Ordinal);
  383. }
  384. // ───────────────────────── T16 / T17 / T18:端点 · 注册 · 依赖方向 ─────────────────────────
  385. [Fact]
  386. public void T16_ProvisionEndpoint_IsPost_Scoped_AndReturnsObservableCounters()
  387. {
  388. var action = Assert.Single(typeof(AdoS8ConfigWatchRulesController)
  389. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  390. .Where(m => m.Name == "ProvisionAsync"));
  391. Assert.Equal("provision", Assert.Single(action.GetCustomAttributes<HttpPostAttribute>()).Template);
  392. Assert.NotEmpty(action.GetCustomAttributes()
  393. .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
  394. // 结果必须可观测:只回 200 会让运维不知道新规则有没有铺开。
  395. foreach (var counter in new[]
  396. { "TenantCount", "DefinitionCount", "CreatedCount", "RefreshedCount", "UnchangedCount", "OrphanedCount" })
  397. Assert.NotNull(typeof(S8RuleProvisioningResult).GetProperty(counter));
  398. }
  399. [Fact]
  400. public void T17_Service_ExposesBothFullAndPerTenantSync()
  401. {
  402. var t = typeof(S8RuleProvisioningService);
  403. Assert.NotNull(t.GetMethod("SyncAsync"));
  404. Assert.NotNull(t.GetMethod("SyncTenantAsync"));
  405. }
  406. /// <summary>
  407. /// T18 + 依赖方向守卫:供给**不得**架在即将退役的业务建规则路径之上。
  408. /// Batch 3 会删掉 <c>CreateAsync</c>;如果这里依赖它,那一批会连带炸掉供给。
  409. /// </summary>
  410. [Fact]
  411. public void T18_ProvisioningDoesNotDependOnLegacyCreatePath()
  412. {
  413. var deps = Assert.Single(typeof(S8RuleProvisioningService).GetConstructors())
  414. .GetParameters().Select(p => p.ParameterType.Name).ToArray();
  415. Assert.DoesNotContain(deps, d => d.Contains("S8WatchRuleService", StringComparison.Ordinal));
  416. Assert.DoesNotContain(deps, d => d.Contains("S8ConfigDraftService", StringComparison.Ordinal));
  417. // 正向:依赖方向必须是 Catalog → Provisioning → DB。
  418. Assert.Contains(deps, d => d.Contains("IS8RuleCatalog", StringComparison.Ordinal));
  419. }
  420. /// <summary>
  421. /// Factory 守卫:身份判定不得含 FactoryId。用源码扫描而非行为断言 ——
  422. /// 谓词写没写在方法体里,反射看不到。
  423. /// </summary>
  424. [Fact]
  425. public void FactoryGuard_IdentityPredicateNeverIncludesFactory()
  426. {
  427. var code = SourceOf("Service/S8/S8RuleProvisioningService.cs");
  428. // 存在性查询只按 tenant_id 过滤
  429. Assert.Contains("Where(x => tenantIds.Contains(x.TenantId))", code);
  430. // 身份分组键只有 (TenantId, RuleCode)
  431. Assert.Contains("GroupBy(r => (r.TenantId, Code: r.RuleCode ?? string.Empty)", code);
  432. // 可执行代码里不得出现按工厂比较的身份谓词
  433. foreach (var forbidden in new[] { "x.FactoryId == ", "r.FactoryId == ", "FactoryId == scope.FactoryId" })
  434. Assert.DoesNotContain(forbidden, ExecutableLines(code));
  435. }
  436. private static S8RuleDefinition FakeRule02() => new()
  437. {
  438. RuleCode = "RULE_S4_FAKE_FOR_TEST",
  439. DisplayName = "测试用第二条规则",
  440. Description = "验证新增定义会自动铺开到每个既有租户",
  441. DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
  442. RuleType = "TIMEOUT",
  443. RuleMechanism = "DATE",
  444. SourceObjectType = "PURCHASE_ORDER_LINE",
  445. SceneCode = "S4",
  446. StageCode = "S4",
  447. ExceptionTypeCode = "PURCHASE_DELIVERY_ABNORMAL",
  448. Timeout = new S8TimeoutSemantics()
  449. };
  450. private static string SourceOf(string relative)
  451. {
  452. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  453. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  454. dir = dir.Parent;
  455. Assert.NotNull(dir);
  456. var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", relative.Replace('/', Path.DirectorySeparatorChar));
  457. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  458. return File.ReadAllText(full);
  459. }
  460. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  461. private static string ExecutableLines(string code) =>
  462. string.Join('\n', code.Split('\n')
  463. .Select(l => l.TrimStart())
  464. .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
  465. && !l.StartsWith("//", StringComparison.Ordinal)));
  466. }