Files
hi-server/internal/model/lottery/chance_service_test.go
T
shanshanzhong147 9933d34bdd 新功能(#3): 抽奖活动 Stage 1 骨架(DB + 规则引擎 + Handler 抽象)
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 + 集成/并发/概率测试。
2026-07-08 20:05:05 -07:00

198 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_grantUNIQUE 冲突 → 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)
}
}