2026-07-09 19:57:39 +08:00

929 lines
38 KiB
Go

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 TestListRechargeBillsReturnsProviderAmountMinor(t *testing.T) {
wallet := &mockPaymentWallet{rechargeBillsResp: &walletv1.ListRechargeBillsResponse{
Bills: []*walletv1.RechargeBill{{
AppCode: "lalu",
TransactionId: "tx-h5",
RechargeType: "mifapay_recharge",
Status: "succeeded",
CurrencyCode: "SAR",
CoinAmount: 120000,
UsdMinorAmount: 1500,
ProviderAmountMinor: 5625,
CreatedAtMs: 1760000000000,
}},
Total: 1,
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills?status=succeeded", 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)
if item["providerAmountMinor"] != float64(5625) || item["currencyCode"] != "SAR" {
t.Fatalf("provider amount should be returned for finance recharge detail: %+v", item)
}
}
func TestGetRechargeBillSummaryKeepsOrdinaryThirdParty(t *testing.T) {
wallet := &mockPaymentWallet{rechargeSummaryResp: &walletv1.GetRechargeBillSummaryResponse{
Total: &walletv1.RechargeBillSummaryBucket{BillCount: 31, CoinAmount: 310000, UsdMinorAmount: 12345},
ThirdParty: &walletv1.RechargeBillSummaryBucket{BillCount: 31, CoinAmount: 310000, UsdMinorAmount: 12345},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills/summary?recharge_type=third_party&status=succeeded", 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)
}
thirdParty := response.Data["thirdParty"].(map[string]any)
total := response.Data["total"].(map[string]any)
if thirdParty["billCount"] != float64(31) || thirdParty["usdMinorAmount"] != float64(12345) {
t.Fatalf("ordinary third party summary should be preserved: %+v", response.Data)
}
if total["billCount"] != float64(31) || total["usdMinorAmount"] != float64(12345) {
t.Fatalf("total should be recomputed from ordinary third party bucket: %+v", response.Data)
}
}
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 TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilRules(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"},
{MethodId: 2311, CurrencyCode: "IDR", Status: "active"},
},
}},
}}
handler := New(wallet, nil, nil, nil, nil)
handler.exchangeRates = fakeExchangeRateClient{result: exchangeRateResult{
Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000", "IDR": "18510.10000000"},
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) != 4 {
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" || got[2311] != "19066" {
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) != 4 {
t.Fatalf("sync response mismatch: %+v", response)
}
}
func TestApplyThirdPartyPaymentRateMarkupCeilsHighRateToInteger(t *testing.T) {
rate, ok := applyThirdPartyPaymentRateMarkup("18510.10000000", 0)
if !ok || rate != "18511" {
t.Fatalf("IDR high rate must ceil to integer: rate=%q ok=%v", rate, ok)
}
}
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,"appVisible":false}`
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.AppVisible == nil ||
wallet.lastCreateProduct.GetAppVisible() ||
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/recharge-bills/summary", handler.GetRechargeBillSummary)
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
rechargeSummaryResp *walletv1.GetRechargeBillSummaryResponse
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) GetRechargeBillSummary(_ context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error) {
if m.rechargeSummaryResp != nil {
return m.rechargeSummaryResp, nil
}
return &walletv1.GetRechargeBillSummaryResponse{}, 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(),
AppVisible: req.GetAppVisible(),
Status: "active",
}}, nil
}