S8CanonicalRoleOptionsTests.cs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. using System.Reflection;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  6. /// <summary>
  7. /// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1 的回归锁。
  8. ///
  9. /// <para>缺陷本体:<c>GetRoleOptionsAsync</c> 曾按**默认租户的物理 SysRole.Id** 段筛选,
  10. /// 于是任何非默认租户的角色候选恒为空。修复后必须按 **canonical Role Code** 在可信租户
  11. /// 作用域内筛选。本文件把「Code 是逻辑身份、Id 是租户本地物理身份」这条契约钉死。</para>
  12. /// </summary>
  13. public class S8CanonicalRoleOptionsTests
  14. {
  15. // 真实数据形态:同一 Code 在每个租户各有一行,Id 互不相同。
  16. private const long DefaultTenantOrderPlannerId = 1329908000101L; // 默认租户
  17. private const long UatTenantOrderPlannerId = 838257188253765L; // UAT 租户
  18. private const long ThirdTenantOrderPlannerId = 797403760988229L; // 第三租户
  19. // ── canonical 定义本身 ────────────────────────────────────────────────
  20. /// <summary>canonical 集合必须正好是这 7 个业务角色码,顺序即产品展示序。</summary>
  21. [Fact]
  22. public void Canonical_IsExactlyTheSevenBusinessRoles()
  23. {
  24. Assert.Equal(
  25. new[]
  26. {
  27. "ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_PURCHASER",
  28. "ROLE_WH_INBOUND", "ROLE_WH_OUTBOUND", "ROLE_QC", "ROLE_EQUIP_MAINT",
  29. },
  30. S8CanonicalRole.BusinessRoleCodes);
  31. }
  32. /// <summary>
  33. /// <c>sys_admin</c> 是平台系统角色,不在 S8 业务角色集内——
  34. /// 它不被任何一条生效通知层引用,不得混进租户的角色候选。
  35. /// </summary>
  36. [Fact]
  37. public void Canonical_DoesNotContainPlatformSysAdmin()
  38. {
  39. Assert.DoesNotContain("sys_admin", S8CanonicalRole.BusinessRoleCodes);
  40. }
  41. /// <summary>canonical 集合只能承载**逻辑身份**:任何 Code 都不得长得像物理 Id。</summary>
  42. [Fact]
  43. public void Canonical_CarriesNoTenantSpecificIdentity()
  44. {
  45. foreach (var code in S8CanonicalRole.BusinessRoleCodes)
  46. Assert.False(long.TryParse(code, out _), $"canonical code '{code}' 不得是数字物理 Id");
  47. }
  48. /// <summary>两个既有定义必须与 canonical 同源,否则又会人工分叉。</summary>
  49. [Fact]
  50. public void Canonical_IsTheSingleSourceForTenantProvisioning()
  51. {
  52. var field = typeof(Admin.NET.Plugin.AiDOP.Infrastructure.AidopTenantInitService)
  53. .GetProperty("S8BusinessRoles", BindingFlags.NonPublic | BindingFlags.Static)!;
  54. var roles = ((string Code, string Name, int OrderNo)[])field.GetValue(null)!;
  55. Assert.Same(S8CanonicalRole.BusinessRoles, roles);
  56. }
  57. // ── 核心回归:非默认租户 ──────────────────────────────────────────────
  58. /// <summary>
  59. /// **本缺陷的核心回归锁**:同一个 Code 在默认租户是 Id=A、在非默认租户是 Id=B。
  60. /// 可信作用域过滤后只剩 B,整形必须原样保留 B——绝不能出现 A。
  61. /// </summary>
  62. [Fact]
  63. public void NonDefaultTenant_YieldsTenantLocalRow_NotDefaultTenantRow()
  64. {
  65. // 可信租户过滤后,仓储只会返回本租户那一行。
  66. var scoped = new[] { (UatTenantOrderPlannerId, "ROLE_ORDER_PLANNER", "订单计划员") };
  67. var result = S8NotificationLayerService.ShapeRoleOptions(scoped);
  68. var only = Assert.Single(result);
  69. Assert.Equal(UatTenantOrderPlannerId, only.Id);
  70. Assert.NotEqual(DefaultTenantOrderPlannerId, only.Id);
  71. Assert.Equal("ROLE_ORDER_PLANNER", only.Code);
  72. }
  73. /// <summary>
  74. /// 跨租户负测:即便三个租户各有同 Code 的行,可信作用域只会交出当前租户那一行;
  75. /// 整形不得把其它租户的 Id 混进来。
  76. /// </summary>
  77. [Fact]
  78. public void CrossTenant_NoLeakage_OnlyCurrentTenantRowSurvives()
  79. {
  80. var scoped = new[] { (UatTenantOrderPlannerId, "ROLE_ORDER_PLANNER", "订单计划员") };
  81. var ids = S8NotificationLayerService.ShapeRoleOptions(scoped).Select(x => x.Id).ToList();
  82. Assert.DoesNotContain(DefaultTenantOrderPlannerId, ids);
  83. Assert.DoesNotContain(ThirdTenantOrderPlannerId, ids);
  84. Assert.Equal(new[] { UatTenantOrderPlannerId }, ids);
  85. }
  86. /// <summary>
  87. /// 筛选依据必须是 Code,不是物理 Id:候选行的 Id 与默认租户 Id 段毫无关系时,
  88. /// 依然必须被完整选出(旧实现在这里会返回空)。
  89. /// </summary>
  90. [Fact]
  91. public void Selection_IsByCode_NotByPhysicalIdRange()
  92. {
  93. var scoped = S8CanonicalRole.BusinessRoles
  94. .Select((r, i) => (Id: 900_000_000_000L + i, r.Code, r.Name))
  95. .ToArray();
  96. var result = S8NotificationLayerService.ShapeRoleOptions(scoped);
  97. Assert.Equal(7, result.Count);
  98. Assert.All(result, r => Assert.True(r.Id >= 900_000_000_000L));
  99. Assert.All(result, r => Assert.False(
  100. r.Id >= 1329908000101L && r.Id <= 1329908000107L, "不得出现默认租户物理 Id"));
  101. }
  102. // ── 排序 / 缺失 / 重复 ────────────────────────────────────────────────
  103. /// <summary>排序由 canonical OrderNo 决定,不由物理 Id 决定——即便 Id 顺序完全颠倒。</summary>
  104. [Fact]
  105. public void Ordering_FollowsCanonicalOrder_NotPhysicalId()
  106. {
  107. var reversed = S8CanonicalRole.BusinessRoles
  108. .Select((r, i) => (Id: 999L - i, r.Code, r.Name)) // Id 与 canonical 序相反
  109. .ToArray();
  110. var codes = S8NotificationLayerService.ShapeRoleOptions(reversed).Select(x => x.Code);
  111. Assert.Equal(S8CanonicalRole.BusinessRoleCodes, codes);
  112. }
  113. /// <summary>
  114. /// 缺失行为:canonical 白名单里某个 Code 在当前租户不存在时,
  115. /// 只是**不出现这一项**——不回落默认租户、不自动建角色、不抛异常。
  116. /// </summary>
  117. [Fact]
  118. public void MissingCanonicalRole_IsSimplyOmitted_NoFallbackNoThrow()
  119. {
  120. var partial = new[]
  121. {
  122. (10L, "ROLE_QC", "质检员"),
  123. (11L, "ROLE_PURCHASER", "采购员"),
  124. };
  125. var result = S8NotificationLayerService.ShapeRoleOptions(partial);
  126. Assert.Equal(new[] { "ROLE_PURCHASER", "ROLE_QC" }, result.Select(x => x.Code)); // 802 < 805
  127. Assert.DoesNotContain(result, x => x.Code == "ROLE_ORDER_PLANNER");
  128. }
  129. /// <summary>空作用域(该租户一个 canonical 角色都没有)返回空列表,而不是异常。</summary>
  130. [Fact]
  131. public void EmptyScope_ReturnsEmpty_NotThrow()
  132. {
  133. Assert.Empty(S8NotificationLayerService.ShapeRoleOptions(
  134. Array.Empty<(long, string, string)>()));
  135. }
  136. /// <summary>
  137. /// 重复 Code(DB 无唯一索引,属已登记技术债)必须**确定性**收敛为一项,
  138. /// 且取 Id 最小者——保证同一份数据每次给出同一个候选。
  139. /// </summary>
  140. [Fact]
  141. public void DuplicateCode_CollapsesDeterministically_ToLowestId()
  142. {
  143. var dup = new[]
  144. {
  145. (77L, "ROLE_QC", "质检员-B"),
  146. (55L, "ROLE_QC", "质检员-A"),
  147. };
  148. var only = Assert.Single(S8NotificationLayerService.ShapeRoleOptions(dup));
  149. Assert.Equal(55L, only.Id);
  150. Assert.Equal("质检员-A", only.Name);
  151. }
  152. /// <summary>整形对同一输入必须幂等且顺序稳定(跑两次结果逐项相同)。</summary>
  153. [Fact]
  154. public void Shaping_IsStableAcrossInvocations()
  155. {
  156. var rows = S8CanonicalRole.BusinessRoles
  157. .Select((r, i) => (Id: 500L + i, r.Code, r.Name)).ToArray();
  158. Assert.Equal(
  159. S8NotificationLayerService.ShapeRoleOptions(rows),
  160. S8NotificationLayerService.ShapeRoleOptions(rows));
  161. }
  162. }