package wallet_test import ( "context" "testing" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" walletservice "hyapp/services/wallet-service/internal/service/wallet" "hyapp/services/wallet-service/internal/testutil/mysqltest" ) // TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。 func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-gift-v2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9", }) if err != nil { t.Fatalf("DebitGift failed: %v", err) } if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 6 || receipt.HeatValue != 22 || receipt.BalanceAfter != 86 || receipt.PriceVersion != "v9" { t.Fatalf("server price settlement mismatch: %+v", receipt) } if receipt.TransactionID == "" || receipt.BillingReceiptID == "" { t.Fatalf("receipt identifiers missing: %+v", receipt) } balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin}) if err != nil { t.Fatalf("GetBalances sender failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 86 { t.Fatalf("sender COIN balance mismatch: %+v", balances) } balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetGiftPoint}) if err != nil { t.Fatalf("GetBalances target failed: %v", err) } if balanceAmount(balances, ledger.AssetGiftPoint) != 6 { t.Fatalf("target GIFT_POINT balance mismatch: %+v", balances) } if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 { t.Fatalf("gift debit should write two entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 3 { t.Fatalf("gift debit should write balance and gift outbox events, got %d", got) } } // TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。 func TestDebitGiftIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{ CommandID: "cmd-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, } first, err := svc.DebitGift(context.Background(), command) if err != nil { t.Fatalf("first debit failed: %v", err) } second, err := svc.DebitGift(context.Background(), command) if err != nil { t.Fatalf("second debit failed: %v", err) } if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 { t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second) } if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent command should write one transaction, got %d", got) } } // TestDebitGiftRejectsCommandPayloadConflict 锁定同 command_id 不同业务 payload 的冲突语义。 func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1} if _, err := svc.DebitGift(context.Background(), command); err != nil { t.Fatalf("first debit failed: %v", err) } command.GiftCount = 2 _, err := svc.DebitGift(context.Background(), command) if !xerr.IsCode(err, xerr.LedgerConflict) { t.Fatalf("expected LEDGER_CONFLICT, got %v", err) } } // TestDebitGiftInsufficientBalance 锁定余额不足时不写成功交易、分录或 outbox。 func TestDebitGiftInsufficientBalance(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1) svc := walletservice.New(repository) _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1, }) if !xerr.IsCode(err, xerr.InsufficientBalance) { t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err) } if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-2"); got != 0 { t.Fatalf("insufficient balance must not write transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id LIKE ?", "wtx_%"); got != 0 { t.Fatalf("insufficient balance must not write entries, got %d", got) } if got := repository.CountRows("wallet_outbox", "command_id = ?", "cmd-2"); got != 0 { t.Fatalf("insufficient balance must not write outbox, got %d", got) } } // TestAdminCreditAssetIsIdempotent 验证后台入账有审计字段、幂等和余额查询闭环。 func TestAdminCreditAssetIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) command := ledger.AdminCreditAssetCommand{ CommandID: "cmd-admin-credit", TargetUserID: 20001, AssetType: "coin", Amount: 500, OperatorUserID: 90001, Reason: "manual recharge", EvidenceRef: "ticket-1", } first, txID, err := svc.AdminCreditAsset(context.Background(), command) if err != nil { t.Fatalf("AdminCreditAsset failed: %v", err) } second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command) if err != nil { t.Fatalf("AdminCreditAsset retry failed: %v", err) } if txID == "" || txID != secondTxID || first.AvailableAmount != 500 || second.AvailableAmount != 500 || first.AssetType != ledger.AssetCoin { t.Fatalf("admin credit idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID) } balances, err := svc.GetBalances(context.Background(), 20001, []string{ledger.AssetCoin, ledger.AssetGiftPoint}) if err != nil { t.Fatalf("GetBalances failed: %v", err) } if balanceAmount(balances, ledger.AssetCoin) != 500 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 { t.Fatalf("balances mismatch: %+v", balances) } } func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 { for _, balance := range balances { if balance.AssetType == assetType { return balance.AvailableAmount } } return -1 }