S8TenantOnlyExceptionScopeTests.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. using System.Reflection;
  2. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  3. using Admin.NET.Plugin.AiDOP.Dto.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Admin.NET.Plugin.AiDOP.Job;
  6. using Admin.NET.Plugin.AiDOP.Service.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  10. /// <summary>
  11. /// S8-TENANT-ONLY-BATCH6:异常 / 工作流 / 主数据侧收敛为租户级作用域。
  12. ///
  13. /// <para><b>本批要消灭的断点</b>:Batch 5 之后规则运行策略的 <c>factory_id</c> 恒为 0,
  14. /// 于是自动建单产出的异常 <c>factory_id</c> 也是 0;而异常中心、任务流、卡死扫描
  15. /// 当时仍按 <c>(tenant_id, factory_id)</c> 查。结果是一个**完整闭环的静默失败**:
  16. /// 规则真实命中、异常真实落库、页面一条也看不到,且没有任何报错。</para>
  17. ///
  18. /// <para><b>本批还修掉了一个真实越权</b>(不是等价改写):主数据侧原来按
  19. /// <c>factory_ref_id</c> 划边界,而真库 <c>factory_ref_id = 1000</c>
  20. /// 同时属于租户 824585161322565 与 797403760988229——按它过滤会把另一租户的
  21. /// 613 名员工 / 210 个部门列进本租户的下拉框,并可被绑定成处理人、写进 responsible_dept_id。</para>
  22. ///
  23. /// <para>因此本文件的多数断言是**反向的**("不得再出现 factory 判据"):
  24. /// 这两类回归的共同特征是加回一个谓词就静默改变可见集合,正向断言抓不到。</para>
  25. /// </summary>
  26. public class S8TenantOnlyExceptionScopeTests
  27. {
  28. private const BindingFlags Instance =
  29. BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
  30. private static readonly string PluginRoot =
  31. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  32. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  33. private static string CodeOnly(string relativePath)
  34. {
  35. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  36. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  37. return string.Join('\n', File.ReadAllLines(full)
  38. .Where(l =>
  39. {
  40. var t = l.TrimStart();
  41. return !t.StartsWith("///", StringComparison.Ordinal)
  42. && !t.StartsWith("//", StringComparison.Ordinal);
  43. }));
  44. }
  45. // ───────────────── T1–T3:可信作用域本身 ─────────────────
  46. /// <summary>T1:S8TrustedScope 只承载租户;工厂不再是它的一个维度。</summary>
  47. [Fact]
  48. public void T1_TrustedScope_CarriesTenantOnly()
  49. {
  50. Assert.Null(typeof(S8TrustedScope).GetProperty("FactoryId"));
  51. var ctors = typeof(S8TrustedScope).GetConstructors();
  52. Assert.All(ctors, c => Assert.DoesNotContain(
  53. c.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)));
  54. var scope = new S8TrustedScope(838257186181189L);
  55. Assert.Equal(838257186181189L, scope.TenantId);
  56. }
  57. /// <summary>
  58. /// T2:LegacyFactoryId 恒等于平台默认哨兵 0,且**不是**作用域。
  59. /// 它只服务「还要往 factory_id 列写值」的旧代码;一旦有人把它当边界用,
  60. /// 得到的是「所有租户都相等」的常量,等于没有边界。
  61. /// </summary>
  62. [Fact]
  63. public void T2_LegacyFactoryId_IsAlwaysPlatformSentinel()
  64. {
  65. Assert.Equal(S8ConfigScope.GlobalFactoryId, new S8TrustedScope(1L).LegacyFactoryId);
  66. Assert.Equal(S8ConfigScope.GlobalFactoryId, new S8TrustedScope(838257186181189L).LegacyFactoryId);
  67. Assert.Equal(0L, S8ConfigScope.GlobalFactoryId);
  68. }
  69. /// <summary>T3:解析器只解析租户;工厂解析链(含 OrgType 常量)必须整体消失。</summary>
  70. [Fact]
  71. public void T3_Resolver_HasNoFactoryResolutionPath()
  72. {
  73. var src = CodeOnly("Infrastructure/S8TrustedScopeResolver.cs");
  74. Assert.DoesNotContain("ResolveFactoryIdAsync", src);
  75. Assert.DoesNotContain("\"501\"", src);
  76. Assert.DoesNotContain("SysOrg", src);
  77. var resolve = typeof(S8TrustedScopeResolver).GetMethod(nameof(S8TrustedScopeResolver.ResolveAsync));
  78. Assert.NotNull(resolve);
  79. Assert.Empty(resolve!.GetParameters());
  80. }
  81. // ───────────────── T4–T8:异常可见性 ─────────────────
  82. /// <summary>T4:异常查询 / 详情 / 筛选项一律不再接受 factoryId。</summary>
  83. [Theory]
  84. [InlineData(nameof(S8ExceptionService.GetFilterOptionsAsync))]
  85. [InlineData(nameof(S8ExceptionService.GetDetailAsync))]
  86. [InlineData(nameof(S8ExceptionService.SoftDeleteTestPrefixAsync))]
  87. public void T4_ExceptionReadEntryPoints_TakeNoFactory(string method)
  88. {
  89. var overloads = typeof(S8ExceptionService)
  90. .GetMethods(BindingFlags.Instance | BindingFlags.Public)
  91. .Where(m => m.Name == method)
  92. .ToList();
  93. Assert.NotEmpty(overloads);
  94. Assert.All(overloads, m => Assert.DoesNotContain(
  95. m.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)));
  96. }
  97. /// <summary>
  98. /// T5:异常表的查询谓词里不得再出现工厂等值。
  99. /// 这是本批的核心断点——留一条就等于「规则命中了但异常中心查不到」。
  100. /// </summary>
  101. [Theory]
  102. [InlineData("Service/S8/S8ExceptionService.cs")]
  103. [InlineData("Service/S8/S8TaskFlowService.cs")]
  104. [InlineData("Service/S8/S8DetectionLogQueryService.cs")]
  105. [InlineData("Service/S8/S8NotificationLogQueryService.cs")]
  106. [InlineData("Service/S8/S8IssueLedgerService.cs")]
  107. [InlineData("Service/S8/S8AlertAggregationService.cs")]
  108. [InlineData("Service/S8/S8DecisionService.cs")]
  109. public void T5_ExceptionQueries_HaveNoFactoryPredicate(string src)
  110. {
  111. var code = CodeOnly(src);
  112. Assert.DoesNotContain("FactoryId == factoryId", code);
  113. Assert.DoesNotContain("FactoryId == q.FactoryId", code);
  114. Assert.DoesNotContain("FactoryId == scope.FactoryId", code);
  115. }
  116. /// <summary>
  117. /// T6:Controller 不得再把客户端 / 作用域的 factory 塞进查询 DTO。
  118. /// 留着 <c>q.FactoryId = ...</c> 就是给「以后有人在 Service 端把它读回去」留门。
  119. /// </summary>
  120. [Theory]
  121. [InlineData("Controllers/S8/AdoS8ExceptionsController.cs")]
  122. [InlineData("Controllers/S8/AdoS8ConfigDetectionLogsController.cs")]
  123. [InlineData("Controllers/S8/AdoS8ConfigNotificationLogsController.cs")]
  124. [InlineData("Controllers/S8/AdoS8IssueLedgerController.cs")]
  125. [InlineData("Controllers/S8/AdoS8AlertAggregationController.cs")]
  126. public void T6_Controllers_DoNotStampFactoryOntoQuery(string src)
  127. {
  128. var code = CodeOnly(src);
  129. Assert.DoesNotContain("q.FactoryId = ", code);
  130. Assert.DoesNotContain("scope.FactoryId", code);
  131. }
  132. /// <summary>
  133. /// T7:查询 DTO 的 FactoryId 保留(老前端兼容),但必须是**死字段**。
  134. /// 保留字段本身没问题,被重新读起来才是问题。
  135. /// </summary>
  136. [Fact]
  137. public void T7_QueryDtoFactoryField_SurvivesButIsInert()
  138. {
  139. Assert.NotNull(typeof(AdoS8ExceptionQueryDto).GetProperty("FactoryId"));
  140. Assert.NotNull(typeof(AdoS8ExceptionQueryDto).GetProperty("TenantId"));
  141. // Service 侧不得读回来。
  142. Assert.DoesNotContain("q.FactoryId", CodeOnly("Service/S8/S8ExceptionService.cs"));
  143. }
  144. /// <summary>T8:异常子资源的作用域校验入口收敛为 (exceptionId, tenantId)。</summary>
  145. [Fact]
  146. public void T8_ExceptionScopeGuard_IsTenantOnly()
  147. {
  148. Assert.NotNull(typeof(S8DecisionService).GetMethod(
  149. nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long)]));
  150. Assert.Null(typeof(S8DecisionService).GetMethod(
  151. nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long), typeof(long)]));
  152. }
  153. // ───────────────── T9–T12:任务流 ─────────────────
  154. /// <summary>T9:全部按 Id 的流转动作都必须是 (id, tenantId, …),不得再带 factoryId。</summary>
  155. [Theory]
  156. [InlineData(nameof(S8TaskFlowService.ClaimAsync))]
  157. [InlineData(nameof(S8TaskFlowService.TransferAsync))]
  158. [InlineData(nameof(S8TaskFlowService.StartProgressAsync))]
  159. [InlineData(nameof(S8TaskFlowService.UpgradeAsync))]
  160. [InlineData(nameof(S8TaskFlowService.RejectAsync))]
  161. [InlineData(nameof(S8TaskFlowService.SubmitVerificationAsync))]
  162. [InlineData(nameof(S8TaskFlowService.ApproveVerificationAsync))]
  163. [InlineData(nameof(S8TaskFlowService.RejectVerificationAsync))]
  164. [InlineData(nameof(S8TaskFlowService.CommentAsync))]
  165. public void T9_TaskFlowActions_TakeIdAndTenantOnly(string method)
  166. {
  167. var overloads = typeof(S8TaskFlowService)
  168. .GetMethods(BindingFlags.Instance | BindingFlags.Public)
  169. .Where(m => m.Name == method)
  170. .ToList();
  171. Assert.NotEmpty(overloads);
  172. foreach (var m in overloads)
  173. {
  174. var ps = m.GetParameters();
  175. Assert.Equal(typeof(long), ps[0].ParameterType); // id
  176. Assert.Equal(typeof(long), ps[1].ParameterType); // tenantId
  177. Assert.Equal("tenantId", ps[1].Name);
  178. Assert.DoesNotContain(ps, p =>
  179. p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  180. }
  181. }
  182. /// <summary>
  183. /// T10:认领 / 转派解析处理人所属部门的硬边界必须是租户。
  184. /// 按工厂解析会在真库跨租户(factory_ref_id=1000 横跨两个租户),
  185. /// 于是「认领本租户异常」可能把另一租户的部门 RecID 写进 responsible_dept_id。
  186. /// </summary>
  187. [Fact]
  188. public void T10_ResponsibleDeptResolution_IsTenantBound()
  189. {
  190. var m = typeof(S8TaskFlowService).GetMethod("ResolveEmployeeResponsibleDeptIdAsync", Instance);
  191. Assert.NotNull(m);
  192. Assert.Equal(new[] { "assigneeId", "tenantId" }, m!.GetParameters().Select(p => p.Name).ToArray());
  193. var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
  194. Assert.Contains("x.Id == assigneeId && x.TenantId == tenantId", code);
  195. Assert.DoesNotContain("x.FactoryRefId == factoryId", code);
  196. }
  197. /// <summary>T11:绑行查询只按 (Id, TenantId, !IsDeleted)。</summary>
  198. [Fact]
  199. public void T11_TaskFlowLoad_BindsByTenantOnly()
  200. {
  201. var load = typeof(S8TaskFlowService).GetMethod("LoadAsync", Instance);
  202. Assert.NotNull(load);
  203. Assert.Equal(new[] { "id", "tenantId" }, load!.GetParameters().Select(p => p.Name).ToArray());
  204. Assert.Contains("x.Id == id && x.TenantId == tenantId && !x.IsDeleted",
  205. CodeOnly("Service/S8/S8TaskFlowService.cs"));
  206. }
  207. /// <summary>T12:SLA 自动升级按异常自身租户调用,不再传工厂。</summary>
  208. [Fact]
  209. public void T12_AutoEscalation_CallsUpgradeWithTenantOnly()
  210. {
  211. var code = CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
  212. Assert.Contains("_taskFlow.UpgradeAsync(e.Id, e.TenantId, remark)", code);
  213. Assert.DoesNotContain("e.FactoryId", code);
  214. }
  215. // ───────────────── T13–T15:卡死扫描(本批最易静默失效处) ─────────────────
  216. /// <summary>
  217. /// T13:卡死扫描的枚举维度是租户;<c>factory_id &gt; 0</c> 谓词必须消失。
  218. /// 留着它,本批之后(factory_id 恒 0)该扫描的候选集恒为空,
  219. /// 而 job 照常运行、日志照常打印——最难发现的那种失效。
  220. /// </summary>
  221. [Fact]
  222. public void T13_ActiveFlowScan_EnumeratesTenants_AndDropsFactoryFilter()
  223. {
  224. var code = CodeOnly("Service/S8/S8ActiveFlowWatchService.cs");
  225. Assert.Contains("SELECT DISTINCT e.tenant_id", code);
  226. Assert.DoesNotContain("e.factory_id > 0", code);
  227. Assert.DoesNotContain("e.factory_id AS FactoryId", code);
  228. var list = typeof(S8ActiveFlowWatchService).GetMethod(
  229. nameof(S8ActiveFlowWatchService.ListActiveTenantsAsync));
  230. Assert.NotNull(list);
  231. Assert.Equal(typeof(Task<List<long>>), list!.ReturnType);
  232. }
  233. /// <summary>T14:ScanAsync 只按租户分片。</summary>
  234. [Fact]
  235. public void T14_ActiveFlowScan_ScansByTenant()
  236. {
  237. var scan = typeof(S8ActiveFlowWatchService).GetMethod(nameof(S8ActiveFlowWatchService.ScanAsync));
  238. Assert.NotNull(scan);
  239. Assert.Equal("tenantId", scan!.GetParameters()[0].Name);
  240. Assert.DoesNotContain(scan.GetParameters(), p =>
  241. p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  242. }
  243. /// <summary>T15:(租户, 工厂) 复合作用域类型必须彻底消失,避免有人再拿它当分片键。</summary>
  244. [Fact]
  245. public void T15_TenantFactoryScopeType_IsRemoved()
  246. {
  247. Assert.Null(typeof(S8ActiveFlowWatchService).Assembly
  248. .GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8TenantFactoryScope"));
  249. Assert.DoesNotContain("S8TenantFactoryScope", CodeOnly("Job/S8ActiveFlowStuckScanJob.cs"));
  250. }
  251. // ───────────────── T16–T19:主数据(真实越权修复) ─────────────────
  252. /// <summary>T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。</summary>
  253. [Fact]
  254. public void T16_MasterDataDropdowns_AreTenantBound()
  255. {
  256. var code = CodeOnly("Service/S8/S8MasterDataAdapter.cs");
  257. Assert.DoesNotContain("FactoryRefId", code);
  258. Assert.Equal(3, CountOccurrences(code, "x.TenantId == scope.TenantId"));
  259. }
  260. /// <summary>T17:操作员绑定的员工硬边界同样收敛为租户。</summary>
  261. [Fact]
  262. public void T17_OperatorBinding_IsTenantBound()
  263. {
  264. var code = CodeOnly("Service/S8/S8OperatorBindingService.cs");
  265. Assert.Contains(".Where(x => x.TenantId == scope.TenantId)", code);
  266. Assert.DoesNotContain("x.FactoryRefId == scope.FactoryId", code);
  267. }
  268. /// <summary>T18:异常列表 / 详情的部门名水合按租户查,不按工厂。</summary>
  269. [Fact]
  270. public void T18_DepartmentHydration_IsTenantBound()
  271. {
  272. var code = CodeOnly("Service/S8/S8ExceptionService.cs");
  273. Assert.Contains("deptIds.Contains(x.Id) && x.TenantId == tenantId", code);
  274. Assert.DoesNotContain("x.FactoryRefId == factoryId", code);
  275. }
  276. /// <summary>
  277. /// T19:自动建单的部门校验按租户;「主数据在哪个工厂」这个三级解析必须整体消失。
  278. /// 它存在的唯一前提是「S8 运营工厂 ≠ 部门主数据工厂」,而工厂不再是作用域后该前提不成立。
  279. /// </summary>
  280. [Fact]
  281. public void T19_AutoWatchDeptResolution_DropsMasterDataFactoryIndirection()
  282. {
  283. var code = CodeOnly("Service/S8/S8ManualReportService.cs");
  284. Assert.DoesNotContain("ResolveDepartmentFactoryRefIdAsync", code);
  285. Assert.DoesNotContain("ValidateDeptForMasterDataFactoryAsync", code);
  286. Assert.DoesNotContain("_masterDataOptions.DepartmentFactoryRefId", code);
  287. Assert.Contains("ValidateDeptInTenantAsync", code);
  288. Assert.Contains("x.Department == UnassignedDepartmentCode && x.TenantId == tenantId", code);
  289. }
  290. // ───────────────── T20–T22:主动提报 / 自动建单写入 ─────────────────
  291. /// <summary>
  292. /// T20:主动提报的可信作用域只解析租户;原来「租户未配机构就拒绝建单」的门禁一并去掉——
  293. /// 它保护不了任何东西(工厂不是隔离维度),却能让一个租户完全无法提报。
  294. /// </summary>
  295. [Fact]
  296. public void T20_ManualReport_ResolvesTenantWithoutOrgPrecondition()
  297. {
  298. var code = CodeOnly("Service/S8/S8ManualReportService.cs");
  299. Assert.Contains("private long ResolveTrustedTenantId()", code);
  300. Assert.DoesNotContain("SysTenant>()", code);
  301. Assert.DoesNotContain("当前租户尚未配置所属机构", code);
  302. }
  303. /// <summary>T21:新建异常的 factory_id 一律盖章平台默认哨兵 0(既有行不重写)。</summary>
  304. [Fact]
  305. public void T21_NewExceptions_StampCompatibilityFactoryZero()
  306. {
  307. var code = CodeOnly("Service/S8/S8ManualReportService.cs");
  308. Assert.DoesNotContain("FactoryId = factoryId", code);
  309. Assert.DoesNotContain("FactoryId = trustedScope.FactoryId", code);
  310. Assert.Equal(3, CountOccurrences(code, "FactoryId = S8ConfigScope.GlobalFactoryId"));
  311. }
  312. /// <summary>T22:自动建单入口不再要求工厂上下文,且不接受 factoryId 形参。</summary>
  313. [Fact]
  314. public void T22_AutoCreationEntryPoints_RequireTenantOnly()
  315. {
  316. foreach (var name in new[]
  317. {
  318. nameof(S8ManualReportService.CreateFromWatchAsync),
  319. nameof(S8ManualReportService.CreateFromHitAsync),
  320. })
  321. {
  322. var m = typeof(S8ManualReportService).GetMethods(BindingFlags.Instance | BindingFlags.Public)
  323. .Single(x => x.Name == name);
  324. Assert.Equal("tenantId", m.GetParameters()[0].Name);
  325. Assert.DoesNotContain(m.GetParameters(), p =>
  326. p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  327. }
  328. Assert.DoesNotContain("自动建单缺失有效租户或工厂上下文",
  329. CodeOnly("Service/S8/S8ManualReportService.cs"));
  330. }
  331. // ───────────────── T23–T25:配置读取(平台默认哨兵必须存活) ─────────────────
  332. /// <summary>
  333. /// T23:配置读取判据收敛为「本租户 ∪ 平台默认」,工厂等值必须消失,
  334. /// 但 <c>(0,0)</c> 平台默认哨兵**不得被误删**——它不是工厂隔离,是平台基线。
  335. ///
  336. /// <para><c>S8SceneConfigService</c> 不在列:<c>ado_s8_scene_config</c> 本就没有平台默认行
  337. /// (实测 29 行全部是租户行,<c>tenant_id = 0</c> 的 0 行),它的读取一直是纯租户精确匹配。
  338. /// 把它一起断言"必须有哨兵"会逼出一个库里根本不存在的概念。</para>
  339. /// </summary>
  340. [Theory]
  341. [InlineData("Service/S8/S8ExceptionTypeService.cs")]
  342. [InlineData("Service/S8/S8NotificationLayerService.cs")]
  343. [InlineData("Service/S8/S8RoleConfigService.cs")]
  344. [InlineData("Service/S8/S8DashboardCellConfigService.cs")]
  345. [InlineData("Service/S8/S8KpiTargetConfigService.cs")]
  346. public void T23_ConfigReads_AreTenantScoped_ButKeepPlatformSentinel(string src)
  347. {
  348. var code = CodeOnly(src);
  349. Assert.DoesNotContain("x.FactoryId == factoryId", code);
  350. Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
  351. Assert.DoesNotContain("x.FactoryId == body.FactoryId", code);
  352. Assert.DoesNotContain("x.FactoryId == e.FactoryId", code);
  353. // 哨兵语义还在(IsGlobal / GlobalTenantId / GlobalFactoryId 至少出现其一)。
  354. Assert.True(
  355. code.Contains("S8ConfigScope.GlobalTenantId", StringComparison.Ordinal)
  356. || code.Contains("S8ConfigScope.IsGlobal", StringComparison.Ordinal)
  357. || code.Contains("S8ConfigScope.GlobalFactoryId", StringComparison.Ordinal),
  358. $"{src} 丢失了平台默认哨兵语义");
  359. }
  360. /// <summary>
  361. /// T24:覆盖优先级不得再按 <c>FactoryId</c> 降序判定。
  362. ///
  363. /// <para>这是本批最容易被写对一半的地方:谓词改成了租户,precedence 却留着
  364. /// <c>OrderByDescending(x =&gt; x.FactoryId)</c>。本批之后租户覆盖行的 factory_id 恒为 0,
  365. /// 与平台默认相等,排序退化 → 平台默认可能反超租户覆盖,
  366. /// 表现是「页面显示的生效配置与运行时真正用的不是同一条」。</para>
  367. /// </summary>
  368. [Theory]
  369. [InlineData("Service/S8/S8ExceptionTypeService.cs")]
  370. [InlineData("Service/S8/S8RoleConfigService.cs")]
  371. [InlineData("Service/S8/S8NotificationLayerService.cs")]
  372. [InlineData("Service/S8/S8DashboardCellConfigService.cs")]
  373. [InlineData("Service/S8/S8MonitorDictionaryService.cs")]
  374. [InlineData("Service/S8/S8MonitoringService.cs")]
  375. [InlineData("Service/S8/S8ManualReportService.cs")]
  376. public void T24_ConfigPrecedence_NoLongerRanksByFactoryId(string src)
  377. {
  378. var code = CodeOnly(src);
  379. Assert.DoesNotContain("OrderByDescending(x => x.FactoryId)", code);
  380. Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code);
  381. }
  382. /// <summary>T25:配置写入把 factory_id 盖章为兼容值 0,绝不写回一个真实工厂号。</summary>
  383. [Theory]
  384. [InlineData("Service/S8/S8ExceptionTypeService.cs")]
  385. [InlineData("Service/S8/S8SceneConfigService.cs")]
  386. [InlineData("Service/S8/S8NotificationLayerService.cs")]
  387. [InlineData("Service/S8/S8RoleConfigService.cs")]
  388. [InlineData("Service/S8/S8DashboardCellConfigService.cs")]
  389. public void T25_ConfigWrites_StampCompatibilityFactory(string src)
  390. {
  391. var code = CodeOnly(src);
  392. Assert.DoesNotContain("body.FactoryId = scope.FactoryId", code);
  393. Assert.Contains("scope.LegacyFactoryId", code);
  394. }
  395. // ───────────────── T26–T28:迁移脚本 ─────────────────
  396. /// <summary>
  397. /// T26:唯一键在实体与迁移脚本两侧必须同步收敛,且**只**收敛唯一键。
  398. /// 只改一侧的后果不对称:只改实体 → CodeFirst 环境与迁移环境的 schema 分叉;
  399. /// 只改脚本 → 下次 EnableInitTable 启动会把旧唯一键加回来。
  400. /// </summary>
  401. [Fact]
  402. public void T26_ExceptionUniqueKey_ConvergesInEntityAndMigration()
  403. {
  404. var entity = CodeOnly("Entity/S8/AdoS8Exception.cs");
  405. Assert.Contains("\"uk_ado_s8_exception_tenant_code\", nameof(TenantId), OrderByType.Asc, nameof(ExceptionCode)", entity);
  406. Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", entity);
  407. var sql = ExecutableSql(MigrationSql());
  408. Assert.Contains("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code (tenant_id, exception_code)", sql);
  409. Assert.Contains("DROP INDEX uk_ado_s8_exception_tenant_factory_code", sql);
  410. }
  411. /// <summary>
  412. /// T27:迁移必须先门禁后 DDL,且门禁失败要能在 <c>sys_db_migration_log.error_message</c> 里读出原因。
  413. /// 没有 preflight 的收紧唯一键 = 撞上历史重号时报一句索引错误,运维无从下手。
  414. /// </summary>
  415. [Fact]
  416. public void T27_Migration_GatesBeforeDdl()
  417. {
  418. var sql = ExecutableSql(MigrationSql());
  419. var gate = sql.IndexOf("s8_tenant_only_BLOCKED_see_dupcode_table_1_0_494", StringComparison.Ordinal);
  420. var ddl = sql.IndexOf("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code", StringComparison.Ordinal);
  421. Assert.True(gate >= 0, "缺少 preflight 门禁");
  422. Assert.True(ddl > gate, "门禁必须在任何 DDL 之前");
  423. Assert.Contains("s8_tenant_only_POSTCHECK_FAILED_1_0_494", sql);
  424. // AutoVersionUpdate 对全文做子串匹配拒绝自定义语句分隔符指令——连注释里出现都会被拦。
  425. Assert.DoesNotContain("DELIMITER", MigrationSql(), StringComparison.OrdinalIgnoreCase);
  426. }
  427. /// <summary>
  428. /// T28:迁移刻意不碰的对象。
  429. /// <c>factory_id</c> 列、历史行的 factory_id 值、以及前三个已冻结脚本的归档表都必须原样保留——
  430. /// 本批移除的是隔离语义,不是 schema 美化,更不是把历史血缘抹平。
  431. /// </summary>
  432. [Fact]
  433. public void T28_Migration_DoesNotTouchOutOfScopeObjects()
  434. {
  435. // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,那正是我们想要的留档。
  436. var sql = ExecutableSql(MigrationSql());
  437. Assert.DoesNotContain("DROP COLUMN", sql);
  438. Assert.DoesNotContain("UPDATE ado_s8_exception", sql);
  439. Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql);
  440. Assert.DoesNotContain("ado_s8_exception_dedup_bak_1_0_493", sql);
  441. Assert.DoesNotContain("ado_s8_scene_config", sql);
  442. Assert.DoesNotContain("ado_s8_notification_layer", sql);
  443. Assert.DoesNotContain("ado_s8_watch_rule", sql);
  444. }
  445. private static string ExecutableSql(string sql) =>
  446. string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
  447. private static string MigrationSql()
  448. {
  449. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  450. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
  451. dir = dir.Parent;
  452. Assert.NotNull(dir);
  453. var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.494.sql");
  454. Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}");
  455. return File.ReadAllText(path);
  456. }
  457. private static int CountOccurrences(string haystack, string needle)
  458. {
  459. var n = 0;
  460. for (var i = haystack.IndexOf(needle, StringComparison.Ordinal);
  461. i >= 0;
  462. i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
  463. n++;
  464. return n;
  465. }
  466. }