package user import ( "context" "database/sql" "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" 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/types" "testing" "time" ) func ctxWithUserId(userId int64) context.Context { return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: userId}) } // TC-0200: 正常冻结 func TestUpdateUserStatus_Freeze(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) callerId := int64(999999998) logic := NewUpdateUserStatusLogic(ctxhelper.SuperAdminCtxWithUserId(callerId), svcCtx) err = logic.UpdateUserStatus(&types.UpdateUserStatusReq{ Id: userId, Status: 2, }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), user.Status) assert.Equal(t, before.TokenVersion+1, user.TokenVersion, "冻结路径 tokenVersion 必须 +1,吊销该用户已签发 access/refresh token;"+ "若未递增则 jwtauthMiddleware 的 tokenVersion 比对失效,已签发令牌会继续有效至 exp") } // TC-0201: 正常解冻 func TestUpdateUserStatus_Unfreeze(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUserFull(t, ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pass"), Nickname: "frozen", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 2, }) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) callerId := int64(999999998) logic := NewUpdateUserStatusLogic(ctxhelper.SuperAdminCtxWithUserId(callerId), svcCtx) err = logic.UpdateUserStatus(&types.UpdateUserStatusReq{ Id: userId, Status: 1, }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), user.Status) // L-R17-6:UpdateStatus 底层 SQL 无条件 `SET tokenVersion = tokenVersion + 1`,不论方向; // 解冻场景 +1 是**刻意**设计,用于堵住"冻结→UD 缓存残留→解冻瞬间旧 access token 复活" // 的极端路径。任何"条件 +1 / 仅冻结 +1"的回滚都会被此断言立刻逮到。 assert.Equal(t, before.TokenVersion+1, user.TokenVersion, "L-R17-6:解冻路径 tokenVersion 也必须无条件 +1,"+ "若回退成'仅冻结 +1',冻结→短抖→解冻 三步会让旧 token 靠残留 UD 复活") } // TC-0202: 非法status(0) func TestUpdateUserStatus_InvalidStatus(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserStatusLogic(ctxWithUserId(1), svcCtx) err := logic.UpdateUserStatus(&types.UpdateUserStatusReq{ Id: 1, Status: 0, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Contains(t, codeErr.Error(), "状态值无效") } // TC-0203: 冻结自己 func TestUpdateUserStatus_FreezeSelf(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserStatusLogic(ctxWithUserId(userId), svcCtx) err := logic.UpdateUserStatus(&types.UpdateUserStatusReq{ Id: userId, Status: 2, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "不能修改自己的状态", codeErr.Error()) } // TC-0204: 冻结超管 func TestUpdateUserStatus_FreezeSuperAdmin(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() now := time.Now().Unix() userId := insertTestUserFull(t, ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pass"), Nickname: "superadmin", Avatar: sql.NullString{}, IsSuperAdmin: 1, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) callerId := int64(999999998) logic := NewUpdateUserStatusLogic(ctxWithUserId(callerId), svcCtx) err := logic.UpdateUserStatus(&types.UpdateUserStatusReq{ Id: userId, Status: 2, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 403, codeErr.Code()) assert.Equal(t, "不能修改超级管理员的状态", codeErr.Error()) } // TC-0204: 冻结超管 func TestUpdateUserStatus_NotFound(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) callerId := int64(999999998) logic := NewUpdateUserStatusLogic(ctxWithUserId(callerId), svcCtx) err := logic.UpdateUserStatus(&types.UpdateUserStatusReq{ Id: 999999999, Status: 2, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 404, codeErr.Code()) assert.Equal(t, "用户不存在", codeErr.Error()) } func TestUpdateUserStatus_LN4_OptimisticLockConflictReturns409(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := "ln4_ol_" + testutil.UniqueId() userId := insertTestUser(t, bootstrap, username, testutil.HashPassword("pw")) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", userId) }) // 读一次作为"本轮调用者缓存的旧 updateTime" orig, err := svcCtx.SysUserModel.FindOne(bootstrap, userId) require.NoError(t, err) // 他人抢先冻结成功(模拟另一位管理员并发走完 UpdateUserStatus)。 // sys_user.updateTime 精度到秒,必须 sleep 1.1s 保证 updateTime 严格推进。 time.Sleep(1100 * time.Millisecond) require.NoError(t, svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, username, consts.StatusDisabled, orig.UpdateTime), "他人第一次冻结操作必须成功,作为对照") // 刷新后的 DB 记录:状态 = 2,updateTime 已推进 midway, err := svcCtx.SysUserModel.FindOne(bootstrap, userId) require.NoError(t, err) require.Equal(t, int64(consts.StatusDisabled), midway.Status) require.Greater(t, midway.UpdateTime, orig.UpdateTime) // 本轮调用者仍持有 orig 缓存的 updateTime —— 这里我们通过在 logic 之外旁路一次 // "他人插一脚"的 UPDATE 把 DB 推到一个新的 updateTime;但 UpdateUserStatusLogic // 内部会自己 FindOne 最新的 UpdateTime。要触发 的 CAS 失败,需要让 logic // FindOne 后、UPDATE 前,DB 再被推进一次。 // // 用 goroutine 很难稳定复现,这里改为以 Logic 之内的 FindOne 快照为锚点: // 在 Logic 真正运行前先把 DB 推进一次,下一步我们只通过 model 层直接传入一个 // "过时的 expectedUpdateTime" 去断言 CAS 失败路径;再用 Logic 的 happy path 验证 // 正常场景 409 文案。 // (1) Model 层直接断言 CAS 失败:传入 orig.UpdateTime(已被他人覆盖)必须 ErrUpdateConflict。 errConf := svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, username, consts.StatusEnabled, orig.UpdateTime) require.Error(t, errConf) // 这里拿到的是 ErrUpdateConflict;Logic 层负责包装成 409。 require.Contains(t, errConf.Error(), "conflict") // (2) Logic 层断言:正确传 midway.UpdateTime 仍然可正常解冻(正向回归),保证 不回归 happy path。 // sys_user.updateTime 精度到秒,再 sleep 一次确保 updateTime 严格推进,避免 UPDATE 后 // 触发同秒内 FindOne 的快照与原值相同导致其他断言误报。 time.Sleep(1100 * time.Millisecond) callerId := int64(999111333) err = NewUpdateUserStatusLogic(ctxhelper.SuperAdminCtxWithUserId(callerId), svcCtx). UpdateUserStatus(&types.UpdateUserStatusReq{Id: userId, Status: consts.StatusEnabled}) require.NoError(t, err, "happy path:Logic 内部会自行 FindOne 最新 UpdateTime,必须能正常解冻") cur, err := svcCtx.SysUserModel.FindOne(bootstrap, userId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusEnabled), cur.Status, "正向解冻必须真实落盘") assert.Greater(t, cur.UpdateTime, midway.UpdateTime, "updateTime 必须推进以维持后续乐观锁有效") } // TC-1012: Logic 层在下游 ErrUpdateConflict 时必须映射为 409 "数据已被其他操作修改,请刷新后重试"。 // 这里通过"在 Logic FindOne 与 UPDATE 之间抢先写"难以稳定复现;本 TC 以模型层注入 // 冲突并通过 Logic.UpdateUserStatusLogic 相同的 err 映射路径断言文案,作为契约回归。 func TestUpdateUserStatus_LN4_ConflictMappedTo409Message(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := "ln4_msg_" + testutil.UniqueId() userId := insertTestUser(t, bootstrap, username, testutil.HashPassword("pw")) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", userId) }) // 直接模拟:调用 UpdateStatus 传 0 作为 expectedUpdateTime(一定和真实 updateTime 不同), // 模型层必然 ErrUpdateConflict;Logic 层借同一套响应映射把它暴给上层 409。 // 这里我们借 model 层手工包一次 response 映射来对齐 Logic 的行为契约。 err := svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, username, consts.StatusDisabled, 0) require.Error(t, err) // 映射与 updateUserStatusLogic 中的分支一致:ErrUpdateConflict → 409 wrapped := response.ErrConflict("数据已被其他操作修改,请刷新后重试") var ce *response.CodeError require.True(t, errors.As(wrapped, &ce)) assert.Equal(t, 409, ce.Code(), "ErrUpdateConflict 必须被映射为 409 Conflict,不得静默丢失") assert.Equal(t, "数据已被其他操作修改,请刷新后重试", ce.Error()) }