package financeapplication import ( "context" "testing" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" walletv1 "hyapp.local/api/proto/wallet/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func TestNormalizeCreateInputRequiresOperationPermission(t *testing.T) { service := NewService(nil) _, err := service.normalizeCreateInput(shared.Actor{ UserID: 7, Username: "ops", Permissions: []string{permissionCreateApplication}, }, validCreateRequest()) if err == nil || err.Error() != "没有操作权限" { t.Fatalf("operation permission must be required, got err=%v", err) } } func TestNormalizeCreateInputRequiresWalletIdentityForWalletOperation(t *testing.T) { service := NewService(nil) req := validCreateRequest() req.Operation = "user_wallet_debit" _, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req) if err == nil || err.Error() != "钱包身份不正确" { t.Fatalf("wallet operation must require wallet identity, got err=%v", err) } req.WalletIdentity = "agency" item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req) if err != nil { t.Fatalf("wallet identity should be accepted: %v", err) } if item.WalletIdentity != "agency" { t.Fatalf("wallet identity mismatch: %q", item.WalletIdentity) } } func TestNormalizeCreateInputClearsWalletIdentityForCoinOperation(t *testing.T) { service := NewService(nil) req := validCreateRequest() req.WalletIdentity = "host" item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:coin-seller-coin-credit"), req) if err != nil { t.Fatalf("coin operation should be accepted: %v", err) } if item.WalletIdentity != "" { t.Fatalf("non-wallet operation must clear wallet identity, got %q", item.WalletIdentity) } if item.RechargeAmount != "10.50" { t.Fatalf("recharge amount mismatch: %q", item.RechargeAmount) } } func TestNormalizeCreateInputAllowsZeroRechargeAmountForUserCoinOperation(t *testing.T) { service := NewService(nil) req := validCreateRequest() req.Operation = "user_coin_credit" req.RechargeAmount = 0 item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-coin-credit"), req) if err != nil { t.Fatalf("user coin operation should allow zero recharge amount: %v", err) } if item.RechargeAmount != "0.00" { t.Fatalf("zero recharge amount must be persisted with decimal scale, got %q", item.RechargeAmount) } } func TestNormalizeCreateInputRejectsZeroRechargeAmountForWalletAndCoinSellerOperations(t *testing.T) { service := NewService(nil) tests := []struct { name string operation string permission string identity string }{ {name: "wallet", operation: "user_wallet_credit", permission: "finance-operation:user-wallet-credit", identity: "agency"}, {name: "coin seller", operation: "coin_seller_coin_credit", permission: "finance-operation:coin-seller-coin-credit"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := validCreateRequest() req.Operation = tt.operation req.WalletIdentity = tt.identity req.RechargeAmount = 0 _, err := service.normalizeCreateInput(actorWithPermissions(tt.permission), req) if err == nil || err.Error() != "充值金额不正确" { t.Fatalf("operation %s must still require positive recharge amount, got err=%v", tt.operation, err) } }) } } func TestExecuteApprovedApplicationAdjustsUserCoinDebit(t *testing.T) { wallet := &fakeFinanceWallet{} service := NewService(nil, WithUserClient(&fakeFinanceUser{ displays: map[string]int64{"163001": 312899006709637120}, users: map[int64]*userclient.User{ 312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7}, }, }), WithWalletClient(wallet)) application := model.FinanceApplication{ ID: 9, AppCode: "lalu", Operation: "user_coin_debit", TargetUserID: "163001", CoinAmount: 120, RechargeAmount: "10.00", } execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 7}, application, "req-1") if err != nil { t.Fatalf("execute approved application failed: %v", err) } if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetCoin || wallet.assetReq.GetAmount() != -120 || wallet.assetReq.GetTargetUserId() != 312899006709637120 { t.Fatalf("wallet asset request mismatch: %+v", wallet.assetReq) } if execution.CommandID != "finance-application:9:user_coin_debit" || execution.AmountDelta != -120 || execution.BalanceAfter != 880 { t.Fatalf("execution mismatch: %+v", execution) } } func TestExecuteApprovedApplicationUsesWalletIdentityAmountMinor(t *testing.T) { wallet := &fakeFinanceWallet{} service := NewService(nil, WithUserClient(&fakeFinanceUser{ users: map[int64]*userclient.User{ 312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7}, }, summaries: map[int64]*userclient.UserRoleSummary{ 312899006709637120: {UserID: 312899006709637120, IsAgency: true}, }, }), WithWalletClient(wallet)) application := model.FinanceApplication{ ID: 10, AppCode: "lalu", Operation: "user_wallet_credit", WalletIdentity: "agency", TargetUserID: "312899006709637120", CoinAmount: 999, RechargeAmount: "12.34", } _, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 8}, application, "req-2") if err != nil { t.Fatalf("execute wallet application failed: %v", err) } if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetAgencySalaryUSD || wallet.assetReq.GetAmount() != 1234 { t.Fatalf("identity wallet request must use salary minor amount, got %+v", wallet.assetReq) } } func TestExecuteApprovedApplicationAdjustsCoinSellerStockDeduction(t *testing.T) { wallet := &fakeFinanceWallet{} service := NewService(nil, WithUserClient(&fakeFinanceUser{ users: map[int64]*userclient.User{ 312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7}, }, summaries: map[int64]*userclient.UserRoleSummary{ 312899006709637120: {UserID: 312899006709637120, IsCoinSeller: true, CoinSellerStatus: "active"}, }, }), WithWalletClient(wallet)) application := model.FinanceApplication{ ID: 11, AppCode: "lalu", Operation: "coin_seller_coin_debit", TargetUserID: "312899006709637120", CoinAmount: 3000000, RechargeAmount: "37.50", } execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 9}, application, "req-3") if err != nil { t.Fatalf("execute coin seller application failed: %v", err) } if wallet.stockReq == nil || wallet.stockReq.GetStockType() != financeStockTypeUSDTDeduction || wallet.stockReq.GetCoinAmount() != 3000000 || wallet.stockReq.GetPaidAmountMicro() != 37500000 { t.Fatalf("coin seller stock request mismatch: %+v", wallet.stockReq) } if wallet.stockReq.GetSellerCountryId() != 63 || wallet.stockReq.GetSellerRegionId() != 7 { t.Fatalf("coin seller region snapshot mismatch: %+v", wallet.stockReq) } if execution.AssetType != financeAssetCoinSellerCoin || execution.AmountDelta != -3000000 || execution.BalanceAfter != 7000000 { t.Fatalf("coin seller execution mismatch: %+v", execution) } } func validCreateRequest() createApplicationRequest { return createApplicationRequest{ AppCode: "lalu", Operation: "coin_seller_coin_credit", TargetUserID: "10001", CoinAmount: 100, RechargeAmount: 10.5, CredentialImageURL: "https://example.com/receipt.png", } } type fakeFinanceUser struct { userclient.Client users map[int64]*userclient.User displays map[string]int64 summaries map[int64]*userclient.UserRoleSummary } func (f *fakeFinanceUser) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) { if user := f.users[req.UserID]; user != nil { return user, nil } return nil, status.Error(codes.NotFound, "user not found") } func (f *fakeFinanceUser) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) { if userID := f.displays[req.DisplayUserID]; userID > 0 { return &userclient.UserIdentity{UserID: userID, DisplayUserID: req.DisplayUserID}, nil } return nil, status.Error(codes.NotFound, "display user id not found") } func (f *fakeFinanceUser) GetUserRoleSummary(_ context.Context, req userclient.GetUserRoleSummaryRequest) (*userclient.UserRoleSummary, error) { if summary := f.summaries[req.UserID]; summary != nil { return summary, nil } return &userclient.UserRoleSummary{UserID: req.UserID}, nil } type fakeFinanceWallet struct { walletclient.Client assetReq *walletv1.AdminCreditAssetRequest stockReq *walletv1.AdminCreditCoinSellerStockRequest } func (f *fakeFinanceWallet) AdminCreditAsset(_ context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) { f.assetReq = req return &walletv1.AdminCreditAssetResponse{ TransactionId: "tx-" + req.GetCommandId(), Balance: &walletv1.AssetBalance{AvailableAmount: 880}, }, nil } func (f *fakeFinanceWallet) AdminCreditCoinSellerStock(_ context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) { f.stockReq = req coinAmount := req.GetCoinAmount() if req.GetStockType() == financeStockTypeUSDTDeduction { coinAmount = -coinAmount } return &walletv1.AdminCreditCoinSellerStockResponse{ TransactionId: "tx-" + req.GetCommandId(), CoinAmount: coinAmount, BalanceAfter: 7000000, }, nil } func actorWithPermissions(operationPermission string) shared.Actor { return shared.Actor{ UserID: 7, Username: "ops", Permissions: []string{permissionCreateApplication, operationPermission}, } }