2026-05-02 13:02:38 +08:00

761 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package wallet_test
import (
"context"
"testing"
"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)
}
}
// 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
}