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) } }