2026-05-20 13:53:08 +08:00

341 lines
11 KiB
Go

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