package user import ( "context" "fmt" "regexp" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" modeluser "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/driver/mysql" "gorm.io/gorm" ) // TestCommissionWithdraw_StaleCacheRejected 覆盖 HIF-139 修复: // ctxUser(来自 auth middleware 的 cache-aside FindOne)即使 Commission=996900, // 只要事务内 FOR UPDATE 读出的 DB 真值 Commission < 申请金额 + pendingTotal, // 申请就必须被拒(UserCommissionNotEnough),且不得 INSERT 任何 withdrawal。 func TestCommissionWithdraw_StaleCacheRejected(t *testing.T) { const userID = int64(510) db, mock, cleanup := newWithdrawTestDB(t) defer cleanup() mock.ExpectBegin() // FOR UPDATE 锁 user 行 → DB 真值 commission=0 mock.ExpectQuery("FROM `user`"). WithArgs(userID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(0))) // pendingTotal=0 mock.ExpectQuery("FROM `withdrawals`"). WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)). WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(0))) // 校验失败 → ROLLBACK,不得 INSERT mock.ExpectRollback() // ctxUser 故意带一个虚高 commission,模拟陈旧 cache logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 996900}) _, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{ Amount: 3000, Method: modeluser.WithdrawalMethodBank, Account: "222", }) if err == nil { t.Fatalf("CommissionWithdraw expected error, got nil") } if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) { t.Fatalf("CommissionWithdraw error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestCommissionWithdraw_HappyPath 覆盖申请成功路径:DB 真值充足 → INSERT withdrawal。 func TestCommissionWithdraw_HappyPath(t *testing.T) { const userID = int64(72) db, mock, cleanup := newWithdrawTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `user`"). WithArgs(userID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(10000))) mock.ExpectQuery("FROM `withdrawals`"). WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)). WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(2000))) // 10000 >= 3000 + 2000 → INSERT mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `withdrawals`")). WillReturnResult(sqlmock.NewResult(99, 1)) mock.ExpectCommit() logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 10000}) resp, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{ Amount: 3000, Method: modeluser.WithdrawalMethodBank, Account: "acc", }) if err != nil { t.Fatalf("CommissionWithdraw unexpected error: %v", err) } if resp == nil || resp.Amount != 3000 || resp.Status != modeluser.WithdrawalStatusPending { t.Fatalf("unexpected response: %+v", resp) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestCommissionWithdraw_PendingTotalExhausts 覆盖 pendingTotal 把可用额度吃光的场景: // DB commission=5000、pending=4000、申请=2000 → 5000 < 6000 → 20010,不得 INSERT。 func TestCommissionWithdraw_PendingTotalExhausts(t *testing.T) { const userID = int64(88) db, mock, cleanup := newWithdrawTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `user`"). WithArgs(userID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(5000))) mock.ExpectQuery("FROM `withdrawals`"). WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)). WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(4000))) mock.ExpectRollback() logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 5000}) _, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{ Amount: 2000, Method: modeluser.WithdrawalMethodBank, Account: "acc", }) if err == nil { t.Fatalf("CommissionWithdraw expected error, got nil") } if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) { t.Fatalf("error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestCommissionWithdraw_LockUserMissing 覆盖事务内 FOR UPDATE 找不到 user 的场景(user 已删/不存在)→ DatabaseQueryError。 func TestCommissionWithdraw_LockUserMissing(t *testing.T) { const userID = int64(999999) db, mock, cleanup := newWithdrawTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `user`"). WithArgs(userID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "commission"})) mock.ExpectRollback() logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 999}) _, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{ Amount: 100, Method: modeluser.WithdrawalMethodBank, Account: "acc", }) if err == nil { t.Fatalf("CommissionWithdraw expected error, got nil") } if !isWithdrawErrCode(err, xerr.DatabaseQueryError) { t.Fatalf("error code = %v, want DatabaseQueryError; raw=%v", withdrawErrCodeOf(err), err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } func newWithdrawTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { t.Helper() sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { if strings.Contains(actualSQL, expectedSQL) { return nil } return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) }))) if err != nil { t.Fatalf("create sqlmock: %v", err) } db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { _ = sqlDB.Close() t.Fatalf("open gorm db: %v", err) } return db, mock, func() { _ = sqlDB.Close() } } func newTestCommissionWithdrawLogic(t *testing.T, db *gorm.DB, ctxUser *modeluser.User) *CommissionWithdrawLogic { t.Helper() ctx := context.WithValue(context.Background(), constant.CtxKeyUser, ctxUser) return &CommissionWithdrawLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } func withdrawErrCodeOf(err error) uint32 { if err == nil { return 0 } type coder interface { GetErrCode() uint32 } cause := errors.Cause(err) if c, ok := cause.(coder); ok { return c.GetErrCode() } return 0 } func isWithdrawErrCode(err error, code uint32) bool { return withdrawErrCodeOf(err) == code }