对接
This commit is contained in:
@@ -4,11 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
bizgift "kra/internal/biz/gift"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/utils/serial"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,7 +26,7 @@ type giftPO struct {
|
||||
Description string `gorm:"size:255"`
|
||||
Tiers string `gorm:"type:text"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
Enabled bool `gorm:"index"`
|
||||
}
|
||||
|
||||
func (giftPO) TableName() string { return "live_gifts" }
|
||||
@@ -244,7 +244,11 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
|
||||
}
|
||||
}
|
||||
roomIDCopy, giftIDCopy := room.ID, gift.ID
|
||||
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
|
||||
serialNo, err := serial.New("DS")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := ledger.GiftSendPO{SerialNo: serialNo, UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -295,6 +299,3 @@ func sendOrder(v string) string {
|
||||
}
|
||||
return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
|
||||
}
|
||||
func newNumber(prefix string) string {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gift
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
bizgift "kra/internal/biz/gift"
|
||||
@@ -116,3 +117,184 @@ func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type RechargePlanPO struct {
|
||||
Sort int `gorm:"index"`
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
Enabled bool `gorm:"default:true"`
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func (RechargePlanPO) TableName() string { return "live_recharge_plans" }
|
||||
|
||||
@@ -23,7 +23,7 @@ type categoryPO struct {
|
||||
Name string `gorm:"size:40;uniqueIndex"`
|
||||
Icon string `gorm:"size:500"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
Enabled bool `gorm:"index"`
|
||||
}
|
||||
|
||||
func (categoryPO) TableName() string { return "live_categories" }
|
||||
@@ -41,7 +41,7 @@ type roomPO struct {
|
||||
AnchorUserID uint `gorm:"index"`
|
||||
StreamURL string `gorm:"size:1000"`
|
||||
ReplayURL string `gorm:"size:1000"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
Enabled bool `gorm:"index"`
|
||||
}
|
||||
|
||||
func (roomPO) TableName() string { return "live_rooms" }
|
||||
@@ -171,7 +171,7 @@ func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error {
|
||||
func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
|
||||
db := r.db(ctx)
|
||||
query := db.Model(&roomPO{}).
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
|
||||
if filter.Name != "" {
|
||||
value := "%" + filter.Name + "%"
|
||||
@@ -219,7 +219,7 @@ func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, erro
|
||||
var row roomRow
|
||||
err := r.db(ctx).Model(&roomPO{}).
|
||||
Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL").
|
||||
Where("live_rooms.id = ?", id).Scan(&row).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
@@ -44,7 +45,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
if err := db.Create(&category).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true}
|
||||
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, ReplayURL: "https://example.test/replay.mp4", Enabled: true}
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled {
|
||||
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || got.ReplayURL != room.ReplayURL || !got.Enabled {
|
||||
t.Fatalf("room get scan result=%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -110,3 +111,158 @@ func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
|
||||
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoPublicVisibilityFiltersBeforePagination(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visible := categoryPO{Name: "Visible", Enabled: true}
|
||||
disabled := categoryPO{Name: "Disabled", Enabled: true}
|
||||
deleted := categoryPO{Name: "Deleted", Enabled: true}
|
||||
for _, category := range []*categoryPO{&visible, &disabled, &deleted} {
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(&disabled).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&deleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rooms := []*roomPO{
|
||||
{Name: "Offline Room", CategoryID: visible.ID, Enabled: true},
|
||||
{Name: "Replay Room", CategoryID: visible.ID, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
|
||||
{Name: "Disabled Room", CategoryID: visible.ID, Enabled: true},
|
||||
{Name: "Disabled Category", CategoryID: disabled.ID, Enabled: true},
|
||||
{Name: "Deleted Category", CategoryID: deleted.ID, Enabled: true},
|
||||
{Name: "Missing Category", CategoryID: 999, Enabled: true},
|
||||
{Name: "Deleted Room", CategoryID: visible.ID, Enabled: true},
|
||||
}
|
||||
for _, room := range rooms {
|
||||
room.AnchorUserID = 2
|
||||
room.CoverURL = "cover.png"
|
||||
if err := db.Create(room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(rooms[2]).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(rooms[6]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
enabled := true
|
||||
filter := bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}
|
||||
items, total, err := repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10, OrderBy: "id_asc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 2 || items[0].ID != rooms[0].ID || items[1].ID != rooms[1].ID {
|
||||
t.Fatalf("public candidates total=%d items=%+v", total, items)
|
||||
}
|
||||
if items[1].ReplayURL != rooms[1].ReplayURL {
|
||||
t.Fatalf("replay URL=%q, want %q", items[1].ReplayURL, rooms[1].ReplayURL)
|
||||
}
|
||||
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Offset: 1, Limit: 1, OrderBy: "id_asc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 1 || items[0].ID != rooms[1].ID {
|
||||
t.Fatalf("public page total=%d items=%+v", total, items)
|
||||
}
|
||||
filter.CategoryID = disabled.ID
|
||||
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 || len(items) != 0 {
|
||||
t.Fatalf("disabled category returned rooms: total=%d items=%+v", total, items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoCreatePreservesExplicitEnabledState(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
// Existing installations still have the old database-level default.
|
||||
for _, table := range []string{"live_categories", "live_rooms"} {
|
||||
if err := db.Table(table).AutoMigrate(&struct {
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
ctx := context.Background()
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "Disabled"
|
||||
if enabled {
|
||||
name = "Enabled"
|
||||
}
|
||||
t.Run(name+" category", func(t *testing.T) {
|
||||
created, err := repo.CreateCategory(ctx, &bizlive.Category{Name: name, Enabled: enabled})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Enabled != enabled {
|
||||
t.Fatalf("created category enabled=%v, want %v", created.Enabled, enabled)
|
||||
}
|
||||
reloaded, err := repo.GetCategory(ctx, created.ID)
|
||||
if err != nil || reloaded.Enabled != enabled {
|
||||
t.Fatalf("reloaded category=%+v error=%v, want enabled=%v", reloaded, err, enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
enabled := true
|
||||
categories, total, err := repo.ListCategories(ctx, bizlive.CategoryFilter{Enabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(categories) != 1 || categories[0].Name != "Enabled" {
|
||||
t.Fatalf("public categories include a disabled new category: total=%d categories=%+v", total, categories)
|
||||
}
|
||||
categoryID := categories[0].ID
|
||||
streams := bizlive.NewStreamUsecase(NewStreamRepo())
|
||||
rooms := bizlive.NewRoomUsecase(repo, streams)
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "Disabled"
|
||||
if enabled {
|
||||
name = "Enabled"
|
||||
}
|
||||
t.Run(name+" room", func(t *testing.T) {
|
||||
created, err := repo.CreateRoom(ctx, &bizlive.Room{
|
||||
Name: name, CategoryID: categoryID, AnchorUserID: 2,
|
||||
CoverURL: "cover.png", ReplayURL: "https://example.test/replay.mp4", Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Enabled != enabled {
|
||||
t.Fatalf("created room enabled=%v, want %v", created.Enabled, enabled)
|
||||
}
|
||||
reloaded, err := repo.GetRoom(ctx, created.ID)
|
||||
if err != nil || reloaded.Enabled != enabled {
|
||||
t.Fatalf("reloaded room=%+v error=%v, want enabled=%v", reloaded, err, enabled)
|
||||
}
|
||||
public, err := rooms.GetPublic(ctx, created.ID)
|
||||
if enabled {
|
||||
if err != nil || public.Status != bizlive.RoomStatusOffline {
|
||||
t.Fatalf("enabled offline public room=%+v error=%v", public, err)
|
||||
}
|
||||
} else if public != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
|
||||
t.Fatalf("disabled new room passed public/publisher authorization: room=%+v error=%v", public, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
public, total, err := repo.ListRooms(ctx, bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(public) != 1 || public[0].Name != "Enabled" {
|
||||
t.Fatalf("public list includes a disabled new room: total=%d rooms=%+v", total, public)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package recharge
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/utils/serial"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
@@ -39,7 +39,7 @@ func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
|
||||
return err
|
||||
}
|
||||
for _, p := range plans {
|
||||
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil {
|
||||
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: p.Enabled}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,11 @@ func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, err
|
||||
rate = v
|
||||
}
|
||||
}
|
||||
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
|
||||
orderNo, err := serial.New("CZ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := ledger.RechargeOrderPO{OrderNo: orderNo, UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
|
||||
if err := r.db(ctx).Create(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,7 +189,3 @@ func orderBy(v string) string {
|
||||
}
|
||||
return "live_recharge_orders.created_at DESC"
|
||||
}
|
||||
|
||||
func number(p string) string {
|
||||
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -95,3 +97,174 @@ func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
|
||||
t.Fatalf("payment state was not joined: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPaidWalletFailureRollsBackCreditMarkerAndAllowsRetry(t *testing.T) {
|
||||
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(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := ledger.RechargeOrderPO{OrderNo: "CZ-ROLLBACK", UserID: 3, Diamonds: 60, AmountCents: 600}
|
||||
if err = db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&ledger.WalletPO{UserID: 3, DiamondBalance: 10}).Error; 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)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err == nil {
|
||||
t.Fatal("forced wallet update failure was ignored")
|
||||
}
|
||||
var stored ledger.RechargeOrderPO
|
||||
if err = db.First(&stored, order.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreditedAt != nil || wallet.DiamondBalance != 10 || wallet.TotalRechargeCents != 0 {
|
||||
t.Fatalf("failed fulfillment changed credit marker or wallet: order=%+v wallet=%+v", stored, wallet)
|
||||
}
|
||||
if err = db.Exec(`DROP TRIGGER reject_wallet_update`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.First(&stored, order.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreditedAt == nil || wallet.DiamondBalance != 70 || wallet.TotalRechargeCents != 600 {
|
||||
t.Fatalf("retry did not credit exactly once: order=%+v wallet=%+v", stored, wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargePlanStoragePreservesExplicitDisabledState(t *testing.T) {
|
||||
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(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("live_recharge_plans").AutoMigrate(&struct {
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false}
|
||||
if err = db.Create(&disabled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored ledger.RechargePlanPO
|
||||
if err = db.First(&stored, disabled.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Enabled {
|
||||
t.Fatal("storage default enabled an explicitly disabled recharge plan")
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
items, err := repo.Plans(context.Background())
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("public plans include a disabled plan: plans=%+v error=%v", items, err)
|
||||
}
|
||||
if _, err = repo.Create(context.Background(), 1, disabled.ID); !errors.Is(err, biz.ErrInvalid) {
|
||||
t.Fatalf("disabled recharge plan accepted an order: error=%v", err)
|
||||
}
|
||||
var orders int64
|
||||
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if orders != 0 {
|
||||
t.Fatalf("disabled recharge plan created %d orders", orders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargeRepoSavePlansPreservesDomainEnabledValues(t *testing.T) {
|
||||
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(&ledger.RechargePlanPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if err = repo.SavePlans(context.Background(), []*biz.Plan{
|
||||
{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false},
|
||||
{Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored []ledger.RechargePlanPO
|
||||
if err = db.Order("sort ASC").Find(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 2 || stored[0].Enabled || !stored[1].Enabled {
|
||||
t.Fatalf("saved plans lost explicit enabled values: %+v", stored)
|
||||
}
|
||||
items, err := repo.Plans(context.Background())
|
||||
if err != nil || len(items) != 1 || items[0].Diamonds != 300 {
|
||||
t.Fatalf("public plan filter returned disabled entries: plans=%+v error=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargeRepoConsecutiveOrdersHaveUniquePaymentSafeNumbers(t *testing.T) {
|
||||
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(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}, &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 pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`,
|
||||
`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}
|
||||
if err = db.Create(&plan).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
pattern := regexp.MustCompile(`^CZ[A-Z2-7]{26}$`)
|
||||
const count = 100
|
||||
seen := make(map[string]struct{}, count)
|
||||
for index := 0; index < count; index++ {
|
||||
order, err := repo.Create(context.Background(), 3, plan.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("order %d failed: %v", index, err)
|
||||
}
|
||||
if !pattern.MatchString(order.OrderNo) || len(order.OrderNo) > 32 {
|
||||
t.Fatalf("invalid recharge order number: %q", order.OrderNo)
|
||||
}
|
||||
if _, exists := seen[order.OrderNo]; exists {
|
||||
t.Fatalf("duplicate recharge order number: %q", order.OrderNo)
|
||||
}
|
||||
seen[order.OrderNo] = struct{}{}
|
||||
}
|
||||
var orders int64
|
||||
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if orders != count {
|
||||
t.Fatalf("created orders=%d, want %d", orders, count)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user