package user import ( "context" "errors" "testing" "time" "perms-system-server/internal/consts" "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 L-N4 修复 —— UpdateUserStatus 必须带 expectedUpdateTime 乐观锁, // 否则两个管理员并发冻结/解冻会 last-write-wins,tokenVersion 被连续 +2 / 刚解冻又踢下线。 // 本端到端测试通过"前置一次旁路 UPDATE 推进 updateTime"来模拟"被他人修改过", // 然后触发 UpdateUserStatus 必须以 409 "数据已被其他操作修改,请刷新后重试" 失败。 // --------------------------------------------------------------------------- // TC-1011: L-N4 —— 调用者读到用户后,他人已把该用户 updateTime 推进过; // UpdateUserStatus 必须返回 409,且用户最终状态保持为"他人已改过"的那次结果, // 本次调用者的预期变更不得覆盖(last-write-wins 被关闭)。 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, 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。要触发 L-N4 的 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, consts.StatusEnabled, orig.UpdateTime) require.Error(t, errConf) // 这里拿到的是 ErrUpdateConflict;Logic 层负责包装成 409。 require.Contains(t, errConf.Error(), "conflict") // (2) Logic 层断言:正确传 midway.UpdateTime 仍然可正常解冻(正向回归),保证 L-N4 不回归 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, "L-N4 happy path:Logic 内部会自行 FindOne 最新 UpdateTime,必须能正常解冻") cur, err := svcCtx.SysUserModel.FindOne(bootstrap, userId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusEnabled), cur.Status, "L-N4:正向解冻必须真实落盘") assert.Greater(t, cur.UpdateTime, midway.UpdateTime, "L-N4:updateTime 必须推进以维持后续乐观锁有效") } // TC-1012: L-N4 —— 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, 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(), "L-N4:ErrUpdateConflict 必须被映射为 409 Conflict,不得静默丢失") assert.Equal(t, "数据已被其他操作修改,请刷新后重试", ce.Error()) }