18df7e4d6b
- commissionWithdrawLogic: 不再用 ctx 中可能陈旧的 cache user 做余额校验 - 在事务内 FOR UPDATE user 行 + 求和 pending → 用 DB 真值校验 commission - 与 approveWithdrawal 对齐数据源,消除 cache/DB 不一致导致的漏报 - 新增 4 个 sqlmock 单测:陈旧 cache 拒绝、happy path、pending 吃光、user 丢失 Co-authored-by: multica-agent <github@multica.ai>
213 lines
7.0 KiB
Go
213 lines
7.0 KiB
Go
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
|
||
}
|