package gift import ( "context" "errors" "regexp" "testing" bizgift "kra/internal/biz/gift" "kra/internal/data/ledger" "github.com/glebarez/sqlite" "gorm.io/gorm" ) type testProvider struct{ db *gorm.DB } func (p testProvider) DB() *gorm.DB { return p.db } func openGiftTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err = db.AutoMigrate(&giftPO{}, &ledger.WalletPO{}, &ledger.GiftSendPO{}, &ledger.SettingPO{}); err != nil { t.Fatal(err) } for _, statement := range []string{ `CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`, `CREATE TABLE live_rooms (id integer primary key, name text, anchor_user_id integer, enabled numeric, deleted_at datetime)`, } { if err = db.Exec(statement).Error; err != nil { t.Fatal(err) } } return db } func TestGiftRepoProtectsLastEnabledGift(t *testing.T) { db := openGiftTestDB(t) repo := NewRepo(testProvider{db: db}) item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Rose", Icon: "rose", Price: 1, Tiers: []int{1}, Enabled: true}) if err != nil { t.Fatal(err) } if err = repo.SetEnabled(context.Background(), item.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) { t.Fatalf("disable last gift error = %v", err) } if err = repo.Delete(context.Background(), item.ID); !errors.Is(err, bizgift.ErrLastEnabled) { t.Fatalf("delete last gift error = %v", err) } } func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) { db := openGiftTestDB(t) repo := NewRepo(testProvider{db: db}) if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil { t.Fatal(err) } if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil { t.Fatal(err) } item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 5, Tiers: []int{1, 10}, Enabled: true}) if err != nil { t.Fatal(err) } if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); err != nil { t.Fatalf("custom quantity should be accepted: %v", err) } var before ledger.WalletPO if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 90 { t.Fatalf("wallet did not debit custom quantity: wallet=%+v err=%v", before, err) } if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil { t.Fatal(err) } var after ledger.WalletPO var sends int64 db.Where("user_id = ?", 1).First(&after) db.Model(&ledger.GiftSendPO{}).Count(&sends) if after.DiamondBalance != 40 || after.TotalSpentDiamonds != 60 || sends != 2 { t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends) } } func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) { db := openGiftTestDB(t) custom := giftPO{Name: "玫瑰", Icon: "custom", Price: 99, Description: "admin configured", Tiers: "[1]", Sort: 88, Enabled: false} if err := db.Create(&custom).Error; err != nil { t.Fatal(err) } if err := db.Model(&giftPO{}).Where("id = ?", custom.ID).Update("enabled", false).Error; err != nil { t.Fatal(err) } if err := seedDefaultCatalog(db); err != nil { t.Fatal(err) } if err := seedDefaultCatalog(db); err != nil { t.Fatal(err) } var count int64 if err := db.Model(&giftPO{}).Count(&count).Error; err != nil { t.Fatal(err) } if count != 24 { t.Fatalf("gift count = %d, want 24", count) } var rose giftPO if err := db.Where("name = ?", "玫瑰").First(&rose).Error; err != nil { t.Fatal(err) } if rose.Icon != "custom" || rose.Price != 99 || rose.Sort != 88 || rose.Enabled { t.Fatalf("existing gift was overwritten: %+v", rose) } } func TestGiftRepoInsufficientBalanceDoesNotWriteLedger(t *testing.T) { db := openGiftTestDB(t) repo := NewRepo(testProvider{db: db}) if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil { t.Fatal(err) } if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 11}).Error; err != nil { t.Fatal(err) } item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true}) if err != nil { t.Fatal(err) } _, err = repo.Send(context.Background(), 1, 7, item.ID, 3) var insufficient *bizgift.InsufficientBalanceError if !errors.Is(err, bizgift.ErrInsufficientBalance) || !errors.As(err, &insufficient) { t.Fatalf("insufficient balance error = %v", err) } if insufficient.Required != 21 || insufficient.Balance != 11 || insufficient.Missing != 10 { t.Fatalf("incorrect recharge shortfall: %+v", insufficient) } var wallet ledger.WalletPO if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil { t.Fatal(err) } var sends int64 if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil { t.Fatal(err) } if wallet.DiamondBalance != 11 || wallet.TotalSpentDiamonds != 0 || sends != 0 { t.Fatalf("failed gift changed ledger: wallet=%+v sends=%d", wallet, sends) } } func TestGiftRepoWalletFailureRollsBackGiftReceipt(t *testing.T) { db := openGiftTestDB(t) repo := NewRepo(testProvider{db: db}) if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil { t.Fatal(err) } if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil { t.Fatal(err) } item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true}) if err != nil { t.Fatal(err) } if err := db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil { t.Fatal(err) } if _, err := repo.Send(context.Background(), 1, 7, item.ID, 3); err == nil { t.Fatal("forced wallet update failure was ignored") } var wallet ledger.WalletPO if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil { t.Fatal(err) } var sends int64 if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil { t.Fatal(err) } if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 { t.Fatalf("wallet failure was not rolled back: wallet=%+v sends=%d", wallet, sends) } } func TestGiftRepoCreateDisabledGiftCannotExposeOrDebitIt(t *testing.T) { db := openGiftTestDB(t) if err := db.Table("live_gifts").AutoMigrate(&struct { Enabled bool `gorm:"default:true"` }{}); err != nil { t.Fatal(err) } repo := NewRepo(testProvider{db: db}) ctx := context.Background() active, err := repo.Save(ctx, &bizgift.Gift{Name: "Active", Icon: "active.png", Price: 1, Tiers: []int{1}, Enabled: true}) if err != nil { t.Fatal(err) } disabled, err := repo.Save(ctx, &bizgift.Gift{Name: "Disabled", Icon: "disabled.png", Price: 5, Tiers: []int{1}, Enabled: false}) if err != nil { t.Fatal(err) } if disabled.Enabled { t.Fatal("a newly created disabled gift was enabled") } reloaded, err := repo.Get(ctx, disabled.ID) if err != nil || reloaded.Enabled { t.Fatalf("reloaded disabled gift=%+v error=%v", reloaded, err) } enabled := true items, total, err := repo.List(ctx, bizgift.Filter{Enabled: &enabled}, bizgift.ListOptions{Limit: 10}) if err != nil { t.Fatal(err) } if total != 1 || len(items) != 1 || items[0].ID != active.ID { t.Fatalf("public gifts include a disabled gift: total=%d items=%+v", total, items) } if err = repo.SetEnabled(ctx, active.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) { t.Fatalf("disabled gift counted as an enabled alternative: error=%v", err) } if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil { t.Fatal(err) } if err = db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil { t.Fatal(err) } if err = db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil { t.Fatal(err) } if _, err = repo.Send(ctx, 1, 7, disabled.ID, 1); !errors.Is(err, bizgift.ErrSendNotAllowed) { t.Fatalf("disabled gift could be sent: error=%v", err) } var wallet ledger.WalletPO if err = db.Where("user_id = ?", 1).First(&wallet).Error; err != nil { t.Fatal(err) } var sends int64 if err = db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil { t.Fatal(err) } if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 { t.Fatalf("disabled gift changed the ledger: wallet=%+v sends=%d", wallet, sends) } } func TestGiftRepoConsecutiveSendsHaveUniqueShortSerialNumbers(t *testing.T) { db := openGiftTestDB(t) ctx := context.Background() repo := NewRepo(testProvider{db: db}) if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil { t.Fatal(err) } const count = 100 if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: count}).Error; err != nil { t.Fatal(err) } if err := db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil { t.Fatal(err) } gift, err := repo.Save(ctx, &bizgift.Gift{Name: "Gift", Icon: "gift.png", Price: 1, Tiers: []int{1}, Enabled: true}) if err != nil { t.Fatal(err) } pattern := regexp.MustCompile(`^DS[A-Z2-7]{26}$`) seen := make(map[string]struct{}, count) for index := 0; index < count; index++ { sent, err := repo.Send(ctx, 1, 7, gift.ID, 1) if err != nil { t.Fatalf("gift %d failed: %v", index, err) } if !pattern.MatchString(sent.SerialNo) || len(sent.SerialNo) > 32 { t.Fatalf("invalid gift serial: %q", sent.SerialNo) } if _, exists := seen[sent.SerialNo]; exists { t.Fatalf("duplicate gift serial: %q", sent.SerialNo) } seen[sent.SerialNo] = struct{}{} } var receipts int64 if err := db.Model(&ledger.GiftSendPO{}).Count(&receipts).Error; err != nil { t.Fatal(err) } var wallet ledger.WalletPO if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil { t.Fatal(err) } if receipts != count || wallet.DiamondBalance != 0 || wallet.TotalSpentDiamonds != count { t.Fatalf("consecutive gifts did not reconcile: receipts=%d wallet=%+v", receipts, wallet) } }