2365 lines
91 KiB
Go
2365 lines
91 KiB
Go
package wallet_test
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"testing"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||
"hyapp/pkg/appcode"
|
||
"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)
|
||
}
|
||
}
|
||
|
||
// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼,扣 COIN 和收 GIFT_POINT 仍然分别落账。
|
||
func TestDebitGiftAllowsSelfGift(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-self-gift",
|
||
RoomID: "room-1",
|
||
SenderUserID: 10001,
|
||
TargetUserID: 10001,
|
||
GiftID: "rose",
|
||
GiftCount: 2,
|
||
PriceVersion: "v9",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("self DebitGift failed: %v", err)
|
||
}
|
||
|
||
if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 6 || receipt.BalanceAfter != 86 {
|
||
t.Fatalf("self gift settlement mismatch: %+v", receipt)
|
||
}
|
||
balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoin) != 86 || balanceAmount(balances, ledger.AssetGiftPoint) != 6 {
|
||
t.Fatalf("self gift balances mismatch: %+v", balances)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
|
||
t.Fatalf("self gift should still write debit and credit entries, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。
|
||
func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(10001, 1000)
|
||
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
|
||
repository.SetGiftPrice("rocket", "v2", 100, 50, 200)
|
||
svc := walletservice.New(repository)
|
||
|
||
commands := []ledger.DebitGiftCommand{
|
||
{CommandID: "cmd-wall-rose-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9"},
|
||
{CommandID: "cmd-wall-rose-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 3, PriceVersion: "v9"},
|
||
{CommandID: "cmd-wall-rocket", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rocket", GiftCount: 1, PriceVersion: "v2"},
|
||
}
|
||
for _, command := range commands {
|
||
if _, err := svc.DebitGift(context.Background(), command); err != nil {
|
||
t.Fatalf("DebitGift %s failed: %v", command.CommandID, err)
|
||
}
|
||
}
|
||
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 {
|
||
t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got)
|
||
}
|
||
processed, err := svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute)
|
||
if err != nil {
|
||
t.Fatalf("ProjectPendingGiftWallEvents failed: %v", err)
|
||
}
|
||
if processed != 3 {
|
||
t.Fatalf("projection should process three gift events, got %d", processed)
|
||
}
|
||
processed, err = svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute)
|
||
if err != nil {
|
||
t.Fatalf("ProjectPendingGiftWallEvents retry failed: %v", err)
|
||
}
|
||
if processed != 0 {
|
||
t.Fatalf("projection must be idempotent after done events, got %d", processed)
|
||
}
|
||
|
||
wall, err := svc.GetUserGiftWall(context.Background(), 10002)
|
||
if err != nil {
|
||
t.Fatalf("GetUserGiftWall failed: %v", err)
|
||
}
|
||
if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 255 {
|
||
t.Fatalf("gift wall summary mismatch: %+v", wall)
|
||
}
|
||
if len(wall.Items) != 2 || wall.Items[0].GiftID != "rose" || wall.Items[0].GiftCount != 5 || wall.Items[0].TotalValue != 35 {
|
||
t.Fatalf("rose gift wall item mismatch: %+v", wall.Items)
|
||
}
|
||
if wall.Items[0].Resource.ResourceID == 0 || wall.Items[0].ResourceSnapshotJSON == "" {
|
||
t.Fatalf("gift wall should keep display resource snapshot: %+v", wall.Items[0])
|
||
}
|
||
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 {
|
||
t.Fatalf("gift wall should keep one row per gift_id, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "user_gift_wall", "done"); got != 3 {
|
||
t.Fatalf("projection should mark each gift event done, 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)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditAssetAllowsSignedAdjustment 验证后台调账同一 RPC 可表达加金币和扣金币,且扣账不能穿透余额约束。
|
||
func TestAdminCreditAssetAllowsSignedAdjustment(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
credit := ledger.AdminCreditAssetCommand{
|
||
CommandID: "cmd-admin-adjust-credit",
|
||
TargetUserID: 20002,
|
||
AssetType: ledger.AssetCoin,
|
||
Amount: 500,
|
||
OperatorUserID: 90001,
|
||
Reason: "manual credit",
|
||
EvidenceRef: "ticket-credit",
|
||
}
|
||
if _, _, err := svc.AdminCreditAsset(context.Background(), credit); err != nil {
|
||
t.Fatalf("credit adjustment failed: %v", err)
|
||
}
|
||
|
||
debit := credit
|
||
debit.CommandID = "cmd-admin-adjust-debit"
|
||
debit.Amount = -120
|
||
debit.Reason = "manual debit"
|
||
debit.EvidenceRef = "ticket-debit"
|
||
balance, txID, err := svc.AdminCreditAsset(context.Background(), debit)
|
||
if err != nil {
|
||
t.Fatalf("debit adjustment failed: %v", err)
|
||
}
|
||
if txID == "" || balance.AvailableAmount != 380 {
|
||
t.Fatalf("debit balance mismatch: balance=%+v tx=%s", balance, txID)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", txID, int64(-120)); got != 1 {
|
||
t.Fatalf("debit adjustment should write one negative entry, got %d", got)
|
||
}
|
||
|
||
overdraw := credit
|
||
overdraw.CommandID = "cmd-admin-adjust-overdraw"
|
||
overdraw.Amount = -1000
|
||
overdraw.Reason = "manual overdraw"
|
||
overdraw.EvidenceRef = "ticket-overdraw"
|
||
if _, _, err := svc.AdminCreditAsset(context.Background(), overdraw); err == nil {
|
||
t.Fatalf("overdraw adjustment must fail")
|
||
}
|
||
}
|
||
|
||
func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(19001, 1000)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
config, err := svc.UpdateRedPacketConfig(ctx, ledger.RedPacketConfig{
|
||
AppCode: "lalu",
|
||
Enabled: true,
|
||
CountTiers: []int32{5},
|
||
AmountTiers: []int64{10},
|
||
DelayedOpenSeconds: 1,
|
||
ExpireSeconds: 2,
|
||
DailySendLimit: 5,
|
||
UpdatedByAdminID: 9001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpdateRedPacketConfig failed: %v", err)
|
||
}
|
||
if !config.Enabled || len(config.CountTiers) != 1 || len(config.AmountTiers) != 1 {
|
||
t.Fatalf("red packet config mismatch: %+v", config)
|
||
}
|
||
|
||
normal, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "cmd-red-packet-normal",
|
||
SenderUserID: 19001,
|
||
RoomID: "room-190",
|
||
RegionID: 190,
|
||
PacketType: ledger.RedPacketTypeNormal,
|
||
TotalAmount: 10,
|
||
PacketCount: 5,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRedPacket normal failed: %v", err)
|
||
}
|
||
if normal.BalanceAfter != 990 || normal.Packet.Status != ledger.RedPacketStatusActive {
|
||
t.Fatalf("normal red packet receipt mismatch: %+v", normal)
|
||
}
|
||
normalClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "cmd-red-packet-normal-claim",
|
||
PacketID: normal.Packet.PacketID,
|
||
UserID: 19002,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ClaimRedPacket normal failed: %v", err)
|
||
}
|
||
normalAfterClaim, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 19002, true)
|
||
if err != nil {
|
||
t.Fatalf("GetRedPacket normal failed: %v", err)
|
||
}
|
||
if !normalAfterClaim.ClaimedByViewer || normalAfterClaim.ViewerClaimAmount != normalClaim.Claim.Amount {
|
||
t.Fatalf("normal viewer claim mismatch: packet=%+v claim=%+v", normalAfterClaim, normalClaim)
|
||
}
|
||
time.Sleep(2100 * time.Millisecond)
|
||
expired, err := svc.ExpireRedPackets(ctx, "lalu", 10)
|
||
if err != nil {
|
||
t.Fatalf("ExpireRedPackets normal failed: %v", err)
|
||
}
|
||
if expired.ExpiredCount != 1 || expired.RefundedAmount != normalAfterClaim.RemainingAmount {
|
||
t.Fatalf("normal expire result mismatch: %+v remaining=%d", expired, normalAfterClaim.RemainingAmount)
|
||
}
|
||
normalExpired, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 0, false)
|
||
if err != nil {
|
||
t.Fatalf("GetRedPacket normal expired failed: %v", err)
|
||
}
|
||
if normalExpired.Status != ledger.RedPacketStatusRefunded || normalExpired.RemainingAmount != 0 || normalExpired.RemainingCount != 0 {
|
||
t.Fatalf("normal red packet should be refunded: %+v", normalExpired)
|
||
}
|
||
|
||
delayed, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "cmd-red-packet-delayed",
|
||
SenderUserID: 19001,
|
||
RoomID: "room-190",
|
||
RegionID: 190,
|
||
PacketType: ledger.RedPacketTypeDelayed,
|
||
TotalAmount: 10,
|
||
PacketCount: 5,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRedPacket delayed failed: %v", err)
|
||
}
|
||
if delayed.Packet.Status != ledger.RedPacketStatusWaitingOpen {
|
||
t.Fatalf("delayed red packet should wait to open: %+v", delayed.Packet)
|
||
}
|
||
_, err = svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "cmd-red-packet-delayed-too-early",
|
||
PacketID: delayed.Packet.PacketID,
|
||
UserID: 19003,
|
||
})
|
||
if xerr.CodeOf(err) != xerr.RedPacketNotOpen {
|
||
t.Fatalf("delayed claim before open code=%s err=%v", xerr.CodeOf(err), err)
|
||
}
|
||
time.Sleep(1100 * time.Millisecond)
|
||
delayedClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "cmd-red-packet-delayed-claim",
|
||
PacketID: delayed.Packet.PacketID,
|
||
UserID: 19003,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ClaimRedPacket delayed after open failed: %v", err)
|
||
}
|
||
if delayedClaim.Claim.Amount <= 0 {
|
||
t.Fatalf("delayed claim amount should be positive: %+v", delayedClaim)
|
||
}
|
||
}
|
||
|
||
func TestWalletOutboxClaimUsesRealMySQLStatusFlow(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetAssetBalance(30001, ledger.AssetCoinSellerCoin, 1000)
|
||
repository.SetRechargePolicy(86, "policy-real-outbox", 100, 100)
|
||
svc := walletservice.New(repository)
|
||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||
|
||
receipt, err := svc.TransferCoinFromSeller(ctx, ledger.CoinSellerTransferCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "cmd-real-outbox-transfer",
|
||
SellerUserID: 30001,
|
||
TargetUserID: 30002,
|
||
SellerRegionID: 86,
|
||
TargetRegionID: 86,
|
||
Amount: 200,
|
||
Reason: "real outbox claim test",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("TransferCoinFromSeller failed: %v", err)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ?", receipt.TransactionID, "pending"); got != 4 {
|
||
t.Fatalf("transfer should create four pending wallet outbox facts, got %d", got)
|
||
}
|
||
|
||
records, err := repository.ClaimPendingWalletOutbox(ctx, "wallet-outbox-real-test", 4, time.Now().Add(time.Minute).UnixMilli())
|
||
if err != nil {
|
||
t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
|
||
}
|
||
if len(records) != 4 {
|
||
t.Fatalf("expected four claimed records, got %d: %+v", len(records), records)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ? AND worker_id = ?", receipt.TransactionID, "delivering", "wallet-outbox-real-test"); got != 4 {
|
||
t.Fatalf("claim should mark all rows delivering for worker, got %d", got)
|
||
}
|
||
var rechargeEventID string
|
||
for _, record := range records {
|
||
if record.EventType == "WalletRechargeRecorded" {
|
||
rechargeEventID = record.EventID
|
||
}
|
||
if record.AppCode != "lalu" || record.TransactionID != receipt.TransactionID || record.PayloadJSON == "" {
|
||
t.Fatalf("claimed record should contain stable wallet fact: %+v", record)
|
||
}
|
||
}
|
||
if rechargeEventID == "" {
|
||
t.Fatalf("claimed records should include WalletRechargeRecorded: %+v", records)
|
||
}
|
||
|
||
if err := repository.MarkWalletOutboxDelivered(ctx, rechargeEventID); err != nil {
|
||
t.Fatalf("MarkWalletOutboxDelivered failed: %v", err)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND (worker_id = '' OR worker_id IS NULL)", rechargeEventID, "delivered"); got != 1 {
|
||
t.Fatalf("delivered record should release worker lock, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestCreditTaskRewardIsIdempotent 验证任务奖励走独立账务类型且重复领奖命令不会重复发金币。
|
||
func TestCreditTaskRewardIsIdempotent(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.TaskRewardCommand{
|
||
CommandID: "cmd-task-reward",
|
||
TargetUserID: 21001,
|
||
Amount: 88,
|
||
TaskType: "daily",
|
||
TaskID: "task-1",
|
||
CycleKey: "2026-05-09",
|
||
Reason: "daily_task_reward",
|
||
}
|
||
|
||
first, err := svc.CreditTaskReward(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("CreditTaskReward failed: %v", err)
|
||
}
|
||
second, err := svc.CreditTaskReward(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("CreditTaskReward retry failed: %v", err)
|
||
}
|
||
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.Amount != 88 || second.Balance.AvailableAmount != 88 {
|
||
t.Fatalf("task reward receipt mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
balances, err := svc.GetBalances(context.Background(), 21001, []string{ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoin) != 88 {
|
||
t.Fatalf("task reward balance mismatch: %+v", balances)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "task_reward"); got != 1 {
|
||
t.Fatalf("task reward should write one transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
|
||
t.Fatalf("task reward should write one entry, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
|
||
t.Fatalf("task reward should write balance and task outbox events, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestCreditLuckyGiftRewardIsIdempotent 验证幸运礼物返奖只按 draw_id 派一次,并产出余额通知事实。
|
||
func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.LuckyGiftRewardCommand{
|
||
CommandID: "lucky_reward:lucky_draw_1",
|
||
TargetUserID: 21002,
|
||
Amount: 188,
|
||
DrawID: "lucky_draw_1",
|
||
RoomID: "room-1",
|
||
GiftID: "rose",
|
||
PoolID: "super_lucky",
|
||
Reason: "lucky_gift_reward",
|
||
}
|
||
|
||
first, err := svc.CreditLuckyGiftReward(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("CreditLuckyGiftReward failed: %v", err)
|
||
}
|
||
second, err := svc.CreditLuckyGiftReward(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("CreditLuckyGiftReward retry failed: %v", err)
|
||
}
|
||
if first.TransactionID == "" || first.TransactionID != second.TransactionID || second.Balance.AvailableAmount != 188 {
|
||
t.Fatalf("lucky reward receipt mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "lucky_gift_reward"); got != 1 {
|
||
t.Fatalf("lucky reward should write one transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletBalanceChanged"); got != 1 {
|
||
t.Fatalf("lucky reward should write one balance change event, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
|
||
t.Fatalf("lucky reward should write one reward fact event, got %d", got)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。
|
||
func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.CoinSellerStockCreditCommand{
|
||
CommandID: "cmd-stock-usdt",
|
||
SellerUserID: 33001,
|
||
StockType: ledger.StockTypeUSDTPurchase,
|
||
CoinAmount: 8000000,
|
||
PaidAmountMicro: 100000000,
|
||
OperatorUserID: 90001,
|
||
}
|
||
|
||
first, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("AdminCreditCoinSellerStock purchase failed: %v", err)
|
||
}
|
||
second, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||
if err != nil {
|
||
t.Fatalf("AdminCreditCoinSellerStock retry failed: %v", err)
|
||
}
|
||
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 8000000 || !first.CountsAsSellerRecharge || first.PaidCurrencyCode != ledger.PaidCurrencyUSDT {
|
||
t.Fatalf("stock purchase receipt mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
|
||
balances, err := svc.GetBalances(context.Background(), 33001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances seller failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 8000000 || balanceAmount(balances, ledger.AssetCoin) != 0 {
|
||
t.Fatalf("seller stock balances mismatch: %+v", balances)
|
||
}
|
||
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE", first.TransactionID); got != 1 {
|
||
t.Fatalf("stock purchase should write one seller recharge record, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
|
||
t.Fatalf("stock purchase should write one wallet entry, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
|
||
t.Fatalf("seller stock purchase must not write player recharge record, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
|
||
t.Fatalf("stock purchase should write balance and stock outbox events, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount 锁定补偿不能携带充值金额的边界。
|
||
func TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
_, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
|
||
CommandID: "cmd-stock-comp-invalid",
|
||
SellerUserID: 34001,
|
||
StockType: ledger.StockTypeCoinCompensation,
|
||
CoinAmount: 50000,
|
||
PaidCurrencyCode: ledger.PaidCurrencyUSDT,
|
||
PaidAmountMicro: 1,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if !xerr.IsCode(err, xerr.CoinSellerStockAmountInvalid) {
|
||
t.Fatalf("expected COIN_SELLER_STOCK_AMOUNT_INVALID, got %v", err)
|
||
}
|
||
if got := repository.CountRows("coin_seller_stock_records", "command_id = ?", "cmd-stock-comp-invalid"); got != 0 {
|
||
t.Fatalf("invalid compensation must not write stock record, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount 验证金币补偿不计入币商进货金额。
|
||
func TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
receipt, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
|
||
CommandID: "cmd-stock-comp",
|
||
SellerUserID: 35001,
|
||
StockType: ledger.StockTypeCoinCompensation,
|
||
CoinAmount: 50000,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("AdminCreditCoinSellerStock compensation failed: %v", err)
|
||
}
|
||
if receipt.CountsAsSellerRecharge || receipt.PaidAmountMicro != 0 || receipt.PaidCurrencyCode != "" || receipt.BalanceAfter != 50000 {
|
||
t.Fatalf("stock compensation receipt mismatch: %+v", receipt)
|
||
}
|
||
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = FALSE AND paid_amount_micro = 0", receipt.TransactionID); got != 1 {
|
||
t.Fatalf("compensation should write one non-recharge stock record, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef 防止同一 USDT 凭证重复给库存入账。
|
||
func TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.CoinSellerStockCreditCommand{
|
||
CommandID: "cmd-stock-ref-1",
|
||
SellerUserID: 36001,
|
||
StockType: ledger.StockTypeUSDTPurchase,
|
||
CoinAmount: 100,
|
||
PaidAmountMicro: 1000000,
|
||
PaymentRef: "usdt-ref-dup",
|
||
EvidenceRef: "oss://receipt-dup-1",
|
||
OperatorUserID: 90001,
|
||
Reason: "first purchase",
|
||
}
|
||
if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
|
||
t.Fatalf("first stock purchase failed: %v", err)
|
||
}
|
||
command.CommandID = "cmd-stock-ref-2"
|
||
command.SellerUserID = 36002
|
||
command.EvidenceRef = "oss://receipt-dup-2"
|
||
_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||
if !xerr.IsCode(err, xerr.CoinSellerPaymentRefDuplicated) {
|
||
t.Fatalf("expected COIN_SELLER_PAYMENT_REF_DUPLICATED, got %v", err)
|
||
}
|
||
if got := repository.CountRows("coin_seller_stock_records", "payment_ref = ?", "usdt-ref-dup"); got != 1 {
|
||
t.Fatalf("duplicate payment_ref should keep one stock record, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminCreditCoinSellerStockRejectsIdempotencyConflict 锁定 command_id 相同但 payload 不同必须 fail-closed。
|
||
func TestAdminCreditCoinSellerStockRejectsIdempotencyConflict(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.CoinSellerStockCreditCommand{
|
||
CommandID: "cmd-stock-conflict",
|
||
SellerUserID: 37001,
|
||
StockType: ledger.StockTypeUSDTPurchase,
|
||
CoinAmount: 100,
|
||
PaidAmountMicro: 1000000,
|
||
PaymentRef: "usdt-ref-conflict",
|
||
EvidenceRef: "oss://receipt-conflict",
|
||
OperatorUserID: 90001,
|
||
Reason: "first purchase",
|
||
}
|
||
if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
|
||
t.Fatalf("first stock purchase failed: %v", err)
|
||
}
|
||
command.CoinAmount = 200
|
||
_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||
if !xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||
t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
paidResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_price_sync",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Price Sync Gift",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
PriceType: resourcedomain.PriceTypeCoin,
|
||
CoinPrice: 77,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create priced gift resource failed: %v", err)
|
||
}
|
||
if paidResource.GiftPointAmount != 77 {
|
||
t.Fatalf("resource gift point should sync coin price: %+v", paidResource)
|
||
}
|
||
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||
GiftID: "price-sync",
|
||
ResourceID: paidResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Price Sync Gift",
|
||
PriceVersion: "v1",
|
||
ChargeAssetType: ledger.AssetDiamond,
|
||
CoinPrice: 1,
|
||
GiftPointAmount: 1,
|
||
HeatValue: 3,
|
||
GiftTypeCode: resourcedomain.GiftTypeNormal,
|
||
OperatorUserID: 90001,
|
||
RegionIDs: []int64{0},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create synced gift config failed: %v", err)
|
||
}
|
||
if gift.ChargeAssetType != ledger.AssetCoin || gift.CoinPrice != 77 || gift.GiftPointAmount != 77 {
|
||
t.Fatalf("gift price should be overridden by resource price: %+v", gift)
|
||
}
|
||
|
||
freeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "gift_free_sync",
|
||
ResourceType: resourcedomain.TypeGift,
|
||
Name: "Free Sync Gift",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||
PriceType: resourcedomain.PriceTypeFree,
|
||
UsageScopes: []string{"gift"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create free gift resource failed: %v", err)
|
||
}
|
||
freeGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||
GiftID: "free-sync",
|
||
ResourceID: freeResource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
Name: "Free Sync Gift",
|
||
PriceVersion: "v1",
|
||
CoinPrice: 9,
|
||
GiftPointAmount: 9,
|
||
HeatValue: 0,
|
||
GiftTypeCode: resourcedomain.GiftTypeNormal,
|
||
OperatorUserID: 90001,
|
||
RegionIDs: []int64{0},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create free synced gift config failed: %v", err)
|
||
}
|
||
if freeGift.CoinPrice != 0 || freeGift.GiftPointAmount != 0 {
|
||
t.Fatalf("free gift price should sync to zero: %+v", freeGift)
|
||
}
|
||
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||
CommandID: "cmd-free-sync",
|
||
RoomID: "room-free",
|
||
SenderUserID: 53001,
|
||
TargetUserID: 53002,
|
||
GiftID: "free-sync",
|
||
GiftCount: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("free gift debit should not require existing balance: %v", err)
|
||
}
|
||
if receipt.ChargeAmount != 0 || receipt.GiftPointAdded != 0 {
|
||
t.Fatalf("free gift receipt mismatch: %+v", receipt)
|
||
}
|
||
}
|
||
|
||
func TestGiftTypeConfigDefaultsAndUpsert(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
items, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{})
|
||
if err != nil {
|
||
t.Fatalf("list gift type configs failed: %v", err)
|
||
}
|
||
if len(items) != 10 || items[0].TypeCode != resourcedomain.GiftTypeNormal || items[0].TabKey != resourcedomain.GiftTypeTabKeyNormal {
|
||
t.Fatalf("default gift type configs mismatch: %+v", items)
|
||
}
|
||
|
||
updated, err := svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{
|
||
TypeCode: resourcedomain.GiftTypeNormal,
|
||
Name: "普通礼物",
|
||
TabKey: "Gift",
|
||
Status: resourcedomain.StatusDisabled,
|
||
SortOrder: 11,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("update gift type config failed: %v", err)
|
||
}
|
||
if updated.Status != resourcedomain.StatusDisabled || updated.SortOrder != 11 {
|
||
t.Fatalf("updated gift type config mismatch: %+v", updated)
|
||
}
|
||
|
||
activeItems, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{ActiveOnly: true})
|
||
if err != nil {
|
||
t.Fatalf("list active gift type configs failed: %v", err)
|
||
}
|
||
if giftTypeCodesContain(activeItems, resourcedomain.GiftTypeNormal) {
|
||
t.Fatalf("disabled gift type must not be returned as active: %+v", activeItems)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。
|
||
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(53001, 1000)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "shop_frame_gold",
|
||
ResourceType: resourcedomain.TypeAvatarFrame,
|
||
Name: "Gold Frame",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
|
||
PriceType: resourcedomain.PriceTypeCoin,
|
||
CoinPrice: 100,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create shop resource failed: %v", err)
|
||
}
|
||
shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||
Items: []resourcedomain.ResourceShopItemInput{{
|
||
ResourceID: resource.ResourceID,
|
||
Status: resourcedomain.StatusActive,
|
||
DurationDays: resourcedomain.ShopDurationThreeDays,
|
||
SortOrder: 1,
|
||
}},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("upsert shop item failed: %v", err)
|
||
}
|
||
|
||
receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||
CommandID: "cmd-shop-frame",
|
||
UserID: 53001,
|
||
ShopItemID: shopItems[0].ShopItemID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("PurchaseResourceShopItem failed: %v", err)
|
||
}
|
||
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 300 || receipt.Balance.AvailableAmount != 700 {
|
||
t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt)
|
||
}
|
||
if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() {
|
||
t.Fatalf("resource shop entitlement mismatch: %+v", receipt.Resource)
|
||
}
|
||
|
||
again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||
CommandID: "cmd-shop-frame",
|
||
UserID: 53001,
|
||
ShopItemID: shopItems[0].ShopItemID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("PurchaseResourceShopItem retry failed: %v", err)
|
||
}
|
||
if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount {
|
||
t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again)
|
||
}
|
||
if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 {
|
||
t.Fatalf("resource shop order should be persisted once, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-frame", "resource_shop_purchase"); got != 1 {
|
||
t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-300)); got != 1 {
|
||
t.Fatalf("resource shop purchase should write one debit entry, got %d", got)
|
||
}
|
||
if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 {
|
||
t.Fatalf("resource shop purchase should write one resource grant, got %d", got)
|
||
}
|
||
if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(53001), resource.ResourceID); got != 1 {
|
||
t.Fatalf("resource shop purchase should grant one entitlement, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
activity := &fakeActivityBadgeClient{status: "consumed"}
|
||
svc := walletservice.New(repository, activity)
|
||
ctx := context.Background()
|
||
|
||
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_projection_tile",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Projection Tile Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
ManagerGrantEnabled: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
MetadataJSON: `{"badge_form":"tile"}`,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create badge resource failed: %v", err)
|
||
}
|
||
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||
CommandID: "cmd-badge-projection",
|
||
TargetUserID: 42002,
|
||
ResourceID: badgeResource.ResourceID,
|
||
Quantity: 1,
|
||
Reason: "system badge grant",
|
||
OperatorUserID: 90001,
|
||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("GrantResource failed: %v", err)
|
||
}
|
||
|
||
processed, err := svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute)
|
||
if err != nil {
|
||
t.Fatalf("ProjectPendingBadgeGrantEvents failed: %v", err)
|
||
}
|
||
if processed != 1 || len(activity.requests) != 1 {
|
||
t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests))
|
||
}
|
||
req := activity.requests[0]
|
||
if req.GetEventType() != "ResourceGranted" || req.GetSourceService() != "wallet-service" || req.GetMetricType() != "wallet_badge_grant" || req.GetUserId() != 42002 {
|
||
t.Fatalf("activity request mismatch: %+v", req)
|
||
}
|
||
var payload struct {
|
||
GrantID string `json:"grant_id"`
|
||
GrantSource string `json:"grant_source"`
|
||
Items []struct {
|
||
ResourceID int64 `json:"resource_id"`
|
||
ResourceType string `json:"resource_type"`
|
||
EntitlementID string `json:"entitlement_id"`
|
||
MetadataJSON string `json:"metadata_json"`
|
||
} `json:"items"`
|
||
}
|
||
if err := json.Unmarshal([]byte(req.GetDimensionsJson()), &payload); err != nil {
|
||
t.Fatalf("decode badge projection payload failed: %v", err)
|
||
}
|
||
if payload.GrantID != grant.GrantID || payload.GrantSource != resourcedomain.GrantSourceAdmin || len(payload.Items) != 1 {
|
||
t.Fatalf("badge projection payload mismatch: %+v", payload)
|
||
}
|
||
if payload.Items[0].ResourceID != badgeResource.ResourceID || payload.Items[0].ResourceType != resourcedomain.TypeBadge || payload.Items[0].EntitlementID == "" {
|
||
t.Fatalf("badge projection item mismatch: %+v", payload.Items[0])
|
||
}
|
||
if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 {
|
||
t.Fatalf("badge projection should mark event done, got %d", got)
|
||
}
|
||
processed, err = svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute)
|
||
if err != nil || processed != 0 || len(activity.requests) != 1 {
|
||
t.Fatalf("badge projection must be idempotent: processed=%d requests=%d err=%v", processed, len(activity.requests), err)
|
||
}
|
||
}
|
||
|
||
// TestManagerCenterResourceGrantFiltersAndRechecksSwitch 锁定经理中心资源开关必须同时约束列表和写事务。
|
||
func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
enabled, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_manager_enabled",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Manager Enabled Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
ManagerGrantEnabled: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create manager enabled resource failed: %v", err)
|
||
}
|
||
disabledForManager, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_manager_disabled",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Manager Disabled Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
ManagerGrantEnabled: false,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create manager disabled resource failed: %v", err)
|
||
}
|
||
if _, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||
ResourceCode: "badge_disabled_status",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Disabled Badge",
|
||
Status: resourcedomain.StatusDisabled,
|
||
Grantable: true,
|
||
ManagerGrantEnabled: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
}); err != nil {
|
||
t.Fatalf("create disabled status resource failed: %v", err)
|
||
}
|
||
|
||
items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
ManagerGrantOnly: true,
|
||
Page: 1,
|
||
PageSize: 20,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ListResources manager grant only failed: %v", err)
|
||
}
|
||
if total != 1 || len(items) != 1 || items[0].ResourceID != enabled.ResourceID {
|
||
t.Fatalf("manager grant resource list mismatch: total=%d items=%+v", total, items)
|
||
}
|
||
|
||
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||
CommandID: "cmd-manager-grant-enabled",
|
||
TargetUserID: 43001,
|
||
ResourceID: enabled.ResourceID,
|
||
Quantity: 1,
|
||
Reason: "manager center",
|
||
OperatorUserID: 91001,
|
||
GrantSource: resourcedomain.GrantSourceManagerCenter,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("manager center grant enabled resource failed: %v", err)
|
||
}
|
||
if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 {
|
||
t.Fatalf("manager grant response mismatch: %+v", grant)
|
||
}
|
||
|
||
_, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||
CommandID: "cmd-manager-grant-disabled",
|
||
TargetUserID: 43001,
|
||
ResourceID: disabledForManager.ResourceID,
|
||
Quantity: 1,
|
||
Reason: "manager center",
|
||
OperatorUserID: 91001,
|
||
GrantSource: resourcedomain.GrantSourceManagerCenter,
|
||
})
|
||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||
t.Fatalf("expected PERMISSION_DENIED for manager disabled resource, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
ctx := context.Background()
|
||
|
||
command := resourcedomain.ResourceCommand{
|
||
ResourceCode: "duplicate_badge",
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
Name: "Duplicate Badge",
|
||
Status: resourcedomain.StatusActive,
|
||
Grantable: true,
|
||
ManagerGrantEnabled: true,
|
||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||
UsageScopes: []string{"profile"},
|
||
OperatorUserID: 90001,
|
||
}
|
||
if _, err := svc.CreateResource(ctx, command); err != nil {
|
||
t.Fatalf("first CreateResource failed: %v", err)
|
||
}
|
||
command.Name = "Duplicate Badge Again"
|
||
if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) {
|
||
t.Fatalf("duplicate resource_code should be conflict, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
|
||
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
|
||
_, err := svc.GrantResourceGroup(context.Background(), resourcedomain.GrantResourceGroupCommand{
|
||
CommandID: "cmd-manager-grant-group",
|
||
TargetUserID: 43002,
|
||
GroupID: 101,
|
||
Reason: "manager center",
|
||
OperatorUserID: 91001,
|
||
GrantSource: resourcedomain.GrantSourceManagerCenter,
|
||
})
|
||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("expected INVALID_ARGUMENT for manager center group grant, got %v", err)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
if !equipped.Equipped {
|
||
t.Fatalf("equip response should mark entitlement as equipped: %+v", equipped)
|
||
}
|
||
resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
|
||
UserID: 43001,
|
||
ResourceType: resourcedomain.TypeBadge,
|
||
ActiveOnly: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list user resources failed: %v", err)
|
||
}
|
||
if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != equipped.EntitlementID {
|
||
t.Fatalf("resource list should expose equipped entitlement: resources=%+v equipped=%+v", resources, equipped)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// TestApplyWithdrawalFreezesUSDBalance 验证提现申请只创建待审核事实,并把可提现美元余额转入冻结。
|
||
func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetAssetBalance(50001, ledger.AssetUSDBalance, 3000)
|
||
svc := walletservice.New(repository)
|
||
|
||
withdrawal, balance, err := svc.ApplyWithdrawal(context.Background(), ledger.WithdrawalCommand{
|
||
CommandID: "cmd-withdraw-usd",
|
||
UserID: 50001,
|
||
Amount: 1200,
|
||
PayoutAccount: "paypal:host@example.com",
|
||
Reason: "host payout",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ApplyWithdrawal failed: %v", err)
|
||
}
|
||
if withdrawal.WithdrawalID == "" || withdrawal.Status != ledger.WithdrawalStatusPending || withdrawal.Amount != 1200 {
|
||
t.Fatalf("withdrawal response mismatch: %+v", withdrawal)
|
||
}
|
||
if balance.AvailableAmount != 1800 || balance.FrozenAmount != 1200 {
|
||
t.Fatalf("withdrawal balance mismatch: %+v", balance)
|
||
}
|
||
if got := repository.CountRows("wallet_withdrawal_requests", "user_id = ? AND status = ?", int64(50001), ledger.WithdrawalStatusPending); got != 1 {
|
||
t.Fatalf("withdrawal request should be persisted once, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-withdraw-usd", "withdrawal_apply"); got != 1 {
|
||
t.Fatalf("withdrawal should write one transaction, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。
|
||
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(51001, 20000)
|
||
svc := walletservice.New(repository)
|
||
|
||
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip1",
|
||
UserID: 51001,
|
||
Level: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("PurchaseVip failed: %v", err)
|
||
}
|
||
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.CoinSpent != 1000 || receipt.CoinBalanceAfter != 19000 {
|
||
t.Fatalf("vip receipt mismatch: %+v", receipt)
|
||
}
|
||
if !receipt.Vip.Active || receipt.Vip.Level != 1 || receipt.Vip.ExpiresAtMS <= receipt.Vip.StartedAtMS {
|
||
t.Fatalf("vip membership mismatch: %+v", receipt.Vip)
|
||
}
|
||
if len(receipt.RewardItems) != 1 || receipt.RewardItems[0].ResourceType != resourcedomain.TypeAvatarFrame {
|
||
t.Fatalf("vip reward items mismatch: %+v", receipt.RewardItems)
|
||
}
|
||
if got := repository.CountRows("vip_purchase_orders", "user_id = ? AND target_level = ?", int64(51001), int32(1)); got != 1 {
|
||
t.Fatalf("vip order should be persisted once, got %d", got)
|
||
}
|
||
if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND status = 'active'", int64(51001)); got != 1 {
|
||
t.Fatalf("vip reward entitlement should be active, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51001), "VipActivated"); got != 1 {
|
||
t.Fatalf("vip purchase should enqueue one activation IM event, got %d", got)
|
||
}
|
||
|
||
again, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip1",
|
||
UserID: 51001,
|
||
Level: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("PurchaseVip retry failed: %v", err)
|
||
}
|
||
if again.OrderID != receipt.OrderID || again.CoinBalanceAfter != receipt.CoinBalanceAfter {
|
||
t.Fatalf("vip idempotency mismatch: first=%+v again=%+v", receipt, again)
|
||
}
|
||
if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip-renew",
|
||
UserID: 51001,
|
||
Level: 1,
|
||
}); err != nil {
|
||
t.Fatalf("same level renewal should be allowed: %v", err)
|
||
}
|
||
upgrade, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip3",
|
||
UserID: 51001,
|
||
Level: 3,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("vip upgrade failed: %v", err)
|
||
}
|
||
if upgrade.Vip.Level != 3 {
|
||
t.Fatalf("vip upgrade level mismatch: %+v", upgrade.Vip)
|
||
}
|
||
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip-down-to-1",
|
||
UserID: 51001,
|
||
Level: 1,
|
||
})
|
||
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
|
||
t.Fatalf("expected VIP_DOWNGRADE_NOT_ALLOWED, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。
|
||
func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
|
||
adminGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
|
||
CommandID: "cmd-admin-vip1",
|
||
TargetUserID: 51003,
|
||
Level: 1,
|
||
GrantSource: ledger.VipGrantSourceAdmin,
|
||
OperatorUserID: 9001,
|
||
Reason: "manual vip grant",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("GrantVip admin failed: %v", err)
|
||
}
|
||
if adminGrant.TransactionID == "" || !adminGrant.Vip.Active || adminGrant.Vip.Level != 1 || len(adminGrant.RewardItems) != 1 {
|
||
t.Fatalf("admin vip grant receipt mismatch: %+v", adminGrant)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-admin-vip1", "vip_grant"); got != 1 {
|
||
t.Fatalf("vip grant should write one wallet transaction, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", adminGrant.TransactionID); got != 0 {
|
||
t.Fatalf("vip grant should not write coin entries, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 1 {
|
||
t.Fatalf("vip grant should enqueue one activation IM event, got %d", got)
|
||
}
|
||
|
||
again, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
|
||
CommandID: "cmd-admin-vip1",
|
||
TargetUserID: 51003,
|
||
Level: 1,
|
||
GrantSource: ledger.VipGrantSourceAdmin,
|
||
OperatorUserID: 9001,
|
||
Reason: "manual vip grant",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("GrantVip retry failed: %v", err)
|
||
}
|
||
if again.TransactionID != adminGrant.TransactionID {
|
||
t.Fatalf("vip grant idempotency mismatch: first=%+v again=%+v", adminGrant, again)
|
||
}
|
||
|
||
activityGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
|
||
CommandID: "cmd-activity-vip3",
|
||
TargetUserID: 51003,
|
||
Level: 3,
|
||
GrantSource: ledger.VipGrantSourceActivity,
|
||
Reason: "activity vip reward",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("GrantVip activity failed: %v", err)
|
||
}
|
||
if activityGrant.Vip.Level != 3 {
|
||
t.Fatalf("activity vip grant should upgrade to level 3: %+v", activityGrant)
|
||
}
|
||
if got := repository.CountRows("user_vip_history", "user_id = ? AND action = ?", int64(51003), ledger.VipGrantSourceActivity); got != 1 {
|
||
t.Fatalf("activity vip grant should write history action, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 2 {
|
||
t.Fatalf("both vip grants should enqueue activation IM events, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestVipLevelSixRequiresRechargeThreshold 验证 6-10 级 VIP 必须先达到累计充值门槛。
|
||
func TestVipLevelSixRequiresRechargeThreshold(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(51002, 50000)
|
||
svc := walletservice.New(repository)
|
||
|
||
currentLevels, err := svc.ListAdminVipLevels(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("ListAdminVipLevels failed: %v", err)
|
||
}
|
||
if len(currentLevels) != 10 {
|
||
t.Fatalf("expected 10 vip levels, got %d", len(currentLevels))
|
||
}
|
||
rewardGroupID := currentLevels[0].RewardResourceGroupID
|
||
commands := make([]ledger.AdminVipLevelCommand, 0, len(currentLevels))
|
||
for _, level := range currentLevels {
|
||
command := ledger.AdminVipLevelCommand{
|
||
Level: level.Level,
|
||
Name: level.Name,
|
||
Status: level.Status,
|
||
PriceCoin: level.PriceCoin,
|
||
DurationMS: level.DurationMS,
|
||
RewardResourceGroupID: level.RewardResourceGroupID,
|
||
RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount,
|
||
SortOrder: level.SortOrder,
|
||
}
|
||
if command.Level == 6 {
|
||
command.Status = ledger.VipStatusActive
|
||
command.PriceCoin = 12000
|
||
command.RewardResourceGroupID = rewardGroupID
|
||
command.RequiredRechargeCoinAmount = 5000
|
||
}
|
||
commands = append(commands, command)
|
||
}
|
||
if _, err := svc.UpdateAdminVipLevels(context.Background(), commands, 9001); err != nil {
|
||
t.Fatalf("UpdateAdminVipLevels failed: %v", err)
|
||
}
|
||
|
||
_, packages, err := svc.ListVipPackages(context.Background(), 51002)
|
||
if err != nil {
|
||
t.Fatalf("ListVipPackages failed: %v", err)
|
||
}
|
||
level6 := vipLevelByNumber(packages, 6)
|
||
if level6 == nil || level6.CanPurchase || level6.PurchaseLockedReason != "recharge_required" || level6.RequiredRechargeCoinAmount != 5000 {
|
||
t.Fatalf("vip level 6 should be locked by recharge gate: %+v", level6)
|
||
}
|
||
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip6-locked",
|
||
UserID: 51002,
|
||
Level: 6,
|
||
})
|
||
if !xerr.IsCode(err, xerr.VIPRechargeRequired) {
|
||
t.Fatalf("expected VIP_RECHARGE_REQUIRED, got %v", err)
|
||
}
|
||
|
||
repository.SetRechargeStats(51002, 5000)
|
||
_, packages, err = svc.ListVipPackages(context.Background(), 51002)
|
||
if err != nil {
|
||
t.Fatalf("ListVipPackages after recharge failed: %v", err)
|
||
}
|
||
level6 = vipLevelByNumber(packages, 6)
|
||
if level6 == nil || !level6.CanPurchase || level6.UserRechargeCoinAmount != 5000 {
|
||
t.Fatalf("vip level 6 should be purchasable after recharge: %+v", level6)
|
||
}
|
||
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||
CommandID: "cmd-vip6",
|
||
UserID: 51002,
|
||
Level: 6,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("PurchaseVip level 6 failed: %v", err)
|
||
}
|
||
if receipt.Vip.Level != 6 || receipt.CoinSpent != 12000 {
|
||
t.Fatalf("vip level 6 receipt mismatch: %+v", receipt)
|
||
}
|
||
}
|
||
|
||
func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel {
|
||
for index := range levels {
|
||
if levels[index].Level == target {
|
||
return &levels[index]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService 验证我的页开关和钻石兑换配置不再由 gateway 硬编码。
|
||
func TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(52001, 700)
|
||
repository.SetAssetBalance(52001, ledger.AssetDiamond, 300)
|
||
svc := walletservice.New(repository)
|
||
|
||
overview, err := svc.GetWalletOverview(context.Background(), 52001)
|
||
if err != nil {
|
||
t.Fatalf("GetWalletOverview failed: %v", err)
|
||
}
|
||
if !overview.FeatureFlags.RechargeEnabled || !overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled {
|
||
t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
|
||
}
|
||
if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetDiamond) != 300 {
|
||
t.Fatalf("overview balances mismatch: %+v", overview.Balances)
|
||
}
|
||
rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001)
|
||
if err != nil {
|
||
t.Fatalf("GetDiamondExchangeConfig failed: %v", err)
|
||
}
|
||
if len(rules) != 2 || rules[0].FromAssetType != ledger.AssetDiamond || !rules[0].Enabled {
|
||
t.Fatalf("diamond exchange rules mismatch: %+v", rules)
|
||
}
|
||
}
|
||
|
||
// TestRechargeProductsAreConfiguredByPlatformRegionAndStatus 锁定内购配置由 wallet-service 持久化并按平台、区域和上架态投放。
|
||
func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
|
||
product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
|
||
AppCode: "lalu",
|
||
AmountMicro: 1500000,
|
||
CoinAmount: 1500,
|
||
ProductName: "1500 Coins",
|
||
Description: "coin pack",
|
||
Platform: ledger.RechargeProductPlatformAndroid,
|
||
RegionIDs: []int64{1002, 1001},
|
||
Enabled: true,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRechargeProduct failed: %v", err)
|
||
}
|
||
if product.ProductID <= 0 || product.ProductCode == "" || product.Channel != ledger.RechargeChannelGoogle {
|
||
t.Fatalf("created product identifiers mismatch: %+v", product)
|
||
}
|
||
if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 {
|
||
t.Fatalf("created product regions mismatch: %+v", product.RegionIDs)
|
||
}
|
||
|
||
products, channels, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid)
|
||
if err != nil {
|
||
t.Fatalf("ListRechargeProducts android failed: %v", err)
|
||
}
|
||
if len(products) != 1 || products[0].ProductName != "1500 Coins" || products[0].CoinAmount != 1500 || products[0].AmountMicro != 1500000 {
|
||
t.Fatalf("android recharge product mismatch: products=%+v", products)
|
||
}
|
||
if len(channels) != 1 || channels[0] != ledger.RechargeChannelGoogle {
|
||
t.Fatalf("android recharge channels mismatch: %+v", channels)
|
||
}
|
||
|
||
iosProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformIOS)
|
||
if err != nil {
|
||
t.Fatalf("ListRechargeProducts ios failed: %v", err)
|
||
}
|
||
if len(iosProducts) != 0 {
|
||
t.Fatalf("ios list must not include android product: %+v", iosProducts)
|
||
}
|
||
|
||
_, err = svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
|
||
AppCode: "lalu",
|
||
ProductID: product.ProductID,
|
||
AmountMicro: 2500000,
|
||
CoinAmount: 2600,
|
||
ProductName: "2600 Coins",
|
||
Description: "updated pack",
|
||
Platform: ledger.RechargeProductPlatformAndroid,
|
||
RegionIDs: []int64{1002},
|
||
Enabled: false,
|
||
OperatorUserID: 90002,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpdateRechargeProduct failed: %v", err)
|
||
}
|
||
|
||
inactiveProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid)
|
||
if err != nil {
|
||
t.Fatalf("ListRechargeProducts inactive failed: %v", err)
|
||
}
|
||
if len(inactiveProducts) != 0 {
|
||
t.Fatalf("disabled product must not be exposed to app: %+v", inactiveProducts)
|
||
}
|
||
|
||
adminProducts, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
|
||
AppCode: "lalu",
|
||
Status: ledger.RechargeProductStatusDisabled,
|
||
RegionID: 1002,
|
||
Page: 1,
|
||
PageSize: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ListAdminRechargeProducts failed: %v", err)
|
||
}
|
||
if total != 1 || len(adminProducts) != 1 || adminProducts[0].Enabled || adminProducts[0].CoinAmount != 2600 {
|
||
t.Fatalf("admin disabled product mismatch: total=%d products=%+v", total, adminProducts)
|
||
}
|
||
|
||
if err := svc.DeleteRechargeProduct(context.Background(), "lalu", product.ProductID); err != nil {
|
||
t.Fatalf("DeleteRechargeProduct failed: %v", err)
|
||
}
|
||
remaining, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{AppCode: "lalu", Page: 1, PageSize: 10})
|
||
if err != nil {
|
||
t.Fatalf("ListAdminRechargeProducts after delete failed: %v", err)
|
||
}
|
||
if total != 0 || len(remaining) != 0 {
|
||
t.Fatalf("deleted product must disappear: total=%d products=%+v", total, remaining)
|
||
}
|
||
}
|
||
|
||
// TestConfirmGooglePaymentUsesProductNameAsGoogleProductID 锁定后台 product_name 作为 Google Play 商品 ID。
|
||
func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := walletservice.New(repository)
|
||
const googleProductID = "coins_1500_google"
|
||
product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
|
||
AppCode: "lalu",
|
||
AmountMicro: 1500000,
|
||
CoinAmount: 1500,
|
||
ProductName: googleProductID,
|
||
Description: "google coin pack",
|
||
Platform: ledger.RechargeProductPlatformAndroid,
|
||
RegionIDs: []int64{1001},
|
||
Enabled: true,
|
||
OperatorUserID: 90001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRechargeProduct failed: %v", err)
|
||
}
|
||
if product.ProductCode == googleProductID {
|
||
t.Fatalf("test requires internal product_code to differ from Google product ID: %+v", product)
|
||
}
|
||
svc.SetGooglePlayClient(&fakeGooglePlayClient{
|
||
purchase: ledger.GooglePlayPurchase{
|
||
ProductID: googleProductID,
|
||
OrderID: "GPA.1",
|
||
PurchaseState: ledger.GooglePurchaseStatePurchased,
|
||
ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
|
||
AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
|
||
},
|
||
})
|
||
|
||
receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "google-pay-product-name",
|
||
UserID: 53001,
|
||
RegionID: 1001,
|
||
ProductID: product.ProductID,
|
||
ProductCode: googleProductID,
|
||
PackageName: "com.org.laluparty",
|
||
PurchaseToken: "purchase-token-product-name",
|
||
OrderID: "GPA.1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ConfirmGooglePayment failed: %v", err)
|
||
}
|
||
if receipt.Status != ledger.PaymentStatusCredited || receipt.CoinAmount != 1500 || receipt.ProductCode != product.ProductCode {
|
||
t.Fatalf("google payment receipt mismatch: %+v", receipt)
|
||
}
|
||
if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ?", product.ProductID, product.ProductCode); got != 1 {
|
||
t.Fatalf("google payment should write one payment order with internal product_code, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
|
||
t.Fatalf("google payment should write one wallet entry, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestApplyGameCoinChangeDebitCreditAndIdempotency(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(88001, 500)
|
||
svc := walletservice.New(repository)
|
||
|
||
debit := ledger.GameCoinChangeCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "game:demo:order-1",
|
||
UserID: 88001,
|
||
PlatformCode: "demo",
|
||
GameID: "demo_rocket_001",
|
||
ProviderOrderID: "order-1",
|
||
ProviderRoundID: "round-1",
|
||
OpType: ledger.GameOpDebit,
|
||
CoinAmount: 120,
|
||
RoomID: "room-game-1",
|
||
RequestHash: "hash-order-1",
|
||
}
|
||
first, err := svc.ApplyGameCoinChange(context.Background(), debit)
|
||
if err != nil {
|
||
t.Fatalf("game debit failed: %v", err)
|
||
}
|
||
if first.BalanceAfter != 380 || first.IdempotentReplay {
|
||
t.Fatalf("game debit receipt mismatch: %+v", first)
|
||
}
|
||
second, err := svc.ApplyGameCoinChange(context.Background(), debit)
|
||
if err != nil {
|
||
t.Fatalf("game debit replay failed: %v", err)
|
||
}
|
||
if second.TransactionID != first.TransactionID || !second.IdempotentReplay || second.BalanceAfter != 380 {
|
||
t.Fatalf("game debit replay mismatch: first=%+v second=%+v", first, second)
|
||
}
|
||
credit, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "game:demo:order-2",
|
||
UserID: 88001,
|
||
PlatformCode: "demo",
|
||
GameID: "demo_rocket_001",
|
||
ProviderOrderID: "order-2",
|
||
OpType: ledger.GameOpCredit,
|
||
CoinAmount: 70,
|
||
RequestHash: "hash-order-2",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("game credit failed: %v", err)
|
||
}
|
||
if credit.BalanceAfter != 450 {
|
||
t.Fatalf("game credit balance mismatch: %+v", credit)
|
||
}
|
||
balances, err := svc.GetBalances(context.Background(), 88001, []string{ledger.AssetCoin})
|
||
if err != nil {
|
||
t.Fatalf("GetBalances failed: %v", err)
|
||
}
|
||
if balanceAmount(balances, ledger.AssetCoin) != 450 {
|
||
t.Fatalf("final balance mismatch: %+v", balances)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "biz_type IN ('game_debit','game_credit')"); got != 2 {
|
||
t.Fatalf("game coin changes should write two transactions, got %d", got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "event_type = ?", "WalletBalanceChanged"); got < 2 {
|
||
t.Fatalf("game coin changes should publish balance outbox events, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestApplyGameCoinChangeRejectsHashConflictAndInsufficientBalance(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
repository.SetBalance(88002, 50)
|
||
svc := walletservice.New(repository)
|
||
command := ledger.GameCoinChangeCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "game:demo:order-conflict",
|
||
UserID: 88002,
|
||
PlatformCode: "demo",
|
||
GameID: "demo_rocket_001",
|
||
ProviderOrderID: "order-conflict",
|
||
OpType: ledger.GameOpDebit,
|
||
CoinAmount: 20,
|
||
RequestHash: "hash-a",
|
||
}
|
||
if _, err := svc.ApplyGameCoinChange(context.Background(), command); err != nil {
|
||
t.Fatalf("first game debit failed: %v", err)
|
||
}
|
||
command.RequestHash = "hash-b"
|
||
if _, err := svc.ApplyGameCoinChange(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||
t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
|
||
}
|
||
_, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
|
||
AppCode: "lalu",
|
||
CommandID: "game:demo:order-insufficient",
|
||
UserID: 88002,
|
||
PlatformCode: "demo",
|
||
GameID: "demo_rocket_001",
|
||
ProviderOrderID: "order-insufficient",
|
||
OpType: ledger.GameOpDebit,
|
||
CoinAmount: 1000,
|
||
RequestHash: "hash-insufficient",
|
||
})
|
||
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
||
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
|
||
}
|
||
if got := repository.CountRows("wallet_transactions", "command_id = ?", "game:demo:order-insufficient"); got != 0 {
|
||
t.Fatalf("insufficient game debit must not write transaction, got %d", got)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool {
|
||
for _, item := range items {
|
||
if item.TypeCode == typeCode {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
type fakeActivityBadgeClient struct {
|
||
status string
|
||
err error
|
||
requests []*activityv1.ConsumeAchievementEventRequest
|
||
}
|
||
|
||
func (f *fakeActivityBadgeClient) ConsumeAchievementEvent(_ context.Context, req *activityv1.ConsumeAchievementEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error) {
|
||
f.requests = append(f.requests, req)
|
||
if f.err != nil {
|
||
return nil, f.err
|
||
}
|
||
status := f.status
|
||
if status == "" {
|
||
status = "consumed"
|
||
}
|
||
return &activityv1.ConsumeAchievementEventResponse{EventId: req.GetEventId(), Status: status}, nil
|
||
}
|
||
|
||
type fakeGooglePlayClient struct {
|
||
purchase ledger.GooglePlayPurchase
|
||
err error
|
||
consumed []string
|
||
}
|
||
|
||
func (f *fakeGooglePlayClient) GetProductPurchase(_ context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) {
|
||
if f.err != nil {
|
||
return ledger.GooglePlayPurchase{}, f.err
|
||
}
|
||
purchase := f.purchase
|
||
if purchase.PackageName == "" {
|
||
purchase.PackageName = packageName
|
||
}
|
||
return purchase, nil
|
||
}
|
||
|
||
func (f *fakeGooglePlayClient) ConsumeProduct(_ context.Context, _ string, productID string, _ string) error {
|
||
f.consumed = append(f.consumed, productID)
|
||
return nil
|
||
}
|