S8TenantOnlyExceptionScopeTests.cs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  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:认领 / 转派<b>不再</b>自动改写责任部门。
  184. ///
  185. /// <para>原实现是 <c>Employee.Department(字符串) → DepartmentMaster</c>,
  186. /// 本批(S8-SYSUSER-ONLY-1)把处理人改成系统账号后这条链的第一跳没有了 ——
  187. /// <c>SysUser</c> 侧不存在指向 <c>DepartmentMaster</c> 的关系
  188. /// (<c>SysUser.OrgId</c> 指的是 <c>SysOrg</c> 公司/工厂树,是另一棵树)。</para>
  189. ///
  190. /// <para>原断言守的是「这条解析别按工厂做」。解析本身没了,守卫对象改为
  191. /// <b>别用 SysOrg 硬凑一个近似值把它复活</b> —— 责任部门由建单时确定,
  192. /// 之后只能由人显式修改,不再随处理人静默漂移。</para>
  193. /// </summary>
  194. [Fact]
  195. public void T10_ResponsibleDeptIsNotSilentlyRewrittenOnAssignment()
  196. {
  197. Assert.Null(typeof(S8TaskFlowService).GetMethod("ResolveEmployeeResponsibleDeptIdAsync", Instance));
  198. var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
  199. Assert.DoesNotContain("ResponsibleDeptId =", code);
  200. Assert.DoesNotContain("SysOrg", code);
  201. }
  202. /// <summary>T11:绑行查询只按 (Id, TenantId, !IsDeleted)。</summary>
  203. [Fact]
  204. public void T11_TaskFlowLoad_BindsByTenantOnly()
  205. {
  206. var load = typeof(S8TaskFlowService).GetMethod("LoadAsync", Instance);
  207. Assert.NotNull(load);
  208. Assert.Equal(new[] { "id", "tenantId" }, load!.GetParameters().Select(p => p.Name).ToArray());
  209. Assert.Contains("x.Id == id && x.TenantId == tenantId && !x.IsDeleted",
  210. CodeOnly("Service/S8/S8TaskFlowService.cs"));
  211. }
  212. /// <summary>T12:SLA 自动升级按异常自身租户调用,不再传工厂。</summary>
  213. [Fact]
  214. public void T12_AutoEscalation_CallsUpgradeWithTenantOnly()
  215. {
  216. var code = CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
  217. Assert.Contains("_taskFlow.UpgradeAsync(e.Id, e.TenantId, remark)", code);
  218. Assert.DoesNotContain("e.FactoryId", code);
  219. }
  220. // ───────────────── T13–T15:卡死扫描(本批最易静默失效处) ─────────────────
  221. /// <summary>
  222. /// T13:卡死扫描的枚举维度是租户;<c>factory_id &gt; 0</c> 谓词必须消失。
  223. /// 留着它,本批之后(factory_id 恒 0)该扫描的候选集恒为空,
  224. /// 而 job 照常运行、日志照常打印——最难发现的那种失效。
  225. /// </summary>
  226. [Fact]
  227. public void T13_ActiveFlowScan_EnumeratesTenants_AndDropsFactoryFilter()
  228. {
  229. var code = CodeOnly("Service/S8/S8ActiveFlowWatchService.cs");
  230. Assert.Contains("SELECT DISTINCT e.tenant_id", code);
  231. Assert.DoesNotContain("e.factory_id > 0", code);
  232. Assert.DoesNotContain("e.factory_id AS FactoryId", code);
  233. var list = typeof(S8ActiveFlowWatchService).GetMethod(
  234. nameof(S8ActiveFlowWatchService.ListActiveTenantsAsync));
  235. Assert.NotNull(list);
  236. Assert.Equal(typeof(Task<List<long>>), list!.ReturnType);
  237. }
  238. /// <summary>T14:ScanAsync 只按租户分片。</summary>
  239. [Fact]
  240. public void T14_ActiveFlowScan_ScansByTenant()
  241. {
  242. var scan = typeof(S8ActiveFlowWatchService).GetMethod(nameof(S8ActiveFlowWatchService.ScanAsync));
  243. Assert.NotNull(scan);
  244. Assert.Equal("tenantId", scan!.GetParameters()[0].Name);
  245. Assert.DoesNotContain(scan.GetParameters(), p =>
  246. p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  247. }
  248. /// <summary>T15:(租户, 工厂) 复合作用域类型必须彻底消失,避免有人再拿它当分片键。</summary>
  249. [Fact]
  250. public void T15_TenantFactoryScopeType_IsRemoved()
  251. {
  252. Assert.Null(typeof(S8ActiveFlowWatchService).Assembly
  253. .GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8TenantFactoryScope"));
  254. Assert.DoesNotContain("S8TenantFactoryScope", CodeOnly("Job/S8ActiveFlowStuckScanJob.cs"));
  255. }
  256. // ───────────────── T16–T19:主数据(真实越权修复) ─────────────────
  257. /// <summary>
  258. /// T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。
  259. ///
  260. /// <para>S8-SYSUSER-ONLY-1 之后人员下拉改列 <c>SysUser</c>(谓词写作
  261. /// <c>u.TenantId == scope.TenantId</c>),因此计数由 3 个 <c>x.</c> 变为
  262. /// 2 个 <c>x.</c> + 1 个 <c>u.</c>。断言总数仍是 3 —— 三个下拉,一个都不能缺边界。</para>
  263. /// </summary>
  264. [Fact]
  265. public void T16_MasterDataDropdowns_AreTenantBound()
  266. {
  267. var code = CodeOnly("Service/S8/S8MasterDataAdapter.cs");
  268. Assert.DoesNotContain("FactoryRefId", code);
  269. Assert.Equal(3,
  270. CountOccurrences(code, "x.TenantId == scope.TenantId")
  271. + CountOccurrences(code, "u.TenantId == scope.TenantId"));
  272. }
  273. /// <summary>
  274. /// T17:操作员绑定已在 S8-SYSUSER-ONLY-1 整体退役,断言随之改为
  275. /// <b>该服务不得复活</b>。
  276. ///
  277. /// <para>原断言检查的是「员工硬边界从工厂收敛为租户」。那道边界现在没有对象可保护了 ——
  278. /// S8 不再把员工绑定成处理人。真正需要守的变成:这个能写 <c>EmployeeMaster</c> 的
  279. /// 写路径不要被重新引回来。删掉断言等于放弃这道守卫,因此改判而不是删除。</para>
  280. /// </summary>
  281. [Fact]
  282. public void T17_OperatorBinding_IsRetiredNotResurrected()
  283. {
  284. Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")));
  285. var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs");
  286. Assert.Contains("Status410Gone", code);
  287. Assert.DoesNotContain("EmployeeMaster", code);
  288. }
  289. /// <summary>T18:异常列表 / 详情的部门名水合按租户查,不按工厂。</summary>
  290. [Fact]
  291. public void T18_DepartmentHydration_IsTenantBound()
  292. {
  293. var code = CodeOnly("Service/S8/S8ExceptionService.cs");
  294. Assert.Contains("deptIds.Contains(x.Id) && x.TenantId == tenantId", code);
  295. Assert.DoesNotContain("x.FactoryRefId == factoryId", code);
  296. }
  297. /// <summary>
  298. /// T19:自动建单的部门校验按租户;「主数据在哪个工厂」这个三级解析必须整体消失。
  299. /// 它存在的唯一前提是「S8 运营工厂 ≠ 部门主数据工厂」,而工厂不再是作用域后该前提不成立。
  300. /// </summary>
  301. [Fact]
  302. public void T19_AutoWatchDeptResolution_DropsMasterDataFactoryIndirection()
  303. {
  304. var code = CodeOnly("Service/S8/S8ManualReportService.cs");
  305. Assert.DoesNotContain("ResolveDepartmentFactoryRefIdAsync", code);
  306. Assert.DoesNotContain("ValidateDeptForMasterDataFactoryAsync", code);
  307. Assert.DoesNotContain("_masterDataOptions.DepartmentFactoryRefId", code);
  308. Assert.Contains("ValidateDeptInTenantAsync", code);
  309. Assert.Contains("x.Department == UnassignedDepartmentCode && x.TenantId == tenantId", code);
  310. }
  311. // ───────────────── T20–T22:主动提报 / 自动建单写入 ─────────────────
  312. /// <summary>
  313. /// T20:主动提报的可信作用域只解析租户;原来「租户未配机构就拒绝建单」的门禁一并去掉——
  314. /// 它保护不了任何东西(工厂不是隔离维度),却能让一个租户完全无法提报。
  315. /// </summary>
  316. [Fact]
  317. public void T20_ManualReport_ResolvesTenantWithoutOrgPrecondition()
  318. {
  319. var code = CodeOnly("Service/S8/S8ManualReportService.cs");
  320. Assert.Contains("private long ResolveTrustedTenantId()", code);
  321. Assert.DoesNotContain("SysTenant>()", code);
  322. Assert.DoesNotContain("当前租户尚未配置所属机构", code);
  323. }
  324. /// <summary>T21:新建异常的 factory_id 一律盖章平台默认哨兵 0(既有行不重写)。</summary>
  325. [Fact]
  326. public void T21_NewExceptions_StampCompatibilityFactoryZero()
  327. {
  328. var code = CodeOnly("Service/S8/S8ManualReportService.cs");
  329. Assert.DoesNotContain("FactoryId = factoryId", code);
  330. Assert.DoesNotContain("FactoryId = trustedScope.FactoryId", code);
  331. Assert.Equal(3, CountOccurrences(code, "FactoryId = S8ConfigScope.GlobalFactoryId"));
  332. }
  333. /// <summary>T22:自动建单入口不再要求工厂上下文,且不接受 factoryId 形参。</summary>
  334. [Fact]
  335. public void T22_AutoCreationEntryPoints_RequireTenantOnly()
  336. {
  337. foreach (var name in new[]
  338. {
  339. nameof(S8ManualReportService.CreateFromWatchAsync),
  340. nameof(S8ManualReportService.CreateFromHitAsync),
  341. })
  342. {
  343. var m = typeof(S8ManualReportService).GetMethods(BindingFlags.Instance | BindingFlags.Public)
  344. .Single(x => x.Name == name);
  345. Assert.Equal("tenantId", m.GetParameters()[0].Name);
  346. Assert.DoesNotContain(m.GetParameters(), p =>
  347. p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  348. }
  349. Assert.DoesNotContain("自动建单缺失有效租户或工厂上下文",
  350. CodeOnly("Service/S8/S8ManualReportService.cs"));
  351. }
  352. // ───────────────── T23–T25:配置读取(平台默认哨兵必须存活) ─────────────────
  353. /// <summary>
  354. /// T23:配置读取判据收敛为「本租户 ∪ 平台默认」,工厂等值必须消失,
  355. /// 但 <c>(0,0)</c> 平台默认哨兵**不得被误删**——它不是工厂隔离,是平台基线。
  356. ///
  357. /// <para><c>S8SceneConfigService</c> 不在列:<c>ado_s8_scene_config</c> 本就没有平台默认行
  358. /// (实测 29 行全部是租户行,<c>tenant_id = 0</c> 的 0 行),它的读取一直是纯租户精确匹配。
  359. /// 把它一起断言"必须有哨兵"会逼出一个库里根本不存在的概念。</para>
  360. /// </summary>
  361. [Theory]
  362. [InlineData("Service/S8/S8ExceptionTypeService.cs")]
  363. [InlineData("Service/S8/S8NotificationLayerService.cs")]
  364. [InlineData("Service/S8/S8RoleConfigService.cs")]
  365. [InlineData("Service/S8/S8DashboardCellConfigService.cs")]
  366. [InlineData("Service/S8/S8KpiTargetConfigService.cs")]
  367. public void T23_ConfigReads_AreTenantScoped_ButKeepPlatformSentinel(string src)
  368. {
  369. var code = CodeOnly(src);
  370. Assert.DoesNotContain("x.FactoryId == factoryId", code);
  371. Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
  372. Assert.DoesNotContain("x.FactoryId == body.FactoryId", code);
  373. Assert.DoesNotContain("x.FactoryId == e.FactoryId", code);
  374. // 哨兵语义还在(IsGlobal / GlobalTenantId / GlobalFactoryId 至少出现其一)。
  375. Assert.True(
  376. code.Contains("S8ConfigScope.GlobalTenantId", StringComparison.Ordinal)
  377. || code.Contains("S8ConfigScope.IsGlobal", StringComparison.Ordinal)
  378. || code.Contains("S8ConfigScope.GlobalFactoryId", StringComparison.Ordinal),
  379. $"{src} 丢失了平台默认哨兵语义");
  380. }
  381. /// <summary>
  382. /// T24:覆盖优先级不得再按 <c>FactoryId</c> 降序判定。
  383. ///
  384. /// <para>这是本批最容易被写对一半的地方:谓词改成了租户,precedence 却留着
  385. /// <c>OrderByDescending(x =&gt; x.FactoryId)</c>。本批之后租户覆盖行的 factory_id 恒为 0,
  386. /// 与平台默认相等,排序退化 → 平台默认可能反超租户覆盖,
  387. /// 表现是「页面显示的生效配置与运行时真正用的不是同一条」。</para>
  388. /// </summary>
  389. [Theory]
  390. [InlineData("Service/S8/S8ExceptionTypeService.cs")]
  391. [InlineData("Service/S8/S8RoleConfigService.cs")]
  392. [InlineData("Service/S8/S8NotificationLayerService.cs")]
  393. [InlineData("Service/S8/S8DashboardCellConfigService.cs")]
  394. [InlineData("Service/S8/S8MonitorDictionaryService.cs")]
  395. [InlineData("Service/S8/S8MonitoringService.cs")]
  396. [InlineData("Service/S8/S8ManualReportService.cs")]
  397. public void T24_ConfigPrecedence_NoLongerRanksByFactoryId(string src)
  398. {
  399. var code = CodeOnly(src);
  400. Assert.DoesNotContain("OrderByDescending(x => x.FactoryId)", code);
  401. Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code);
  402. }
  403. /// <summary>T25:配置写入把 factory_id 盖章为兼容值 0,绝不写回一个真实工厂号。</summary>
  404. [Theory]
  405. [InlineData("Service/S8/S8ExceptionTypeService.cs")]
  406. [InlineData("Service/S8/S8SceneConfigService.cs")]
  407. [InlineData("Service/S8/S8NotificationLayerService.cs")]
  408. [InlineData("Service/S8/S8RoleConfigService.cs")]
  409. [InlineData("Service/S8/S8DashboardCellConfigService.cs")]
  410. public void T25_ConfigWrites_StampCompatibilityFactory(string src)
  411. {
  412. var code = CodeOnly(src);
  413. Assert.DoesNotContain("body.FactoryId = scope.FactoryId", code);
  414. Assert.Contains("scope.LegacyFactoryId", code);
  415. }
  416. // ───────────────── T26–T28:迁移脚本 ─────────────────
  417. /// <summary>
  418. /// T26:唯一键在实体与迁移脚本两侧必须同步收敛,且**只**收敛唯一键。
  419. /// 只改一侧的后果不对称:只改实体 → CodeFirst 环境与迁移环境的 schema 分叉;
  420. /// 只改脚本 → 下次 EnableInitTable 启动会把旧唯一键加回来。
  421. /// </summary>
  422. [Fact]
  423. public void T26_ExceptionUniqueKey_ConvergesInEntityAndMigration()
  424. {
  425. var entity = CodeOnly("Entity/S8/AdoS8Exception.cs");
  426. Assert.Contains("\"uk_ado_s8_exception_tenant_code\", nameof(TenantId), OrderByType.Asc, nameof(ExceptionCode)", entity);
  427. Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", entity);
  428. var sql = ExecutableSql(MigrationSql());
  429. Assert.Contains("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code (tenant_id, exception_code)", sql);
  430. Assert.Contains("DROP INDEX uk_ado_s8_exception_tenant_factory_code", sql);
  431. }
  432. /// <summary>
  433. /// T27:迁移必须先门禁后 DDL,且门禁失败要能在 <c>sys_db_migration_log.error_message</c> 里读出原因。
  434. /// 没有 preflight 的收紧唯一键 = 撞上历史重号时报一句索引错误,运维无从下手。
  435. /// </summary>
  436. [Fact]
  437. public void T27_Migration_GatesBeforeDdl()
  438. {
  439. var sql = ExecutableSql(MigrationSql());
  440. var gate = sql.IndexOf("s8_tenant_only_BLOCKED_see_dupcode_table_1_0_494", StringComparison.Ordinal);
  441. var ddl = sql.IndexOf("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code", StringComparison.Ordinal);
  442. Assert.True(gate >= 0, "缺少 preflight 门禁");
  443. Assert.True(ddl > gate, "门禁必须在任何 DDL 之前");
  444. Assert.Contains("s8_tenant_only_POSTCHECK_FAILED_1_0_494", sql);
  445. // AutoVersionUpdate 对全文做子串匹配拒绝自定义语句分隔符指令——连注释里出现都会被拦。
  446. Assert.DoesNotContain("DELIMITER", MigrationSql(), StringComparison.OrdinalIgnoreCase);
  447. }
  448. /// <summary>
  449. /// T28:迁移刻意不碰的对象。
  450. /// <c>factory_id</c> 列、历史行的 factory_id 值、以及前三个已冻结脚本的归档表都必须原样保留——
  451. /// 本批移除的是隔离语义,不是 schema 美化,更不是把历史血缘抹平。
  452. /// </summary>
  453. [Fact]
  454. public void T28_Migration_DoesNotTouchOutOfScopeObjects()
  455. {
  456. // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,那正是我们想要的留档。
  457. var sql = ExecutableSql(MigrationSql());
  458. Assert.DoesNotContain("DROP COLUMN", sql);
  459. Assert.DoesNotContain("UPDATE ado_s8_exception", sql);
  460. Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql);
  461. Assert.DoesNotContain("ado_s8_exception_dedup_bak_1_0_493", sql);
  462. Assert.DoesNotContain("ado_s8_scene_config", sql);
  463. Assert.DoesNotContain("ado_s8_notification_layer", sql);
  464. Assert.DoesNotContain("ado_s8_watch_rule", sql);
  465. }
  466. private static string ExecutableSql(string sql) =>
  467. string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
  468. private static string MigrationSql()
  469. {
  470. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  471. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
  472. dir = dir.Parent;
  473. Assert.NotNull(dir);
  474. var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.494.sql");
  475. Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}");
  476. return File.ReadAllText(path);
  477. }
  478. private static int CountOccurrences(string haystack, string needle)
  479. {
  480. var n = 0;
  481. for (var i = haystack.IndexOf(needle, StringComparison.Ordinal);
  482. i >= 0;
  483. i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
  484. n++;
  485. return n;
  486. }
  487. }