package role import ( "errors" "testing" "time" "perms-system-server/internal/consts" roleModel "perms-system-server/internal/model/role" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/testutil/mocks" "perms-system-server/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/sqlx" "go.uber.org/mock/gomock" ) // TC-0120: 正常更新 func TestUpdateRole_Normal(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() pc := testutil.UniqueId() res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) }) newName := testutil.UniqueId() logic := NewUpdateRoleLogic(ctx, svcCtx) err = logic.UpdateRole(&types.UpdateRoleReq{ Id: roleId, Name: newName, Remark: "updated remark", PermsLevel: 2, Status: 2, }) require.NoError(t, err) updated, err := svcCtx.SysRoleModel.FindOne(ctx, roleId) require.NoError(t, err) assert.Equal(t, newName, updated.Name) assert.Equal(t, "updated remark", updated.Remark) assert.Equal(t, int64(2), updated.PermsLevel) assert.Equal(t, int64(2), updated.Status) } // TC-0121: 不存在 func TestUpdateRole_NotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateRoleLogic(ctx, svcCtx) err := logic.UpdateRole(&types.UpdateRoleReq{ Id: 999999999, Name: "whatever", PermsLevel: 1, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 404, ce.Code()) assert.Equal(t, "角色不存在", ce.Error()) } // TC-0539: updateRole非管理员拒绝 func TestUpdateRole_MemberRejected(t *testing.T) { pc := "test_product" ctx := ctxhelper.MemberCtx(pc) svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := roleRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) }) logic := NewUpdateRoleLogic(ctx, svcCtx) err = logic.UpdateRole(&types.UpdateRoleReq{Id: roleId, Name: "test", PermsLevel: 1}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // 覆盖目标:事务已 COMMIT 成功后, // 任何缓存清理路径的失败只应记 Errorf,不得把 degraded 成功映射成 5xx 让客户端误触发重试。 // adminCtx helper 定义于 bindRolePermsLogic_test.go (同 package role)。 // TC-1119: L-R14-1 非超管 UpdateRole 访问别产品的 roleId 必须返回 404 "角色不存在", // 与 RoleDetail 的 M-N3 口径一致,消除 404 vs 403 的跨产品 roleId 枚举 oracle。 func TestUpdateRole_L_R14_1_CrossProductReturns404(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() otherProduct := "l_r14_1_upd_" + testutil.UniqueId() res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: otherProduct, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) }) adminCtx := ctxhelper.AdminCtx("test_product") err = NewUpdateRoleLogic(adminCtx, svcCtx).UpdateRole(&types.UpdateRoleReq{ Id: roleId, Name: "should_not_update", PermsLevel: 1, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 404, ce.Code(), "L-R14-1:跨产品 roleId 必须 404,不得以 403 暴露存在性") assert.Equal(t, "角色不存在", ce.Error(), "L-R14-1:文案必须与 'id 不存在' 完全一致,彻底消除枚举 oracle") // DB 不得被污染 after, err := svcCtx.SysRoleModel.FindOne(ctx, roleId) require.NoError(t, err) assert.NotEqual(t, "should_not_update", after.Name, "跨产品被拒绝的请求不得对 DB 产生任何副作用") } // TC-0859: UpdateRole —— UpdateWithOptLock 成功,FindUserIdsByRoleId 失败,handler 返回 nil。 func TestUpdateRole_PostCommitUserIdsError_StaysSuccess(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) roleMock := mocks.NewMockSysRoleModel(ctrl) urMock := mocks.NewMockSysUserRoleModel(ctrl) roleMock.EXPECT().FindOne(gomock.Any(), int64(9)). Return(&roleModel.SysRole{ Id: 9, ProductCode: "pc_m4u", Name: "before", PermsLevel: 50, Status: consts.StatusEnabled, UpdateTime: 100, }, nil) // UpdateWithOptLock 成功;签名:UpdateWithOptLock(ctx, role, prevUpdateTime)。 roleMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(100)).Return(nil) // 审计 H-R18-1:rename(before→after) 触发 InvalidateRoleCache(prevName),属于 post-commit 尽力而为, // 无返回值,mock 仅注册期望、不校验结果——符合"缓存失效失败只记日志"的语义。 roleMock.EXPECT().InvalidateRoleCache(gomock.Any(), int64(9), "pc_m4u", "before") // 关键断言:post-commit transient err 不应导致 handler 失败。 urMock.EXPECT().FindUserIdsByRoleId(gomock.Any(), int64(9)). Return(nil, errors.New("boom")) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Role: roleMock, UserRole: urMock, }) err := NewUpdateRoleLogic(adminCtx("pc_m4u"), svcCtx).UpdateRole(&types.UpdateRoleReq{ Id: 9, Name: "after", Remark: "r", PermsLevel: 60, Status: 0, }) assert.NoError(t, err, "UpdateRole 已提交成功,post-commit 缓存失败只记日志,handler 必须返回 nil") } // TC-1204: UpdateRole 重命名后旧 name 索引缓存必须失效。 // UpdateWithOptLock 内部只失效新 name 键;rename 路径的旧 name 键必须在 post-commit 由 // InvalidateRoleCache(prevName) 显式清除,否则 Redis TTL 窗口内同名并发创建会命中幽灵快照。 func TestUpdateRole_RenameInvalidatesOldNameCache(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() pc := testutil.UniqueId() pid := mustInsertEnabledProduct(t, ctx, svcCtx, pc) oldName := "old_" + testutil.UniqueId() roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: pc, Name: oldName, Status: consts.StatusEnabled, PermsLevel: 10, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := roleRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) testutil.CleanTable(ctx, conn, "`sys_product`", pid) }) // 第一次查询,让 sysRole:productCode:name 索引键写入 Redis 缓存。 _, err = svcCtx.SysRoleModel.FindOneByProductCodeName(ctx, pc, oldName) require.NoError(t, err) newName := "new_" + testutil.UniqueId() require.NoError(t, NewUpdateRoleLogic(ctx, svcCtx).UpdateRole(&types.UpdateRoleReq{ Id: roleId, Name: newName, Remark: "renamed", PermsLevel: 10, })) // 重命名后,旧 name 对应的 Redis 缓存键必须失效,否则 FindOneByProductCodeName 仍会返回旧行。 _, err = svcCtx.SysRoleModel.FindOneByProductCodeName(ctx, pc, oldName) require.Error(t, err, "rename 后旧 name 索引缓存键必须被 InvalidateRoleCache(prevName) 清除;"+ "若残留则 FindOneByProductCodeName 会返回已改名的旧行,形成幽灵快照") require.True(t, errors.Is(err, sqlx.ErrNotFound), "旧 name 缓存失效后,FindOneByProductCodeName 应返回 ErrNotFound 而非旧行") // 新 name 应能正常查询到。 found, err := svcCtx.SysRoleModel.FindOneByProductCodeName(ctx, pc, newName) require.NoError(t, err) assert.Equal(t, roleId, found.Id) assert.Equal(t, newName, found.Name) }