package user import ( "context" "fmt" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" usermodel "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" ) // TestCancelWithdrawal_DoesNotRefundCommission 验证 HIF-140 修复: // 用户撤销 pending 提现的事务体必须只更新 withdrawal.status, // 严禁触发 UpdateCommission(commission 列读 / 写)或写 333/338 commission 日志。 // // sqlmock 严格匹配期望 SQL:只允许出现 BEGIN / SELECT withdrawal FOR UPDATE / // UPDATE withdrawal SET status / COMMIT,不允许出现 SELECT/UPDATE `user` 或 // INSERT system_logs。 func TestCancelWithdrawal_DoesNotRefundCommission(t *testing.T) { const ( withdrawalID = int64(987654) userID = int64(42) amount = int64(2500) ) db, mock, cleanup := newCancelWithdrawalTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `withdrawals`"). WithArgs(withdrawalID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). AddRow(withdrawalID, userID, amount, usermodel.WithdrawalStatusPending)) mock.ExpectExec("UPDATE `withdrawals` SET"). WithArgs(usermodel.WithdrawalStatusCancelled, sqlmock.AnyArg(), withdrawalID, usermodel.WithdrawalStatusPending). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() logic := newTestCancelWithdrawalLogic(t, db, userID) resp, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) if err != nil { t.Fatalf("CancelWithdrawal unexpected error: %v", err) } if resp == nil || resp.Id != withdrawalID { t.Fatalf("CancelWithdrawal response = %+v, want id=%d", resp, withdrawalID) } if resp.Status != usermodel.WithdrawalStatusCancelled { t.Fatalf("CancelWithdrawal status = %d, want %d", resp.Status, usermodel.WithdrawalStatusCancelled) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestCancelWithdrawal_RejectsNonPending 覆盖:状态非 pending(已批准/拒绝/已撤销) // 时撤销必须短路返回 WithdrawalStatusInvalid,且不得写任何状态或佣金。 func TestCancelWithdrawal_RejectsNonPending(t *testing.T) { const ( withdrawalID = int64(55555) userID = int64(42) ) cases := []struct { name string status uint8 }{ {"already approved", usermodel.WithdrawalStatusApproved}, {"already rejected", usermodel.WithdrawalStatusRejected}, {"already cancelled", usermodel.WithdrawalStatusCancelled}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db, mock, cleanup := newCancelWithdrawalTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `withdrawals`"). WithArgs(withdrawalID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). AddRow(withdrawalID, userID, int64(2500), tc.status)) mock.ExpectRollback() logic := newTestCancelWithdrawalLogic(t, db, userID) _, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) { t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } }) } } // TestCancelWithdrawal_RejectsOtherUserWithdrawal 覆盖:当前登录用户尝试撤销 // 不属于自己的 pending 提现,必须返回 PermissionDenied 且不得写库。 func TestCancelWithdrawal_RejectsOtherUserWithdrawal(t *testing.T) { const ( withdrawalID = int64(33333) ownerUserID = int64(99) attackerID = int64(42) ) db, mock, cleanup := newCancelWithdrawalTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `withdrawals`"). WithArgs(withdrawalID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). AddRow(withdrawalID, ownerUserID, int64(2500), usermodel.WithdrawalStatusPending)) mock.ExpectRollback() logic := newTestCancelWithdrawalLogic(t, db, attackerID) _, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) if !isCancelErrCode(err, xerr.PermissionDenied) { t.Fatalf("CancelWithdrawal err = %v, want PermissionDenied", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE 模拟撤销与审批并发的场景: // 第二个 cancel 在 LoadPendingWithdrawalForUpdate 取到行时,状态已被先到的 approve // 改成 1(FOR UPDATE 行锁让出后看到的最新状态),cancel 应当短路返回错误, // 严禁继续往下写 status 或动 commission。 func TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE(t *testing.T) { const ( withdrawalID = int64(77777) userID = int64(42) ) db, mock, cleanup := newCancelWithdrawalTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery("FROM `withdrawals`"). WithArgs(withdrawalID, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). AddRow(withdrawalID, userID, int64(2500), usermodel.WithdrawalStatusApproved)) mock.ExpectRollback() logic := newTestCancelWithdrawalLogic(t, db, userID) _, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) { t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid (loser of race)", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } func newCancelWithdrawalTestDB(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 newTestCancelWithdrawalLogic(t *testing.T, db *gorm.DB, userID int64) *CancelWithdrawalLogic { t.Helper() ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID}) return &CancelWithdrawalLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{ DB: db, UserModel: stubUserModelForCancel{}, }, } } // stubUserModelForCancel 是 user.Model 的零依赖替身,仅覆盖 CancelWithdrawal 必须 // 调用的 ClearUserCache。其它方法被调用会导致 nil-interface panic,正好可以暴露 // 测试边界外的意外依赖。 type stubUserModelForCancel struct { usermodel.Model } func (stubUserModelForCancel) ClearUserCache(_ context.Context, _ ...*usermodel.User) error { return nil } func cancelErrCodeOf(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 isCancelErrCode(err error, code uint32) bool { return cancelErrCodeOf(err) == code }