package payment import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/repository" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" ) func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) { db, sqlMock, err := sqlmock.New() if err != nil { t.Fatalf("create sql mock failed: %v", err) } defer db.Close() wallet := &mockPaymentWallet{rechargeBillsResp: &walletv1.ListRechargeBillsResponse{}} router := newPaymentHandlerTestRouter(New(wallet, db, nil, nil, nil)) userID := int64(327702592329093120) sellerUserID := int64(328453424662192128) expectUserDisplayIDLookup(sqlMock, "111", userID) expectUserDisplayIDLookup(sqlMock, "167864", sellerUserID) request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills?display_user_id=111&seller_display_user_id=167864", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastRechargeBills == nil || wallet.lastRechargeBills.GetUserId() != userID || wallet.lastRechargeBills.GetSellerUserId() != sellerUserID { t.Fatalf("recharge bill user filters should be resolved before wallet call: %+v", wallet.lastRechargeBills) } if err := sqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) { wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{ Channels: []*walletv1.ThirdPartyPaymentChannel{{ AppCode: "lalu", ProviderCode: "mifapay", ProviderName: "MiFaPay", Status: "active", SortOrder: 10, Methods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: 810, AppCode: "lalu", ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Status: "active", UsdToCurrencyRate: "1.00000000", }}, }, { AppCode: "lalu", ProviderCode: "v5pay", ProviderName: "V5Pay", Status: "active", SortOrder: 20, Methods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: 2310, AppCode: "lalu", ProviderCode: "v5pay", ProviderName: "V5Pay", CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "STCPAY", MethodName: "STC Pay", Status: "active", UsdToCurrencyRate: "1.00000000", }}, }}, }} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?status=active", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastThirdPartyChannels == nil || wallet.lastThirdPartyChannels.GetAppCode() != "lalu" || wallet.lastThirdPartyChannels.GetProviderCode() != "" || wallet.lastThirdPartyChannels.GetStatus() != "active" || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() { t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } items := response.Data["items"].([]any) channel := items[0].(map[string]any) v5Channel := items[1].(map[string]any) methods := channel["methods"].([]any) v5Methods := v5Channel["methods"].([]any) if response.Code != 0 || channel["providerCode"] != "mifapay" || v5Channel["providerCode"] != "v5pay" || len(methods) != 1 || len(v5Methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" || v5Methods[0].(map[string]any)["payType"] != "STCPAY" { t.Fatalf("third party channels response mismatch: %+v", response) } } func TestListTemporaryPaymentLinksForwardsFilters(t *testing.T) { wallet := &mockPaymentWallet{temporaryOrdersResp: &walletv1.ListTemporaryRechargeOrdersResponse{ Total: 1, Orders: []*walletv1.ExternalRechargeOrder{{ OrderId: "tmp_1001", AppCode: "lalu", AudienceType: "temporary", ProductName: "Temporary payment USD 1.99", CoinAmount: 0, UsdMinorAmount: 199, ProviderCode: "mifapay", PaymentMethodId: 810, CountryCode: "SA", CurrencyCode: "SAR", ProviderAmountMinor: 746, PayWay: "Card", PayType: "MADA", PayUrl: "https://pay.example/tmp_1001", ProviderOrderId: "mifa_1001", Status: "redirected", CreatedAtMs: 1700000000000, UpdatedAtMs: 1700000000001, }}, }} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodGet, "/admin/payment/temporary-links?status=redirected&provider_code=mifapay&keyword=tmp&page=2&page_size=30", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastTemporaryOrders == nil || wallet.lastTemporaryOrders.GetAppCode() != "lalu" || wallet.lastTemporaryOrders.GetStatus() != "redirected" || wallet.lastTemporaryOrders.GetProviderCode() != "mifapay" || wallet.lastTemporaryOrders.GetKeyword() != "tmp" || wallet.lastTemporaryOrders.GetPage() != 2 || wallet.lastTemporaryOrders.GetPageSize() != 30 { t.Fatalf("temporary link list request mismatch: %+v", wallet.lastTemporaryOrders) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } items := response.Data["items"].([]any) item := items[0].(map[string]any) if response.Code != 0 || response.Data["total"].(float64) != 1 || item["orderId"] != "tmp_1001" || item["payUrl"] != "https://pay.example/tmp_1001" || item["status"] != "redirected" { t.Fatalf("temporary link response mismatch: %+v", response) } } func TestGetTemporaryPaymentLinkRefreshesSingleOrder(t *testing.T) { wallet := &mockPaymentWallet{temporaryOrderResp: &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{ OrderId: "tmp_1001", AppCode: "lalu", AudienceType: "temporary", ProviderCode: "v5pay", PayUrl: "https://pay.example/tmp_1001", Status: "paid", }}} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodGet, "/admin/payment/temporary-links/tmp_1001", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastTemporaryOrder == nil || wallet.lastTemporaryOrder.GetAppCode() != "lalu" || wallet.lastTemporaryOrder.GetOrderId() != "tmp_1001" { t.Fatalf("temporary link get request mismatch: %+v", wallet.lastTemporaryOrder) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != 0 || response.Data["orderId"] != "tmp_1001" || response.Data["status"] != "paid" { t.Fatalf("temporary link get response mismatch: %+v", response) } } func TestCreateTemporaryPaymentLinkRecordsOwner(t *testing.T) { userDB, userSQL, err := sqlmock.New() if err != nil { t.Fatalf("create user sql mock failed: %v", err) } defer userDB.Close() store, storeSQL, closeStore := newPaymentStoreSQLMock(t) defer closeStore() expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}}) expectRegionCountry(userSQL, "lalu", 2, "SA", 1) expectStoreRecordOwnerAndEnrich(storeSQL, "lalu", "tmp_created", 7, 2) wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")} router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil)) request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810,"returnUrl":"https://h5.example/pay"}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastCreateTemporary == nil || wallet.lastCreateTemporary.GetAppCode() != "lalu" || wallet.lastCreateTemporary.GetUsdMinorAmount() != 1299 || wallet.lastCreateTemporary.GetProviderCode() != "mifapay" || wallet.lastCreateTemporary.GetPaymentMethodId() != 810 || wallet.lastCreateTemporary.GetReturnUrl() != "https://h5.example/pay" || wallet.lastCreateTemporary.GetPayerAccount() != "tester" { t.Fatalf("create temporary request mismatch: %+v", wallet.lastCreateTemporary) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != 0 || response.Data["orderId"] != "tmp_created" || response.Data["regionId"].(float64) != 2 || response.Data["ownerUserId"].(float64) != 7 { t.Fatalf("create payment link response mismatch: %+v", response) } if err := userSQL.ExpectationsWereMet(); err != nil { t.Fatalf("user sql expectations mismatch: %v", err) } if err := storeSQL.ExpectationsWereMet(); err != nil { t.Fatalf("store sql expectations mismatch: %v", err) } } func TestCreateTemporaryPaymentLinkRejectsOutOfScopeRegion(t *testing.T) { userDB, _, err := sqlmock.New() if err != nil { t.Fatalf("create user sql mock failed: %v", err) } defer userDB.Close() store, storeSQL, closeStore := newPaymentStoreSQLMock(t) defer closeStore() expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 3}}) wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")} router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil)) request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastThirdPartyChannels != nil || wallet.lastCreateTemporary != nil { t.Fatalf("out-of-scope request must fail before wallet calls: channels=%+v create=%+v", wallet.lastThirdPartyChannels, wallet.lastCreateTemporary) } if err := storeSQL.ExpectationsWereMet(); err != nil { t.Fatalf("store sql expectations mismatch: %v", err) } } func TestCreateTemporaryPaymentLinkRejectsCountryRegionMismatch(t *testing.T) { userDB, userSQL, err := sqlmock.New() if err != nil { t.Fatalf("create user sql mock failed: %v", err) } defer userDB.Close() store, storeSQL, closeStore := newPaymentStoreSQLMock(t) defer closeStore() expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}}) expectRegionCountry(userSQL, "lalu", 2, "SA", 0) wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")} router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil)) request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastCreateTemporary != nil { t.Fatalf("country mismatch must fail before create order: %+v", wallet.lastCreateTemporary) } if err := userSQL.ExpectationsWereMet(); err != nil { t.Fatalf("user sql expectations mismatch: %v", err) } if err := storeSQL.ExpectationsWereMet(); err != nil { t.Fatalf("store sql expectations mismatch: %v", err) } } func TestGetMoneyPerformanceAggregatesPaidTemporaryOwners(t *testing.T) { walletDB, walletSQL, err := sqlmock.New() if err != nil { t.Fatalf("create wallet sql mock failed: %v", err) } defer walletDB.Close() store, storeSQL, closeStore := newPaymentStoreSQLMock(t) defer closeStore() expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}}) walletSQL.ExpectQuery(`(?s)SELECT app_code, order_id, usd_minor_amount.*FROM external_recharge_orders`). WithArgs("lalu", "temporary"). WillReturnRows(sqlmock.NewRows([]string{"app_code", "order_id", "usd_minor_amount", "status", "country_code", "provider_code", "payment_method_id", "pay_url", "created_at_ms", "updated_at_ms"}). AddRow("lalu", "tmp_paid", int64(1299), "paid", "SA", "mifapay", int64(810), "https://pay.example/tmp_paid", int64(1700000000000), int64(1700000000100)). AddRow("lalu", "tmp_credited", int64(2000), "credited", "SA", "mifapay", int64(810), "https://pay.example/tmp_credited", int64(1700000000000), int64(1700000000200))) expectStoreOwners(storeSQL, "lalu", []ownerFixture{{orderID: "tmp_paid", adminUserID: 7, regionID: 2}, {orderID: "tmp_credited", adminUserID: 7, regionID: 2}}) expectStoreUsers(storeSQL, 7, "tester", "Tester") router := newPaymentHandlerTestRouter(New(&mockPaymentWallet{}, nil, walletDB, store, nil)) request := httptest.NewRequest(http.MethodGet, "/admin/money/performance", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } items := response.Data["items"].([]any) item := items[0].(map[string]any) summary := response.Data["summary"].(map[string]any) if item["paidLinkCount"].(float64) != 2 || item["paidUsdMinor"].(float64) != 3299 || item["operatorName"] != "Tester" || summary["paidUsdMinor"].(float64) != 3299 { t.Fatalf("performance response mismatch: %+v", response) } if err := walletSQL.ExpectationsWereMet(); err != nil { t.Fatalf("wallet sql expectations mismatch: %v", err) } if err := storeSQL.ExpectationsWereMet(); err != nil { t.Fatalf("store sql expectations mismatch: %v", err) } } func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) { wallet := &mockPaymentWallet{} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-methods/810/status", bytes.NewBufferString(`{"enabled":false}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastSetMethodStatus == nil || wallet.lastSetMethodStatus.GetMethodId() != 810 || wallet.lastSetMethodStatus.GetEnabled() || wallet.lastSetMethodStatus.GetOperatorUserId() != 7 { t.Fatalf("method status request mismatch: %+v", wallet.lastSetMethodStatus) } } func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) { wallet := &mockPaymentWallet{} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-rates/810", bytes.NewBufferString(`{"usdToCurrencyRate":" 3.75000000 "}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastUpdateRate == nil || wallet.lastUpdateRate.GetMethodId() != 810 || wallet.lastUpdateRate.GetUsdToCurrencyRate() != "3.75000000" || wallet.lastUpdateRate.GetOperatorUserId() != 7 { t.Fatalf("payment rate request mismatch: %+v", wallet.lastUpdateRate) } } func TestSyncThirdPartyPaymentMethodsDefaultsToV5Pay(t *testing.T) { wallet := &mockPaymentWallet{syncMethodsResp: &walletv1.SyncThirdPartyPaymentMethodsResponse{ ProviderCode: "v5pay", ScannedCountryCount: 11, FetchedCountryCount: 8, CreatedCount: 6, UpdatedCount: 20, SkippedCount: 1, Issues: []*walletv1.ThirdPartyPaymentMethodSyncIssue{{ CountryCode: "SA", CurrencyCode: "SAR", Code: "1013", Message: "app is invalid", }}, }} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-methods/sync", bytes.NewBufferString(`{}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastSyncMethods == nil || wallet.lastSyncMethods.GetAppCode() != "lalu" || wallet.lastSyncMethods.GetProviderCode() != "v5pay" || wallet.lastSyncMethods.GetOperatorUserId() != 7 { t.Fatalf("sync methods request mismatch: %+v", wallet.lastSyncMethods) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != 0 || response.Data["providerCode"] != "v5pay" || response.Data["createdCount"].(float64) != 6 || response.Data["failedCountryCount"].(float64) != 1 { t.Fatalf("sync methods response mismatch: %+v", response) } } func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.T) { wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{ Channels: []*walletv1.ThirdPartyPaymentChannel{{ AppCode: "lalu", ProviderCode: "mifapay", ProviderName: "MiFaPay", Status: "active", Methods: []*walletv1.ThirdPartyPaymentMethod{ {MethodId: 810, CurrencyCode: "SAR", Status: "active"}, {MethodId: 811, CurrencyCode: "BHD", Status: "disabled"}, }, }, { AppCode: "lalu", ProviderCode: "v5pay", ProviderName: "V5Pay", Status: "active", Methods: []*walletv1.ThirdPartyPaymentMethod{ {MethodId: 2310, CurrencyCode: "SAR", Status: "active"}, }, }}, }} handler := New(wallet, nil, nil, nil, nil) handler.exchangeRates = fakeExchangeRateClient{result: exchangeRateResult{ Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000"}, SourceNames: []string{"open.er-api.com"}, }} router := newPaymentHandlerTestRouter(handler) request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", bytes.NewBufferString(`{"markupPercent":3}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() { t.Fatalf("sync must read active and disabled methods: %+v", wallet.lastThirdPartyChannels) } if len(wallet.updateRateRequests) != 3 { t.Fatalf("sync should update every method with a fetched currency rate: %+v", wallet.updateRateRequests) } got := map[int64]string{} for _, req := range wallet.updateRateRequests { if req.GetOperatorUserId() != 7 { t.Fatalf("sync operator mismatch: %+v", req) } got[req.GetMethodId()] = req.GetUsdToCurrencyRate() } if got[810] != "3.9" || got[811] != "0.4" || got[2310] != "3.9" { t.Fatalf("synced rates mismatch: %+v", got) } var response adminPaymentTestResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != 0 || response.Data["updatedCount"].(float64) != 3 { t.Fatalf("sync response mismatch: %+v", response) } } func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) { wallet := &mockPaymentWallet{} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", bytes.NewBufferString(`{"markupPercent":-1}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastThirdPartyChannels != nil { t.Fatalf("invalid markup must fail before reading payment methods: %+v", wallet.lastThirdPartyChannels) } } func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) { wallet := &mockPaymentWallet{} router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}` request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if wallet.lastCreateProduct == nil || wallet.lastCreateProduct.GetAmountMicro() != 10_000_000 || wallet.lastCreateProduct.GetCoinAmount() != 880000 || wallet.lastCreateProduct.GetAudienceType() != "coin_seller" || wallet.lastCreateProduct.GetPlatform() != "web" || len(wallet.lastCreateProduct.GetRegionIds()) != 1 || wallet.lastCreateProduct.GetRegionIds()[0] != 7100 || !wallet.lastCreateProduct.GetEnabled() || wallet.lastCreateProduct.GetOperatorUserId() != 7 { t.Fatalf("create recharge product request mismatch: %+v", wallet.lastCreateProduct) } } func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() router.Use(func(c *gin.Context) { // 后台 handler 只依赖 app_code、request_id 和操作者;测试在这里模拟鉴权中间件已完成后的上下文。 c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) c.Set(middleware.ContextRequestID, "payment-handler-test") c.Set(middleware.ContextUserID, uint(7)) c.Set(middleware.ContextUsername, "tester") c.Next() }) router.GET("/admin/payment/recharge-bills", handler.ListRechargeBills) router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels) router.GET("/admin/payment/temporary-links", handler.ListTemporaryPaymentLinks) router.POST("/admin/payment/temporary-links", handler.CreateTemporaryPaymentLink) router.GET("/admin/payment/temporary-links/:order_id", handler.GetTemporaryPaymentLink) router.GET("/admin/money/performance", handler.GetMoneyPerformance) router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus) router.POST("/admin/payment/third-party-methods/sync", handler.SyncThirdPartyPaymentMethods) router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate) router.POST("/admin/payment/third-party-rates/sync", handler.SyncThirdPartyPaymentRates) router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct) return router } type adminPaymentTestResponse struct { Code int `json:"code"` Data map[string]any `json:"data"` } func expectUserIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) { sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`). WithArgs( "lalu", keyword, keyword, keyword, sqlmock.AnyArg(), keyword, "%"+keyword+"%", keyword, keyword, keyword, sqlmock.AnyArg(), keyword, ). WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) } func expectUserDisplayIDLookup(sqlMock sqlmock.Sqlmock, displayUserID string, userID int64) { sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`). WithArgs( "lalu", displayUserID, displayUserID, sqlmock.AnyArg(), displayUserID, ). WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) } type moneyScopeFixture struct { appCode string regionID int64 } type ownerFixture struct { orderID string adminUserID uint regionID int64 } func newPaymentStoreSQLMock(t *testing.T) (*repository.Store, sqlmock.Sqlmock, func()) { t.Helper() sqlDB, mock, err := sqlmock.New() if err != nil { t.Fatalf("create store sql mock failed: %v", err) } mock.MatchExpectationsInOrder(false) gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { _ = sqlDB.Close() t.Fatalf("create gorm db failed: %v", err) } return repository.New(gormDB), mock, func() { _ = sqlDB.Close() } } func expectStoreMoneyAccess(mock sqlmock.Sqlmock, userID uint, scopes []moneyScopeFixture) { mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?"). WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, "tester", "Tester", "active")) mock.ExpectQuery("SELECT \\* FROM `admin_user_roles` WHERE `admin_user_roles`.`user_id` = \\?"). WillReturnRows(sqlmock.NewRows([]string{"user_id", "role_id"})) rows := sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"}) for index, scope := range scopes { rows.AddRow(index+1, userID, scope.appCode, scope.regionID, int64(1700000000000), int64(1700000000000)) } mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC"). WillReturnRows(rows) } func expectRegionCountry(mock sqlmock.Sqlmock, appCode string, regionID int64, countryCode string, count int) { mock.ExpectQuery(`(?s)SELECT COUNT\(\*\).*FROM region_countries.*WHERE app_code = \? AND region_id = \? AND country_code = \? AND status = 'active'`). WithArgs(appCode, regionID, countryCode). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count)) } func expectStoreRecordOwnerAndEnrich(mock sqlmock.Sqlmock, appCode string, orderID string, adminUserID uint, regionID int64) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO `admin_temporary_payment_link_owners`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() expectStoreOwners(mock, appCode, []ownerFixture{{orderID: orderID, adminUserID: adminUserID, regionID: regionID}}) expectStoreUsers(mock, adminUserID, "tester", "Tester") } func expectStoreOwners(mock sqlmock.Sqlmock, appCode string, owners []ownerFixture) { rows := sqlmock.NewRows([]string{"app_code", "order_id", "admin_user_id", "region_id", "created_at_ms", "updated_at_ms"}) for _, owner := range owners { rows.AddRow(appCode, owner.orderID, owner.adminUserID, owner.regionID, int64(1700000000000), int64(1700000000000)) } mock.ExpectQuery("SELECT \\* FROM `admin_temporary_payment_link_owners` WHERE app_code = \\? AND order_id IN"). WillReturnRows(rows) } func expectStoreUsers(mock sqlmock.Sqlmock, userID uint, username string, name string) { mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE id IN"). WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, username, name, "active")) } func temporaryMethodChannels(appCode string, providerCode string, methodID int64, countryCode string) *walletv1.ListThirdPartyPaymentChannelsResponse { return &walletv1.ListThirdPartyPaymentChannelsResponse{Channels: []*walletv1.ThirdPartyPaymentChannel{{ AppCode: appCode, ProviderCode: providerCode, ProviderName: providerCode, Status: "active", Methods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: methodID, AppCode: appCode, ProviderCode: providerCode, ProviderName: providerCode, CountryCode: countryCode, CountryName: countryCode, CurrencyCode: countryCode, PayWay: "Card", PayType: "CARD", MethodName: "Card", Status: "active", }}, }}} } type mockPaymentWallet struct { walletclient.Client lastRechargeBills *walletv1.ListRechargeBillsRequest rechargeBillsResp *walletv1.ListRechargeBillsResponse lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse lastTemporaryOrders *walletv1.ListTemporaryRechargeOrdersRequest temporaryOrdersResp *walletv1.ListTemporaryRechargeOrdersResponse lastCreateTemporary *walletv1.CreateTemporaryRechargeOrderRequest createTemporaryResp *walletv1.H5RechargeOrderResponse lastTemporaryOrder *walletv1.GetTemporaryRechargeOrderRequest temporaryOrderResp *walletv1.H5RechargeOrderResponse lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest lastSyncMethods *walletv1.SyncThirdPartyPaymentMethodsRequest syncMethodsResp *walletv1.SyncThirdPartyPaymentMethodsResponse updateRateRequests []*walletv1.UpdateThirdPartyPaymentRateRequest lastCreateProduct *walletv1.CreateRechargeProductRequest } func (m *mockPaymentWallet) ListRechargeBills(_ context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) { m.lastRechargeBills = req if m.rechargeBillsResp != nil { return m.rechargeBillsResp, nil } return &walletv1.ListRechargeBillsResponse{}, nil } func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) { m.lastThirdPartyChannels = req if m.thirdPartyChannelsResp != nil { return m.thirdPartyChannelsResp, nil } return &walletv1.ListThirdPartyPaymentChannelsResponse{}, nil } func (m *mockPaymentWallet) ListTemporaryRechargeOrders(_ context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error) { m.lastTemporaryOrders = req if m.temporaryOrdersResp != nil { return m.temporaryOrdersResp, nil } return &walletv1.ListTemporaryRechargeOrdersResponse{}, nil } func (m *mockPaymentWallet) CreateTemporaryRechargeOrder(_ context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { m.lastCreateTemporary = req if m.createTemporaryResp != nil { return m.createTemporaryResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{ OrderId: "tmp_created", AppCode: req.GetAppCode(), AudienceType: "temporary", UsdMinorAmount: req.GetUsdMinorAmount(), ProviderCode: req.GetProviderCode(), PaymentMethodId: req.GetPaymentMethodId(), PayUrl: "https://pay.example/tmp_created", Status: "redirected", }}, nil } func (m *mockPaymentWallet) GetTemporaryRechargeOrder(_ context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { m.lastTemporaryOrder = req if m.temporaryOrderResp != nil { return m.temporaryOrderResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), Status: "pending"}}, nil } func (m *mockPaymentWallet) SetThirdPartyPaymentMethodStatus(_ context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { m.lastSetMethodStatus = req status := "disabled" if req.GetEnabled() { status = "active" } return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{ MethodId: req.GetMethodId(), AppCode: req.GetAppCode(), ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "SA", PayWay: "Card", PayType: "MADA", Status: status, }}, nil } func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { m.lastUpdateRate = req m.updateRateRequests = append(m.updateRateRequests, req) return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{ MethodId: req.GetMethodId(), AppCode: req.GetAppCode(), ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "SA", PayWay: "Card", PayType: "MADA", Status: "active", UsdToCurrencyRate: req.GetUsdToCurrencyRate(), }}, nil } func (m *mockPaymentWallet) SyncThirdPartyPaymentMethods(_ context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error) { m.lastSyncMethods = req if m.syncMethodsResp != nil { return m.syncMethodsResp, nil } return &walletv1.SyncThirdPartyPaymentMethodsResponse{ProviderCode: req.GetProviderCode()}, nil } type fakeExchangeRateClient struct { result exchangeRateResult err error } func (f fakeExchangeRateClient) LatestUSDRates(context.Context, []string) (exchangeRateResult, error) { return f.result, f.err } func (m *mockPaymentWallet) CreateRechargeProduct(_ context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) { m.lastCreateProduct = req return &walletv1.RechargeProductResponse{Product: &walletv1.RechargeProduct{ AppCode: req.GetAppCode(), ProductId: 9001, ProductCode: "h5_seller_10", ProductName: req.GetProductName(), Description: req.GetDescription(), AudienceType: req.GetAudienceType(), Platform: req.GetPlatform(), AmountMicro: req.GetAmountMicro(), CoinAmount: req.GetCoinAmount(), RegionIds: append([]int64(nil), req.GetRegionIds()...), Enabled: req.GetEnabled(), Status: "active", }}, nil }