9933d34bdd
Closes HIF-3 Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。 - 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理) - CI 全绿(构建/Vet/测试 + golangci-lint) - 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更 架构师 review 通过,4 项决策已在 issue 上给出: 1. 邀请转化语义 = 首次付款激活 2. 佣金日志类型 = 新增 CommissionTypeLottery=339 3. 家庭组归属 = 穿透到 owner 4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层) 后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。
198 lines
5.7 KiB
Go
198 lines
5.7 KiB
Go
package lottery
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
"gorm.io/driver/mysql"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func newLotteryTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||
t.Helper()
|
||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||
if strings.Contains(actual, expected) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||
})))
|
||
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 TestChanceService_Grant_ZeroAmountShortCircuits(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
svc := NewChanceService(db)
|
||
if err := svc.Grant(context.Background(), 1, 100, "manual_grant", "ref-1", 0); err != nil {
|
||
t.Fatalf("Grant(amount=0) unexpected err: %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("no queries expected, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Grant_FirstTimeInsertsGrantAndBalance(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
// INSERT lottery_chance_grant,未冲突返回 1 行
|
||
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||
// UPSERT lottery_chance_balance
|
||
mock.ExpectExec("INSERT INTO `lottery_chance_balance`").
|
||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||
mock.ExpectCommit()
|
||
|
||
svc := NewChanceService(db)
|
||
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||
t.Fatalf("Grant: %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Grant_IdempotentOnDuplicateSourceRef(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
// INSERT lottery_chance_grant,UNIQUE 冲突 → 0 行影响
|
||
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||
// balance 不应被触发
|
||
mock.ExpectCommit()
|
||
|
||
svc := NewChanceService(db)
|
||
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||
t.Fatalf("Grant: %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Consume_LocksAndDecrements(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||
WithArgs(int64(42), int64(100), 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||
AddRow(int64(9), int64(42), int64(100), int64(2), int64(3), int64(1)))
|
||
mock.ExpectExec("UPDATE `lottery_chance_balance`").
|
||
WithArgs(sqlmock.AnyArg(), int64(9)).
|
||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||
mock.ExpectCommit()
|
||
|
||
svc := NewChanceService(db)
|
||
var remaining int64
|
||
err := db.Transaction(func(tx *gorm.DB) error {
|
||
var e error
|
||
remaining, e = svc.Consume(context.Background(), tx, 42, 100)
|
||
return e
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Consume: %v", err)
|
||
}
|
||
if remaining != 1 {
|
||
t.Fatalf("expected remaining=1, got %d", remaining)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Consume_NoRowReturnsErrNoChances(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||
WithArgs(int64(42), int64(100), 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectRollback()
|
||
|
||
svc := NewChanceService(db)
|
||
err := db.Transaction(func(tx *gorm.DB) error {
|
||
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||
return e
|
||
})
|
||
if !errors.Is(err, ErrNoChances) {
|
||
t.Fatalf("expected ErrNoChances, got %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Consume_ZeroRemainingReturnsErrNoChances(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||
WithArgs(int64(42), int64(100), 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||
AddRow(int64(9), int64(42), int64(100), int64(0), int64(3), int64(3)))
|
||
mock.ExpectRollback()
|
||
|
||
svc := NewChanceService(db)
|
||
err := db.Transaction(func(tx *gorm.DB) error {
|
||
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||
return e
|
||
})
|
||
if !errors.Is(err, ErrNoChances) {
|
||
t.Fatalf("expected ErrNoChances when remaining=0, got %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Consume_RequiresTx(t *testing.T) {
|
||
db, _, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
svc := NewChanceService(db)
|
||
if _, err := svc.Consume(context.Background(), nil, 1, 1); err == nil {
|
||
t.Fatalf("expected error when tx is nil")
|
||
}
|
||
}
|
||
|
||
func TestChanceService_Query_NotFoundReturnsZero(t *testing.T) {
|
||
db, mock, cleanup := newLotteryTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||
WithArgs(int64(42), int64(100), 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
|
||
svc := NewChanceService(db)
|
||
got, err := svc.Query(context.Background(), 42, 100)
|
||
if err != nil {
|
||
t.Fatalf("Query: %v", err)
|
||
}
|
||
if got != 0 {
|
||
t.Fatalf("expected 0 when no row, got %d", got)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("expectations: %v", err)
|
||
}
|
||
}
|