package wheel import ( "context" "errors" "testing" "time" "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "gorm.io/driver/sqlite" "gorm.io/gorm" ) type fakeWheelGateway struct { giftCalls []fakeGiftBackpackCall propsCalls []integration.GivePropsBackpackRequest switches int changes []integration.GoldReceiptCommand events map[string]struct{} room integration.RoomProfile failNextIncome bool } type fakeGiftBackpackCall struct { userID int64 giftID int64 quantity int } func (g *fakeWheelGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { if g.events == nil { g.events = map[string]struct{}{} } if _, exists := g.events[cmd.EventID]; exists { return nil } if cmd.ReceiptType == walletReceiptIncome && g.failNextIncome { g.failNextIncome = false return errors.New("grant failed") } g.changes = append(g.changes, cmd) g.events[cmd.EventID] = struct{}{} return nil } func (g *fakeWheelGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) { if g.events == nil { return false, nil } _, exists := g.events[eventID] return exists, nil } func (g *fakeWheelGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error { g.propsCalls = append(g.propsCalls, req) return nil } func (g *fakeWheelGateway) IncrGiftBackpack(_ context.Context, userID int64, giftID int64, quantity int) error { g.giftCalls = append(g.giftCalls, fakeGiftBackpackCall{ userID: userID, giftID: giftID, quantity: quantity, }) return nil } func (g *fakeWheelGateway) SwitchUseProps(context.Context, int64, int64) error { g.switches++ return nil } func (g *fakeWheelGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error { return nil } func (g *fakeWheelGateway) RemoveUserProfileCacheAll(context.Context, int64) error { return nil } func (g *fakeWheelGateway) MapGoldBalance(context.Context, []int64) (map[int64]int64, error) { return map[int64]int64{}, nil } func (g *fakeWheelGateway) GetRoomProfileByUserID(context.Context, int64) (integration.RoomProfile, error) { return g.room, nil } func TestGrantResourceRewardRoutesGiftToGiftBackpack(t *testing.T) { gateway := &fakeWheelGateway{} service := &Service{java: gateway} giftID := int64(2044086730826510337) if err := service.grantResourceReward(context.Background(), &model.WheelDrawRecord{ UserID: 2042274349343506434, ResourceID: &giftID, ResourceType: resourceTypeGift, DurationDays: 7, }); err != nil { t.Fatalf("grantResourceReward() error = %v", err) } if len(gateway.giftCalls) != 1 { t.Fatalf("gift backpack calls = %d, want 1", len(gateway.giftCalls)) } if got := gateway.giftCalls[0]; got.userID != 2042274349343506434 || got.giftID != giftID || got.quantity != 1 { t.Fatalf("gift backpack call = %+v", got) } if len(gateway.propsCalls) != 0 { t.Fatalf("props backpack calls = %d, want 0", len(gateway.propsCalls)) } if gateway.switches != 0 { t.Fatalf("switch use props calls = %d, want 0", gateway.switches) } } func TestDrawRespectsRewardTotalLimit(t *testing.T) { service, gateway, db := newWheelDrawTestService(t) seedWheelDrawConfig(t, db, 1, 0) resp, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1}) if err != nil { t.Fatalf("Draw() first error = %v", err) } if resp.GrantFailed { t.Fatalf("Draw() first grant failed: %+v", resp) } if _, err := service.Draw(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1}); err == nil { t.Fatal("Draw() second expected exhausted error") } if len(gateway.changes) != 2 { t.Fatalf("wallet changes = %d, want 2 pay+reward for first draw only", len(gateway.changes)) } var reward model.WheelRewardConfig if err := db.Where("sort = ?", 1).First(&reward).Error; err != nil { t.Fatalf("load reward: %v", err) } if reward.IssuedCount != 1 { t.Fatalf("issued_count = %d, want 1", reward.IssuedCount) } } func TestDrawSkipsExhaustedRewardWhenFallbackAvailable(t *testing.T) { service, gateway, db := newWheelDrawTestService(t) seedWheelDrawConfig(t, db, 1, 0) first, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1}) if err != nil { t.Fatalf("Draw() first error = %v", err) } if len(first.Records) != 1 { t.Fatalf("first records = %d, want 1", len(first.Records)) } exhaustedRewardID := first.Records[0].RewardConfigID if err := db.Model(&model.WheelRewardConfig{}). Where("sort = ?", 2). Updates(map[string]any{ "probability": probabilityTotal, "gold_amount": int64(2), }).Error; err != nil { t.Fatalf("enable fallback reward: %v", err) } second, err := service.Draw(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1}) if err != nil { t.Fatalf("Draw() second error = %v", err) } if second.GrantFailed { t.Fatalf("Draw() second grant failed: %+v", second) } if len(second.Records) != 1 { t.Fatalf("second records = %d, want 1", len(second.Records)) } if second.Records[0].RewardConfigID == exhaustedRewardID { t.Fatalf("second draw hit exhausted reward %d", exhaustedRewardID) } if second.Records[0].GoldAmount != 2 { t.Fatalf("second reward gold = %d, want fallback gold 2", second.Records[0].GoldAmount) } if len(gateway.changes) != 4 { t.Fatalf("wallet changes = %d, want 4 pay+reward twice", len(gateway.changes)) } } func TestDrawFailedGrantCanBeRetriedWithoutNewPayment(t *testing.T) { service, gateway, db := newWheelDrawTestService(t) seedWheelDrawConfig(t, db, 0, 0) gateway.failNextIncome = true resp, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1}) if err != nil { t.Fatalf("Draw() error = %v", err) } if !resp.GrantFailed { t.Fatalf("Draw() grantFailed = false, want true") } if len(resp.Records) != 1 || resp.Records[0].Status != drawStatusFailed { t.Fatalf("records = %+v, want one FAILED record", resp.Records) } if len(gateway.changes) != 1 || gateway.changes[0].ReceiptType != walletReceiptExpenditure { t.Fatalf("wallet changes after failed grant = %+v, want only payment", gateway.changes) } if err := service.RetryDrawRecord(context.Background(), resp.Records[0].ID); err != nil { t.Fatalf("RetryDrawRecord() error = %v", err) } var record model.WheelDrawRecord if err := db.Where("id = ?", resp.Records[0].ID).First(&record).Error; err != nil { t.Fatalf("load record: %v", err) } if record.Status != drawStatusSuccess { t.Fatalf("record status = %s, want SUCCESS", record.Status) } if len(gateway.changes) != 2 || gateway.changes[1].ReceiptType != walletReceiptIncome { t.Fatalf("wallet changes after retry = %+v, want reward income", gateway.changes) } } func TestDrawIdempotencyReturnsOriginalResultWithoutSecondCharge(t *testing.T) { service, gateway, db := newWheelDrawTestService(t) seedWheelDrawConfig(t, db, 0, 0) req := DrawRequest{Category: categoryClassic, Times: 1, IdempotencyKey: "wheel-h5-key-1"} first, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, req) if err != nil { t.Fatalf("Draw() first error = %v", err) } second, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, req) if err != nil { t.Fatalf("Draw() second error = %v", err) } if second.DrawNo != first.DrawNo { t.Fatalf("second drawNo = %s, want %s", second.DrawNo, first.DrawNo) } if len(gateway.changes) != 2 { t.Fatalf("wallet changes = %d, want one pay+reward", len(gateway.changes)) } var recordCount int64 if err := db.Model(&model.WheelDrawRecord{}).Where("draw_no = ?", first.DrawNo).Count(&recordCount).Error; err != nil { t.Fatalf("count records: %v", err) } if recordCount != 1 { t.Fatalf("record count = %d, want 1", recordCount) } } func TestDrawIdempotencyRejectsDifferentRequest(t *testing.T) { service, gateway, db := newWheelDrawTestService(t) seedWheelDrawConfig(t, db, 0, 0) _, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1, IdempotencyKey: "wheel-h5-key-2"}) if err != nil { t.Fatalf("Draw() first error = %v", err) } _, err = service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 10, IdempotencyKey: "wheel-h5-key-2"}) if err == nil { t.Fatal("Draw() second expected idempotency conflict") } if len(gateway.changes) != 2 { t.Fatalf("wallet changes = %d, want only first pay+reward", len(gateway.changes)) } } func newWheelDrawTestService(t *testing.T) (*Service, *fakeWheelGateway, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&model.WheelConfig{}, &model.WheelPoolConfig{}, &model.WheelRewardConfig{}, &model.WheelDrawRecord{}, &model.ActivityIdempotency{}, &model.UserBaseInfo{}); err != nil { t.Fatalf("migrate sqlite: %v", err) } gateway := &fakeWheelGateway{ events: map[string]struct{}{}, room: integration.RoomProfile{ ID: integration.Int64Value(9001), SysOrigin: "LIKEI", }, } return NewService(config.Config{}, db, gateway), gateway, db } func seedWheelDrawConfig(t *testing.T, db *gorm.DB, totalLimit int64, userLimit int64) { t.Helper() now := time.Now() configRow := model.WheelConfig{ID: 1, SysOrigin: "LIKEI", Enabled: true, Timezone: defaultTimezone, CreateTime: now, UpdateTime: now} pool := model.WheelPoolConfig{ ID: 2, ConfigID: 1, SysOrigin: "LIKEI", Category: categoryClassic, Enabled: true, PriceOneGold: 1, PriceTenGold: 10, PriceFiftyGold: 50, Sort: 1, CreateTime: now, UpdateTime: now, } rewards := make([]model.WheelRewardConfig, 0, 8) for index := 0; index < 8; index++ { probability := 0 limit := int64(0) perUserLimit := int64(0) if index == 0 { probability = probabilityTotal limit = totalLimit perUserLimit = userLimit } rewards = append(rewards, model.WheelRewardConfig{ ID: int64(100 + index), ConfigID: 1, SysOrigin: "LIKEI", Category: categoryClassic, RewardType: rewardTypeGold, GoldAmount: 1, Probability: probability, TotalLimit: limit, UserLimit: perUserLimit, Sort: index + 1, Enabled: true, CreateTime: now, UpdateTime: now, }) } if err := db.Create(&configRow).Error; err != nil { t.Fatalf("seed config: %v", err) } if err := db.Create(&pool).Error; err != nil { t.Fatalf("seed pool: %v", err) } if err := db.Create(&rewards).Error; err != nil { t.Fatalf("seed rewards: %v", err) } }