|
@@ -0,0 +1,192 @@
|
|
|
|
|
+using System.Reflection;
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
|
|
+using Xunit;
|
|
|
|
|
+
|
|
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1 的回归锁。
|
|
|
|
|
+///
|
|
|
|
|
+/// <para>缺陷本体:<c>GetRoleOptionsAsync</c> 曾按**默认租户的物理 SysRole.Id** 段筛选,
|
|
|
|
|
+/// 于是任何非默认租户的角色候选恒为空。修复后必须按 **canonical Role Code** 在可信租户
|
|
|
|
|
+/// 作用域内筛选。本文件把「Code 是逻辑身份、Id 是租户本地物理身份」这条契约钉死。</para>
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class S8CanonicalRoleOptionsTests
|
|
|
|
|
+{
|
|
|
|
|
+ // 真实数据形态:同一 Code 在每个租户各有一行,Id 互不相同。
|
|
|
|
|
+ private const long DefaultTenantOrderPlannerId = 1329908000101L; // 默认租户
|
|
|
|
|
+ private const long UatTenantOrderPlannerId = 838257188253765L; // UAT 租户
|
|
|
|
|
+ private const long ThirdTenantOrderPlannerId = 797403760988229L; // 第三租户
|
|
|
|
|
+
|
|
|
|
|
+ // ── canonical 定义本身 ────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>canonical 集合必须正好是这 7 个业务角色码,顺序即产品展示序。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Canonical_IsExactlyTheSevenBusinessRoles()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Equal(
|
|
|
|
|
+ new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_PURCHASER",
|
|
|
|
|
+ "ROLE_WH_INBOUND", "ROLE_WH_OUTBOUND", "ROLE_QC", "ROLE_EQUIP_MAINT",
|
|
|
|
|
+ },
|
|
|
|
|
+ S8CanonicalRole.BusinessRoleCodes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// <c>sys_admin</c> 是平台系统角色,不在 S8 业务角色集内——
|
|
|
|
|
+ /// 它不被任何一条生效通知层引用,不得混进租户的角色候选。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Canonical_DoesNotContainPlatformSysAdmin()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.DoesNotContain("sys_admin", S8CanonicalRole.BusinessRoleCodes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>canonical 集合只能承载**逻辑身份**:任何 Code 都不得长得像物理 Id。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Canonical_CarriesNoTenantSpecificIdentity()
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var code in S8CanonicalRole.BusinessRoleCodes)
|
|
|
|
|
+ Assert.False(long.TryParse(code, out _), $"canonical code '{code}' 不得是数字物理 Id");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>两个既有定义必须与 canonical 同源,否则又会人工分叉。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Canonical_IsTheSingleSourceForTenantProvisioning()
|
|
|
|
|
+ {
|
|
|
|
|
+ var field = typeof(Admin.NET.Plugin.AiDOP.Infrastructure.AidopTenantInitService)
|
|
|
|
|
+ .GetProperty("S8BusinessRoles", BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
|
|
|
+ var roles = ((string Code, string Name, int OrderNo)[])field.GetValue(null)!;
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Same(S8CanonicalRole.BusinessRoles, roles);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 核心回归:非默认租户 ──────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// **本缺陷的核心回归锁**:同一个 Code 在默认租户是 Id=A、在非默认租户是 Id=B。
|
|
|
|
|
+ /// 可信作用域过滤后只剩 B,整形必须原样保留 B——绝不能出现 A。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void NonDefaultTenant_YieldsTenantLocalRow_NotDefaultTenantRow()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 可信租户过滤后,仓储只会返回本租户那一行。
|
|
|
|
|
+ var scoped = new[] { (UatTenantOrderPlannerId, "ROLE_ORDER_PLANNER", "订单计划员") };
|
|
|
|
|
+
|
|
|
|
|
+ var result = S8NotificationLayerService.ShapeRoleOptions(scoped);
|
|
|
|
|
+
|
|
|
|
|
+ var only = Assert.Single(result);
|
|
|
|
|
+ Assert.Equal(UatTenantOrderPlannerId, only.Id);
|
|
|
|
|
+ Assert.NotEqual(DefaultTenantOrderPlannerId, only.Id);
|
|
|
|
|
+ Assert.Equal("ROLE_ORDER_PLANNER", only.Code);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 跨租户负测:即便三个租户各有同 Code 的行,可信作用域只会交出当前租户那一行;
|
|
|
|
|
+ /// 整形不得把其它租户的 Id 混进来。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void CrossTenant_NoLeakage_OnlyCurrentTenantRowSurvives()
|
|
|
|
|
+ {
|
|
|
|
|
+ var scoped = new[] { (UatTenantOrderPlannerId, "ROLE_ORDER_PLANNER", "订单计划员") };
|
|
|
|
|
+
|
|
|
|
|
+ var ids = S8NotificationLayerService.ShapeRoleOptions(scoped).Select(x => x.Id).ToList();
|
|
|
|
|
+
|
|
|
|
|
+ Assert.DoesNotContain(DefaultTenantOrderPlannerId, ids);
|
|
|
|
|
+ Assert.DoesNotContain(ThirdTenantOrderPlannerId, ids);
|
|
|
|
|
+ Assert.Equal(new[] { UatTenantOrderPlannerId }, ids);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 筛选依据必须是 Code,不是物理 Id:候选行的 Id 与默认租户 Id 段毫无关系时,
|
|
|
|
|
+ /// 依然必须被完整选出(旧实现在这里会返回空)。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Selection_IsByCode_NotByPhysicalIdRange()
|
|
|
|
|
+ {
|
|
|
|
|
+ var scoped = S8CanonicalRole.BusinessRoles
|
|
|
|
|
+ .Select((r, i) => (Id: 900_000_000_000L + i, r.Code, r.Name))
|
|
|
|
|
+ .ToArray();
|
|
|
|
|
+
|
|
|
|
|
+ var result = S8NotificationLayerService.ShapeRoleOptions(scoped);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(7, result.Count);
|
|
|
|
|
+ Assert.All(result, r => Assert.True(r.Id >= 900_000_000_000L));
|
|
|
|
|
+ Assert.All(result, r => Assert.False(
|
|
|
|
|
+ r.Id >= 1329908000101L && r.Id <= 1329908000107L, "不得出现默认租户物理 Id"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 排序 / 缺失 / 重复 ────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>排序由 canonical OrderNo 决定,不由物理 Id 决定——即便 Id 顺序完全颠倒。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Ordering_FollowsCanonicalOrder_NotPhysicalId()
|
|
|
|
|
+ {
|
|
|
|
|
+ var reversed = S8CanonicalRole.BusinessRoles
|
|
|
|
|
+ .Select((r, i) => (Id: 999L - i, r.Code, r.Name)) // Id 与 canonical 序相反
|
|
|
|
|
+ .ToArray();
|
|
|
|
|
+
|
|
|
|
|
+ var codes = S8NotificationLayerService.ShapeRoleOptions(reversed).Select(x => x.Code);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(S8CanonicalRole.BusinessRoleCodes, codes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 缺失行为:canonical 白名单里某个 Code 在当前租户不存在时,
|
|
|
|
|
+ /// 只是**不出现这一项**——不回落默认租户、不自动建角色、不抛异常。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void MissingCanonicalRole_IsSimplyOmitted_NoFallbackNoThrow()
|
|
|
|
|
+ {
|
|
|
|
|
+ var partial = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ (10L, "ROLE_QC", "质检员"),
|
|
|
|
|
+ (11L, "ROLE_PURCHASER", "采购员"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var result = S8NotificationLayerService.ShapeRoleOptions(partial);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(new[] { "ROLE_PURCHASER", "ROLE_QC" }, result.Select(x => x.Code)); // 802 < 805
|
|
|
|
|
+ Assert.DoesNotContain(result, x => x.Code == "ROLE_ORDER_PLANNER");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>空作用域(该租户一个 canonical 角色都没有)返回空列表,而不是异常。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void EmptyScope_ReturnsEmpty_NotThrow()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Empty(S8NotificationLayerService.ShapeRoleOptions(
|
|
|
|
|
+ Array.Empty<(long, string, string)>()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 重复 Code(DB 无唯一索引,属已登记技术债)必须**确定性**收敛为一项,
|
|
|
|
|
+ /// 且取 Id 最小者——保证同一份数据每次给出同一个候选。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void DuplicateCode_CollapsesDeterministically_ToLowestId()
|
|
|
|
|
+ {
|
|
|
|
|
+ var dup = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ (77L, "ROLE_QC", "质检员-B"),
|
|
|
|
|
+ (55L, "ROLE_QC", "质检员-A"),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var only = Assert.Single(S8NotificationLayerService.ShapeRoleOptions(dup));
|
|
|
|
|
+ Assert.Equal(55L, only.Id);
|
|
|
|
|
+ Assert.Equal("质检员-A", only.Name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>整形对同一输入必须幂等且顺序稳定(跑两次结果逐项相同)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Shaping_IsStableAcrossInvocations()
|
|
|
|
|
+ {
|
|
|
|
|
+ var rows = S8CanonicalRole.BusinessRoles
|
|
|
|
|
+ .Select((r, i) => (Id: 500L + i, r.Code, r.Name)).ToArray();
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Equal(
|
|
|
|
|
+ S8NotificationLayerService.ShapeRoleOptions(rows),
|
|
|
|
|
+ S8NotificationLayerService.ShapeRoleOptions(rows));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|