This commit is contained in:
Yvan
2026-09-22 20:20:23 +08:00
parent bf33e87d72
commit ceaea78b0f
42 changed files with 2845 additions and 154 deletions
+7 -6
View File
@@ -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)
}
+182
View File
@@ -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)
}
}
+1 -1
View File
@@ -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" }
+4 -4
View File
@@ -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 {
+158 -2
View File
@@ -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)
}
}
+7 -7
View File
@@ -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)
}
+173
View File
@@ -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)
}
}