package dept import ( "context" "database/sql" "errors" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" userModel "perms-system-server/internal/model/user" "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" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) func TestUpdateDept_Normal(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, err := insertDeptRaw(ctx, svcCtx, 0, "upd_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) newName := "updated_" + testutil.UniqueId() l := NewUpdateDeptLogic(ctx, svcCtx) err = l.UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: newName, Sort: 99, Remark: "updated remark", Status: 2, }) require.NoError(t, err) d, err := svcCtx.SysDeptModel.FindOne(ctx, deptId) require.NoError(t, err) assert.Equal(t, newName, d.Name) assert.Equal(t, int64(99), d.Sort) assert.Equal(t, "updated remark", d.Remark) assert.Equal(t, int64(2), d.Status) } // TC-0102: 不存在 func TestUpdateDept_NotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) l := NewUpdateDeptLogic(ctx, svcCtx) err := l.UpdateDept(&types.UpdateDeptReq{ Id: 999999999, Name: "ghost_" + testutil.UniqueId(), }) 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-0101: 正常更新 func TestUpdateDept_StatusZeroUnchanged(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, err := insertDeptRaw(ctx, svcCtx, 0, "s0_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId) require.NoError(t, err) assert.Equal(t, int64(1), before.Status) l := NewUpdateDeptLogic(ctx, svcCtx) err = l.UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "changed_" + testutil.UniqueId(), Status: 0, }) require.NoError(t, err) after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId) require.NoError(t, err) assert.Equal(t, int64(1), after.Status) } // TC-0103: DeptType NORMAL→DEV func TestUpdateDept_DeptType_NormalToDev(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, err := insertDeptRaw(ctx, svcCtx, 0, "dt_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId) require.NoError(t, err) assert.Equal(t, "NORMAL", before.DeptType) l := NewUpdateDeptLogic(ctx, svcCtx) err = l.UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "dt_changed_" + testutil.UniqueId(), DeptType: "DEV", }) require.NoError(t, err) after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId) require.NoError(t, err) assert.Equal(t, "DEV", after.DeptType) } // TC-0104: DeptType无效值返回错误 func TestUpdateDept_DeptType_InvalidRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, err := insertDeptRaw(ctx, svcCtx, 0, "dti_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) l := NewUpdateDeptLogic(ctx, svcCtx) err = l.UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "dti_changed_" + testutil.UniqueId(), DeptType: "INVALID_TYPE", }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "部门类型无效") after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId) require.NoError(t, err) assert.Equal(t, "NORMAL", after.DeptType, "无效DeptType不应修改数据库") } // TC-0533: updateDept非超管拒绝 func TestUpdateDept_NonSuperAdminRejected(t *testing.T) { ctx := ctxhelper.AdminCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) l := NewUpdateDeptLogic(ctx, svcCtx) err := l.UpdateDept(&types.UpdateDeptReq{Id: 1, Name: "test"}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } func superAdminCtx() context.Context { return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 1, Username: "su", IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled, }) } // TC-0848: deptType 变更 → FindIdsByDeptId 恰好调用 1 次,返回 [100, 101];handler 返回 nil。 // NORMAL→DEV 方向是放宽权限,不吊销会话;post-commit 仍需 FindIdsByDeptId 清 UD 聚合缓存。 func TestUpdateDept_DeptTypeChanged_InvokesFindIdsOnce(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) userMock := mocks.NewMockSysUserModel(ctrl) // 老部门是 NORMAL,请求改成 DEV → deptTypeChanged=true 才能触发 FindIdsByDeptId。 deptMock.EXPECT().FindOne(gomock.Any(), int64(77)). Return(&deptModel.SysDept{ Id: 77, Name: "n", DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled, UpdateTime: 500, }, nil) runDeptTxInline(deptMock) deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(500)).Return(nil) deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(77)) // 关键断言:post-commit 恰好调用 1 次;返回的切片会被继续塞进 CleanByUserIds(loader 内部走 Redis)。 userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(77)). Return([]int64{100, 101}, nil).Times(1) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Dept: deptMock, User: userMock, }) err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: 77, Name: "n", Sort: 0, Remark: "", DeptType: consts.DeptTypeDev, }) require.NoError(t, err, "正常场景 UpdateDept 必须返回 nil,且仅调用一次 FindIdsByDeptId") } // TC-0849: FindIdsByDeptId 返回 err —— handler 仍返回 nil(degraded 成功),旧缓存由 TTL 过期兜底。 func TestUpdateDept_FindIdsByDeptIdError_DegradedSuccess(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) userMock := mocks.NewMockSysUserModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), int64(88)). Return(&deptModel.SysDept{ Id: 88, Name: "n", DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled, UpdateTime: 1000, }, nil) runDeptTxInline(deptMock) deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(1000)).Return(nil) deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(88)) userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(88)). Return(nil, errors.New("transient DB error")) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Dept: deptMock, User: userMock, }) err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: 88, Name: "n", DeptType: consts.DeptTypeDev, }) assert.NoError(t, err, "FindIdsByDeptId 失败不得映射 500;TTL 过期兜底,客户端不应重试整次 UpdateDept") } // seedDeptWithUser 建一个自定义 DeptType/Status 的部门,并挂一个新 sys_user 到该部门下。 // 返回 (deptId, userId);统一 cleanup 由调用方负责,避免测试之间相互拖拽。 func seedDeptWithUser(t *testing.T, svcCtx *svc.ServiceContext, tag, path, deptType string, status int64) (int64, int64) { t.Helper() bootstrap := ctxhelper.SuperAdminCtx() now := time.Now().Unix() dRes, err := svcCtx.SysDeptModel.Insert(bootstrap, &deptModel.SysDept{ ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: deptType, Status: status, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) deptId, _ := dRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(bootstrap, &userModel.SysUser{ Username: tag + "_u_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, DeptId: deptId, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() return deptId, userId } // TC-1174: UpdateDept DeptType: DEV → NORMAL 必须把该部门下**所有**在编 sys_user 的 // tokenVersion 原子性 +1(签发层吊销),否则 5min TTL 内旧 access token 仍享有 DEV 全权。 func TestUpdateDept_L_R16_2_DevTypeToNormal_RevokesAllMembers(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev2norm", "/7100/", consts.DeptTypeDev, consts.StatusEnabled) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) prev := before.TokenVersion require.NoError(t, NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "renamed_" + testutil.UniqueId(), DeptType: consts.DeptTypeNormal, })) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, prev+1, after.TokenVersion, "DEV→NORMAL 让部门内所有人的 loadPerms 从全权分支掉到普通分支,tokenVersion 必须同事务 +1") } // TC-1175: DEV 部门 Status Enabled → Disabled —— 同样构成 DEV 全权收窄,tokenVersion 必须 +1。 func TestUpdateDept_L_R16_2_DevStatusToDisabled_RevokesAllMembers(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev_dis", "/7200/", consts.DeptTypeDev, consts.StatusEnabled) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) prev := before.TokenVersion require.NoError(t, NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "dev_disabled_" + testutil.UniqueId(), DeptType: consts.DeptTypeDev, Status: consts.StatusDisabled, })) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, prev+1, after.TokenVersion, "DEV 部门 Enabled→Disabled 与 DeptType DEV→NORMAL 同构,都让 loadPerms 全权分支失效") } // TC-1176: NORMAL 部门 Status Enabled → Disabled —— 业务语义是"冻结本部门所有活动", // 必须把部门内成员的 tokenVersion 同事务 +1,防止 5min TTL 窗口内旧 token 继续读写。 func TestUpdateDept_L_R16_2_NormalStatusToDisabled_RevokesAllMembers(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm_dis", "/7300/", consts.DeptTypeNormal, consts.StatusEnabled) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) prev := before.TokenVersion require.NoError(t, NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "norm_disabled_" + testutil.UniqueId(), DeptType: consts.DeptTypeNormal, Status: consts.StatusDisabled, })) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, prev+1, after.TokenVersion, "NORMAL 部门 Enabled→Disabled 是冻结动作,部门成员的 tokenVersion 必须 +1") } // TC-1177: NORMAL→DEV 是放宽权限的升级方向,tokenVersion 保持不变;把合法会话无故下线 // 不仅没收益,还会让产品侧管理员需要重新登录 + 重新建会话,损害可用性。 func TestUpdateDept_L_R16_2_NormalToDev_NoRevoke(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm2dev", "/7400/", consts.DeptTypeNormal, consts.StatusEnabled) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) prev := before.TokenVersion require.NoError(t, NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "norm2dev_" + testutil.UniqueId(), DeptType: consts.DeptTypeDev, })) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, prev, after.TokenVersion, "NORMAL→DEV 是升权方向,不得把现存会话吊销;否则每次类型调整都会触发一次大规模强制下线") } // TC-1178: 部门 Status Disabled→Enabled(恢复启用)同样是升权方向,tokenVersion 必须保持不变。 func TestUpdateDept_L_R16_2_StatusReEnable_NoRevoke(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_reenable", "/7500/", consts.DeptTypeNormal, consts.StatusDisabled) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) prev := before.TokenVersion require.NoError(t, NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: deptId, Name: "reenable_" + testutil.UniqueId(), DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled, })) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, prev, after.TokenVersion, "Disabled→Enabled 是恢复活动,不是收窄方向;tokenVersion 必须保持不变") } // deptType / status 都没变时,不应调 FindIdsByDeptId(避免无效缓存失效风暴);dept 自身行 // 的 InvalidateDeptCache 仍需调用,因为 sys_dept 的 updateTime/name/remark/sort 已经被 UPDATE。 func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) userMock := mocks.NewMockSysUserModel(ctrl) // 老部门 DEV,请求也是 DEV;status 未传 → 没有变更。 deptMock.EXPECT().FindOne(gomock.Any(), int64(99)). Return(&deptModel.SysDept{ Id: 99, Name: "x", DeptType: consts.DeptTypeDev, Status: consts.StatusEnabled, UpdateTime: 200, }, nil) runDeptTxInline(deptMock) deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(200)).Return(nil) deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(99)) // 关键:没有任何 FindIdsByDeptId / FindIdsByDeptIdForShareTx EXPECT 即等价 Times(0)。 svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Dept: deptMock, User: userMock, }) err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{ Id: 99, Name: "x", DeptType: consts.DeptTypeDev, Sort: 1, }) require.NoError(t, err, "无变更时 UpdateDept 只做元字段更新,不得触发缓存清理风暴") }