package user_test import ( "context" "testing" "time" "perms-system-server/internal/model/user" "perms-system-server/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // M-2 回归:UpdatePassword / UpdateStatus 必须校验 RowsAffected。 // // 旧实现的 bug:Model 层 UPDATE 语句使用 `conn.ExecCtx`,若行已被删除或并发被修改, // `affected=0`,但代码直接 `return nil`,让上层误判"改密成功 / 冻结成功"。 // 利用 FindOne 的二级缓存(Redis):删除数据库中的行但不清缓存 → // 后续 UpdatePassword/UpdateStatus 内部的 FindOne 命中 stale cache 仍返回用户, // 真实 UPDATE 落空 `affected=0`,新实现必须回 ErrUpdateConflict。 // // 如果测试跑挂说明 M-2 修复被回退,必须立即修复 Model 层而非改测试。 // TC-0924: UpdatePassword 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功 func TestSysUserModel_UpdatePassword_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "m2_pw_del_" + testutil.UniqueId() data := newTestSysUser(username, 1) res, err := m.Insert(ctx, data) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) }) // 触发 FindOne 填充二级缓存 (id-key + username-key),模拟 Loader 刚读过用户的场景 _, err = m.FindOne(ctx, id) require.NoError(t, err) _, err = m.FindOneByUsername(ctx, username) require.NoError(t, err) // 直接走原始 SQL 删除行,**绕过** Model 的缓存失效钩子——此时 Redis 里仍保留用户快照 _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id) require.NoError(t, err) // UpdatePassword 内部:FindOne 命中 stale cache 返回用户 → UPDATE WHERE id=? AND updateTime=? // 因为行已不存在,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。 err = m.UpdatePassword(ctx, id, "new_hashed_pw", 1) require.ErrorIs(t, err, user.ErrUpdateConflict, "M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密") } // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功 func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "m2_st_del_" + testutil.UniqueId() data := newTestSysUser(username, 1) res, err := m.Insert(ctx, data) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) }) _, err = m.FindOne(ctx, id) require.NoError(t, err) _, err = m.FindOneByUsername(ctx, username) require.NoError(t, err) _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id) require.NoError(t, err) // UpdateStatus 内部:FindOne 命中 stale cache → UPDATE WHERE id=? 仍 affected=0。 // 旧实现返回 nil;新实现必须回 ErrUpdateConflict,让上层区分"冻结生效 / 用户已不存在"。 err = m.UpdateStatus(ctx, id, 2) require.ErrorIs(t, err, user.ErrUpdateConflict, "M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁") } // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流) func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "m2_pw_ok_" + testutil.UniqueId() data := newTestSysUser(username, 1) res, err := m.Insert(ctx, data) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) }) orig, err := m.FindOne(ctx, id) require.NoError(t, err) origTv := orig.TokenVersion // 乐观锁依赖秒级 updateTime,必须让 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime, // 否则"空白更新"仍 affected=1 但 updateTime 值不变,容易掩盖后续断言 time.Sleep(1100 * time.Millisecond) newPw := "new_hashed_password_xyz" err = m.UpdatePassword(ctx, id, newPw, 1) require.NoError(t, err) got, err := m.FindOne(ctx, id) require.NoError(t, err) assert.Equal(t, newPw, got.Password) assert.Equal(t, int64(1), got.MustChangePassword) assert.Equal(t, origTv+1, got.TokenVersion, "改密必须递增 tokenVersion 以注销旧会话") assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则乐观锁无法生效") } // TC-0927: UpdateStatus 正常路径仍然成功且 tokenVersion 递增 func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "m2_st_ok_" + testutil.UniqueId() data := newTestSysUser(username, 1) res, err := m.Insert(ctx, data) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) }) orig, err := m.FindOne(ctx, id) require.NoError(t, err) origTv := orig.TokenVersion require.Equal(t, int64(1), orig.Status) err = m.UpdateStatus(ctx, id, 2) require.NoError(t, err) got, err := m.FindOne(ctx, id) require.NoError(t, err) assert.Equal(t, int64(2), got.Status) assert.Equal(t, origTv+1, got.TokenVersion, "冻结 / 解冻必须递增 tokenVersion 使旧 token 全部失效") } // TC-0928: UpdatePassword 对不存在的 userId 必须回 ErrNotFound(FindOne 先失败), // 确保 M-2 的 "affected=0 → ErrUpdateConflict" 不会把 "FindOne miss" 误报成 Conflict func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsNotFound(t *testing.T) { ctx := context.Background() m, _ := newModel(t) err := m.UpdatePassword(ctx, 999999999999, "irrelevant", 1) require.ErrorIs(t, err, user.ErrNotFound) } // TC-0929: UpdateStatus 对不存在的 userId 必须回 ErrNotFound func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsNotFound(t *testing.T) { ctx := context.Background() m, _ := newModel(t) err := m.UpdateStatus(ctx, 999999999999, 2) require.ErrorIs(t, err, user.ErrNotFound) }