822 lines
30 KiB
Go
822 lines
30 KiB
Go
package wallet_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||
"hyapp/services/wallet-service/internal/testutil/mysqltest"
|
||
)
|
||
|
||
// TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。
|
||
func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(10001, 100)
|
||
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
|
||
svc := walletservice.New(repository)
|
||
|
||
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||
CommandID: "cmd-gift-v2",
|
||
RoomID: "room-1",
|
||
SenderUserID: 10001,
|
||
TargetUserID: 10002,
|
||
GiftID: "rose",
|
||
GiftCount: 2,
|
||
PriceVersion: "v9",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("DebitGift failed: %v", err)
|
||
}
|
||
|
||
if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 6 || receipt.HeatValue != 22 || receipt.BalanceAfter != 86 || receipt.PriceVersion != "v9" {
|
||
t.Fatalf("server price settlement mismatch: %+v", receipt)
|
||
}
|
||
if receipt.TransactionID == "" || receipt.BillingReceiptID == "" {
|
||
t.Fatalf("receipt identifiers missing: %+v", receipt)
|
||
}
|
||
|
||
balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances sender failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoin) != 86 {
|
||
t.Fatalf("sender COIN balance mismatch: %+v", balances)
|
||
}
|
||
balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetGiftPoint})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances target failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetGiftPoint) != 6 {
|
||
t.Fatalf("target GIFT_POINT balance mismatch: %+v", balances)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
|
||
t.Fatalf("gift debit should write two entries, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 3 {
|
||
t.Fatalf("gift debit should write balance and gift outbox events, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。
|
||
func TestDebitGiftIsIdempotent(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(10001, 100)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.DebitGiftCommand{
|
||
CommandID: "cmd-1",
|
||
RoomID: "room-1",
|
||
SenderUserID: 10001,
|
||
TargetUserID: 10002,
|
||
GiftID: "rose",
|
||
GiftCount: 2,
|
||
}
|
||
|
||
first, err := svc.DebitGift(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("first debit failed: %v", err)
|
||
}
|
||
second, err := svc.DebitGift(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("second debit failed: %v", err)
|
||
}
|
||
|
||
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
|
||
t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
|
||
t.Fatalf("idempotent command should write one transaction, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestDebitGiftRejectsCommandPayloadConflict 锁定同 command_id 不同业务 payload 的冲突语义。
|
||
func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(10001, 100)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1}
|
||
if _, err := svc.DebitGift(context.Background(), command); err != nil {
|
||
t.Fatalf("first debit failed: %v", err)
|
||
}
|
||
command.GiftCount = 2
|
||
_, err := svc.DebitGift(context.Background(), command)
|
||
if !xerr.IsCode(err, xerr.LedgerConflict) {
|
||
t.Fatalf("expected LEDGER_CONFLICT, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestDebitGiftInsufficientBalance 锁定余额不足时不写成功交易、分录或 outbox。
|
||
func TestDebitGiftInsufficientBalance(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(10001, 1)
|
||
svc := walletservice.New(repository)
|
||
|
||
_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||
CommandID: "cmd-2",
|
||
RoomID: "room-1",
|
||
SenderUserID: 10001,
|
||
TargetUserID: 10002,
|
||
GiftID: "rose",
|
||
GiftCount: 1,
|
||
})
|
||
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
||
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-2"); got != 0 {
|
||
t.Fatalf("insufficient balance must not write transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id LIKE ?", "wtx_%"); got != 0 {
|
||
t.Fatalf("insufficient balance must not write entries, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "command_id = ?", "cmd-2"); got != 0 {
|
||
t.Fatalf("insufficient balance must not write outbox, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditAssetIsIdempotent 验证后台入账有审计字段、幂等和余额查询闭环。
|
||
func TestAdminCreditAssetIsIdempotent(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.AdminCreditAssetCommand{
|
||
CommandID: "cmd-admin-credit",
|
||
TargetUserID: 20001,
|
||
AssetType: "coin",
|
||
Amount: 500,
|
||
OperatorUserID: 90001,
|
||
Reason: "manual recharge",
|
||
EvidenceRef: "ticket-1",
|
||
}
|
||
|
||
first, txID, err := svc.AdminCreditAsset(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("AdminCreditAsset failed: %v", err)
|
||
}
|
||
second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("AdminCreditAsset retry failed: %v", err)
|
||
}
|
||
if txID == "" || txID != secondTxID || first.AvailableAmount != 500 || second.AvailableAmount != 500 || first.AssetType != ledger.AssetCoin {
|
||
t.Fatalf("admin credit idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID)
|
||
}
|
||
|
||
balances, err := svc.GetBalances(context.Background(), 20001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoin) != 500 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
|
||
t.Fatalf("balances mismatch: %+v", balances)
|
||
}
|
||
}
|
||
|
||
// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。
|
||
func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
repository.SetRechargePolicy(1001, "recharge-v1", 80000, 100)
|
||
_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
|
||
CommandID: "cmd-credit-seller",
|
||
TargetUserID: 30001,
|
||
AssetType: ledger.AssetCoinSellerCoin,
|
||
Amount: 160000,
|
||
OperatorUserID: 90001,
|
||
Reason: "seed seller balance",
|
||
EvidenceRef: "ticket-seller",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed seller asset failed: %v", err)
|
||
}
|
||
|
||
command := ledger.CoinSellerTransferCommand{
|
||
CommandID: "cmd-seller-transfer",
|
||
SellerUserID: 30001,
|
||
TargetUserID: 30002,
|
||
SellerRegionID: 1001,
|
||
TargetRegionID: 1001,
|
||
Amount: 80000,
|
||
Reason: "player recharge",
|
||
}
|
||
first, err := svc.TransferCoinFromSeller(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("TransferCoinFromSeller failed: %v", err)
|
||
}
|
||
second, err := svc.TransferCoinFromSeller(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("TransferCoinFromSeller retry failed: %v", err)
|
||
}
|
||
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SellerBalanceAfter != 80000 || first.TargetBalanceAfter != 80000 || first.RechargeUSDMinor != 100 {
|
||
t.Fatalf("transfer receipt mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
|
||
sellerBalances, err := svc.GetBalances(context.Background(), 30001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances seller failed: %v", err)
|
||
}
|
||
if balanceAmount(sellerBalances, ledger.AssetCoinSellerCoin) != 80000 || balanceAmount(sellerBalances, ledger.AssetCoin) != 0 {
|
||
t.Fatalf("seller balances mismatch: %+v", sellerBalances)
|
||
}
|
||
targetBalances, err := svc.GetBalances(context.Background(), 30002, []string{ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances target failed: %v", err)
|
||
}
|
||
if balanceAmount(targetBalances, ledger.AssetCoin) != 80000 {
|
||
t.Fatalf("target COIN balance mismatch: %+v", targetBalances)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
|
||
t.Fatalf("idempotent seller transfer should write one transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
|
||
t.Fatalf("seller transfer should write two entries, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 {
|
||
t.Fatalf("seller transfer should write one recharge record, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 {
|
||
t.Fatalf("seller transfer should write two balance events, one transfer event and one recharge event, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestTransferCoinFromSellerInsufficientBalance 验证币商余额不足时不生成转账交易。
|
||
func TestTransferCoinFromSellerInsufficientBalance(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
|
||
_, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
|
||
CommandID: "cmd-seller-low",
|
||
SellerUserID: 31001,
|
||
TargetUserID: 31002,
|
||
SellerRegionID: 1001,
|
||
TargetRegionID: 1001,
|
||
Amount: 1,
|
||
Reason: "player recharge",
|
||
})
|
||
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
||
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-low"); got != 0 {
|
||
t.Fatalf("insufficient seller balance must not write transaction, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestTransferCoinFromSellerRequiresRechargePolicy 验证缺少区域充值政策时不会产生半成品充值事实。
|
||
func TestTransferCoinFromSellerRequiresRechargePolicy(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
|
||
CommandID: "cmd-credit-no-policy",
|
||
TargetUserID: 32001,
|
||
AssetType: ledger.AssetCoinSellerCoin,
|
||
Amount: 80000,
|
||
OperatorUserID: 90001,
|
||
Reason: "seed seller balance",
|
||
EvidenceRef: "ticket-no-policy",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed seller asset failed: %v", err)
|
||
}
|
||
|
||
_, err = svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
|
||
CommandID: "cmd-seller-no-policy",
|
||
SellerUserID: 32001,
|
||
TargetUserID: 32002,
|
||
SellerRegionID: 2001,
|
||
TargetRegionID: 2001,
|
||
Amount: 80000,
|
||
Reason: "player recharge",
|
||
})
|
||
if !xerr.IsCode(err, xerr.NotFound) {
|
||
t.Fatalf("expected NOT_FOUND when recharge policy is missing, got %v", err)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-no-policy"); got != 0 {
|
||
t.Fatalf("missing recharge policy must not write transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_recharge_records", "transaction_id LIKE ?", "wtx_%"); got != 0 {
|
||
t.Fatalf("missing recharge policy must not write recharge record, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestGiftConfigMustSelectGiftResource 验证后台新增礼物只能绑定 resource_type=gift 的资源。
|
||
func TestGiftConfigMustSelectGiftResource(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
frame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "frame_gold",
|
||
ResourceType: resourcedomain.TypeAvatarFrame,
|
||
Name: "Gold Frame",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create frame resource failed: %v", err)
|
||
}
|
||
_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||
GiftID: "bad-gift",
|
||
ResourceID: frame.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Bad Gift",
|
||
PriceVersion: "v1",
|
||
CoinPrice: 1,
|
||
GiftPointAmount: 1,
|
||
HeatValue: 1,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("expected INVALID_ARGUMENT for non-gift resource, got %v", err)
|
||
}
|
||
|
||
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_spark",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Spark",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create gift resource failed: %v", err)
|
||
}
|
||
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||
GiftID: "spark",
|
||
ResourceID: giftResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Spark",
|
||
PriceVersion: "v1",
|
||
CoinPrice: 5,
|
||
GiftPointAmount: 2,
|
||
HeatValue: 9,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create gift config failed: %v", err)
|
||
}
|
||
if gift.Resource.ResourceType != resourcedomain.TypeGift || gift.CoinPrice != 5 {
|
||
t.Fatalf("gift config mismatch: %+v", gift)
|
||
}
|
||
|
||
repository.SetBalance(41001, 100)
|
||
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||
CommandID: "cmd-spark-gift",
|
||
RoomID: "room-1",
|
||
SenderUserID: 41001,
|
||
TargetUserID: 41002,
|
||
GiftID: "spark",
|
||
GiftCount: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("DebitGift with resource-backed gift failed: %v", err)
|
||
}
|
||
if receipt.CoinSpent != 10 || receipt.GiftPointAdded != 4 || receipt.HeatValue != 18 {
|
||
t.Fatalf("resource-backed gift settlement mismatch: %+v", receipt)
|
||
}
|
||
}
|
||
|
||
func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
nowMs := time.Now().UnixMilli()
|
||
|
||
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_diamond_magic",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Diamond Magic",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create diamond gift resource failed: %v", err)
|
||
}
|
||
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||
GiftID: "diamond-magic",
|
||
ResourceID: giftResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Diamond Magic",
|
||
PriceVersion: "v1",
|
||
ChargeAssetType: ledger.AssetDiamond,
|
||
CoinPrice: 7,
|
||
GiftPointAmount: 3,
|
||
HeatValue: 9,
|
||
GiftTypeCode: resourcedomain.GiftTypeMagic,
|
||
EffectiveFromMS: nowMs - 1000,
|
||
EffectiveToMS: nowMs + 60_000,
|
||
EffectTypes: []string{resourcedomain.GiftEffectAnimation, resourcedomain.GiftEffectMusic},
|
||
OperatorUserID: 90001,
|
||
RegionIDs: []int64{0},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create diamond gift config failed: %v", err)
|
||
}
|
||
if gift.ChargeAssetType != ledger.AssetDiamond || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
|
||
t.Fatalf("diamond gift config mismatch: %+v", gift)
|
||
}
|
||
|
||
repository.SetAssetBalance(52001, ledger.AssetDiamond, 100)
|
||
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||
CommandID: "cmd-diamond-gift",
|
||
RoomID: "room-diamond",
|
||
SenderUserID: 52001,
|
||
TargetUserID: 52002,
|
||
GiftID: "diamond-magic",
|
||
GiftCount: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("DebitGift with diamond-backed gift failed: %v", err)
|
||
}
|
||
if receipt.ChargeAssetType != ledger.AssetDiamond || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
|
||
t.Fatalf("diamond gift settlement mismatch: %+v", receipt)
|
||
}
|
||
}
|
||
|
||
// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验,region_id=0 作为 GLOBAL 兜底区域。
|
||
func TestGiftConfigRegionFilter(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
globalResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_regional_global",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Regional Global",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create global gift resource failed: %v", err)
|
||
}
|
||
saudiResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_regional_saudi",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Regional Saudi",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create saudi gift resource failed: %v", err)
|
||
}
|
||
egyptResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_regional_egypt",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Regional Egypt",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create egypt gift resource failed: %v", err)
|
||
}
|
||
commands := []resourcedomain.GiftConfigCommand{
|
||
{
|
||
GiftID: "regional-global",
|
||
ResourceID: globalResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Regional Global",
|
||
PriceVersion: "v1",
|
||
CoinPrice: 1,
|
||
GiftPointAmount: 1,
|
||
HeatValue: 1,
|
||
OperatorUserID: 90001,
|
||
RegionIDs: []int64{0},
|
||
},
|
||
{
|
||
GiftID: "regional-saudi",
|
||
ResourceID: saudiResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Regional Saudi",
|
||
PriceVersion: "v1",
|
||
CoinPrice: 2,
|
||
GiftPointAmount: 2,
|
||
HeatValue: 2,
|
||
OperatorUserID: 90001,
|
||
RegionIDs: []int64{1001},
|
||
},
|
||
{
|
||
GiftID: "regional-egypt",
|
||
ResourceID: egyptResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Regional Egypt",
|
||
PriceVersion: "v1",
|
||
CoinPrice: 3,
|
||
GiftPointAmount: 3,
|
||
HeatValue: 3,
|
||
OperatorUserID: 90001,
|
||
RegionIDs: []int64{2002},
|
||
},
|
||
}
|
||
for _, command := range commands {
|
||
if _, err := svc.CreateGiftConfig(ctx, command); err != nil {
|
||
t.Fatalf("create gift config %s failed: %v", command.GiftID, err)
|
||
}
|
||
}
|
||
|
||
items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
|
||
Keyword: "regional-",
|
||
ActiveOnly: true,
|
||
FilterRegion: true,
|
||
RegionID: 1001,
|
||
Page: 1,
|
||
PageSize: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list region gifts failed: %v", err)
|
||
}
|
||
if total != 2 || !giftIDsContain(items, "regional-global") || !giftIDsContain(items, "regional-saudi") || giftIDsContain(items, "regional-egypt") {
|
||
t.Fatalf("region gift list mismatch total=%d items=%+v", total, items)
|
||
}
|
||
|
||
repository.SetBalance(51001, 100)
|
||
if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||
CommandID: "cmd-regional-egypt-in-saudi",
|
||
RoomID: "room-region-1001",
|
||
SenderUserID: 51001,
|
||
TargetUserID: 51002,
|
||
GiftID: "regional-egypt",
|
||
GiftCount: 1,
|
||
RegionID: 1001,
|
||
}); !xerr.IsCode(err, xerr.NotFound) {
|
||
t.Fatalf("expected NOT_FOUND when sending gift outside configured region, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestGrantResourceGroupExpandsWalletAssetAndEntitlement 验证资源组赠送会原子展开钱包资产入账和非钱包权益。
|
||
func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_vip",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "VIP Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create badge resource failed: %v", err)
|
||
}
|
||
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||
GroupCode: "starter_pack",
|
||
Name: "Starter Pack",
|
||
Status: resourcedomain.StatusActive,
|
||
Items: []resourcedomain.ResourceGroupItemInput{
|
||
{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1},
|
||
{ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2},
|
||
},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create resource group failed: %v", err)
|
||
}
|
||
|
||
command := resourcedomain.GrantResourceGroupCommand{
|
||
CommandID: "cmd-grant-starter-pack",
|
||
TargetUserID: 42001,
|
||
GroupID: group.GroupID,
|
||
Reason: "test grant",
|
||
OperatorUserID: 90001,
|
||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||
}
|
||
first, err := svc.GrantResourceGroup(ctx, command)
|
||
if err != nil {
|
||
t.Fatalf("GrantResourceGroup failed: %v", err)
|
||
}
|
||
second, err := svc.GrantResourceGroup(ctx, command)
|
||
if err != nil {
|
||
t.Fatalf("GrantResourceGroup retry failed: %v", err)
|
||
}
|
||
if first.GrantID == "" || first.GrantID != second.GrantID || len(first.Items) != 2 {
|
||
t.Fatalf("grant idempotency mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
|
||
balances, err := svc.GetBalances(ctx, 42001, []string{ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoin) != 2000 {
|
||
t.Fatalf("coin grant balance mismatch: %+v", balances)
|
||
}
|
||
if got := repository.CountRows("resource_grants", "command_id = ?", command.CommandID); got != 1 {
|
||
t.Fatalf("idempotent group grant should write one grant, got %d", got)
|
||
}
|
||
if got := repository.CountRows("resource_grant_items", "grant_id = ?", first.GrantID); got != 2 {
|
||
t.Fatalf("group grant should write two grant items, got %d", got)
|
||
}
|
||
if got := repository.CountRows("user_resource_entitlements", "user_id = ?", int64(42001)); got != 1 {
|
||
t.Fatalf("group grant should write one non-coin entitlement, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ?", int64(42001), ledger.AssetCoin); got != 1 {
|
||
t.Fatalf("group grant should write one coin wallet entry, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestActiveResourceGroupRejectsUngrantableResource 验证 active 资源组不能保存或启用当前不可发放的资源项。
|
||
func TestActiveResourceGroupRejectsUngrantableResource(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
disabledBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_disabled",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Disabled Badge",
|
||
Status: resourcedomain.StatusDisabled,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create disabled badge failed: %v", err)
|
||
}
|
||
_, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||
GroupCode: "active_badge_disabled",
|
||
Name: "Active Disabled Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Items: []resourcedomain.ResourceGroupItemInput{
|
||
{ResourceID: disabledBadge.ResourceID, Quantity: 1},
|
||
},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if !xerr.IsCode(err, xerr.Conflict) {
|
||
t.Fatalf("expected CONFLICT for disabled group resource, got %v", err)
|
||
}
|
||
|
||
coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "coin_resource_forbidden_in_group",
|
||
ResourceType: resourcedomain.TypeCoin,
|
||
Name: "Coin Resource",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyWalletCredit,
|
||
WalletAssetType: ledger.AssetCoin,
|
||
WalletAssetAmount: 1000,
|
||
UsageScopes: []string{"grant"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create coin resource failed: %v", err)
|
||
}
|
||
_, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||
GroupCode: "active_coin_resource",
|
||
Name: "Active Coin Resource",
|
||
Status: resourcedomain.StatusActive,
|
||
Items: []resourcedomain.ResourceGroupItemInput{
|
||
{ResourceID: coinResource.ResourceID, Quantity: 1},
|
||
},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("expected INVALID_ARGUMENT for coin resource group item, got %v", err)
|
||
}
|
||
|
||
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||
GroupCode: "disabled_badge_staging",
|
||
Name: "Disabled Badge Staging",
|
||
Status: resourcedomain.StatusDisabled,
|
||
Items: []resourcedomain.ResourceGroupItemInput{
|
||
{ResourceID: disabledBadge.ResourceID, Quantity: 1},
|
||
},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("disabled resource group should be saveable before enable: %v", err)
|
||
}
|
||
_, err = svc.SetResourceGroupStatus(ctx, resourcedomain.StatusCommand{
|
||
ID: group.GroupID,
|
||
Status: resourcedomain.StatusActive,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if !xerr.IsCode(err, xerr.Conflict) {
|
||
t.Fatalf("expected CONFLICT when enabling group with disabled resource, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestEquipUserResourceRequiresActiveEquipableEntitlement 验证用户只能佩戴自己有效的可佩戴权益。
|
||
func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_equippable",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Equippable Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create badge resource failed: %v", err)
|
||
}
|
||
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||
CommandID: "cmd-grant-equip-badge",
|
||
TargetUserID: 43001,
|
||
ResourceID: badgeResource.ResourceID,
|
||
Quantity: 1,
|
||
Reason: "test equip",
|
||
OperatorUserID: 90001,
|
||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("grant badge resource failed: %v", err)
|
||
}
|
||
equipped, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
UserID: 43001,
|
||
ResourceID: badgeResource.ResourceID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("equip badge resource failed: %v", err)
|
||
}
|
||
if equipped.EntitlementID == "" || equipped.EntitlementID != grant.Items[0].EntitlementID {
|
||
t.Fatalf("equipped entitlement mismatch: equipped=%+v grant=%+v", equipped, grant)
|
||
}
|
||
repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
UserID: 43001,
|
||
ResourceID: badgeResource.ResourceID,
|
||
EntitlementID: equipped.EntitlementID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("repeat equip badge resource failed: %v", err)
|
||
}
|
||
if repeated.EntitlementID != equipped.EntitlementID {
|
||
t.Fatalf("repeat equip should keep entitlement: repeated=%+v equipped=%+v", repeated, equipped)
|
||
}
|
||
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 1 {
|
||
t.Fatalf("equipment should keep one active badge, got %d", got)
|
||
}
|
||
|
||
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_not_equipable",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Not Equipable Gift",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create gift resource failed: %v", err)
|
||
}
|
||
if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||
CommandID: "cmd-grant-non-equipable-gift",
|
||
TargetUserID: 43001,
|
||
ResourceID: giftResource.ResourceID,
|
||
Quantity: 1,
|
||
Reason: "test non equip",
|
||
OperatorUserID: 90001,
|
||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||
}); err != nil {
|
||
t.Fatalf("grant gift resource failed: %v", err)
|
||
}
|
||
_, err = svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
UserID: 43001,
|
||
ResourceID: giftResource.ResourceID,
|
||
})
|
||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("expected INVALID_ARGUMENT for non-equipable resource, got %v", err)
|
||
}
|
||
}
|
||
|
||
func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 {
|
||
for _, balance := range balances {
|
||
if balance.AssetType == assetType {
|
||
return balance.AvailableAmount
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
|
||
for _, item := range items {
|
||
if item.GiftID == giftID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|