mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: verify manual mark-paid payments
- add on-chain validation before manual mark-paid - verify recipient address, token contract, amount, confirmations, and tx time - reject duplicate transaction hashes and non on-chain orders - add manual payment verification tests for EVM, TRON, and Solana - change BSC network key to binance - add display_name to supported_assets while keeping network unchanged
This commit is contained in:
@@ -2,6 +2,7 @@ package route
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -647,6 +649,138 @@ func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-mark-paid-ok",
|
||||
OrderId: "order-admin-mark-paid-ok",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
verified := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(got *mdb.Orders, blockID string) (string, error) {
|
||||
verified = true
|
||||
if got.TradeId != order.TradeId {
|
||||
t.Fatalf("validator trade_id = %s, want %s", got.TradeId, order.TradeId)
|
||||
}
|
||||
if blockID != "block-admin-ok" {
|
||||
t.Fatalf("validator block id = %s, want block-admin-ok", blockID)
|
||||
}
|
||||
return "canonical-block-admin-ok", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-ok",
|
||||
}, token)
|
||||
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
if !verified {
|
||||
t.Fatal("expected chain verifier to be called")
|
||||
}
|
||||
|
||||
paid, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if paid.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if paid.BlockTransactionId != "canonical-block-admin-ok" {
|
||||
t.Fatalf("block_transaction_id = %q", paid.BlockTransactionId)
|
||||
}
|
||||
if paid.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||
t.Fatalf("callback_confirm = %d, want %d", paid.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_MarkPaidRejectsVerificationFailure(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-mark-paid-bad-proof",
|
||||
OrderId: "order-admin-mark-paid-bad-proof",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
return "", errors.New("transaction amount mismatch")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-bad",
|
||||
}, token)
|
||||
t.Logf("MarkOrderPaid verifier failure: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
||||
t.Fatalf("order changed after failed verification: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_MarkPaidRejectsNonOnChainOrder(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-mark-paid-provider",
|
||||
OrderId: "order-admin-mark-paid-provider",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
PayProvider: mdb.PaymentProviderOkPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
called := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
called = true
|
||||
return "block-admin-provider", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-provider",
|
||||
}, token)
|
||||
t.Logf("MarkOrderPaid provider order: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("verifier should not run for non-on-chain order")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
|
||||
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
@@ -657,6 +791,59 @@ func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_ResendCallbackRejectsEmptyNotifyURL(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-resend-empty-url",
|
||||
OrderId: "order-admin-resend-empty-url",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||
CallbackNum: 3,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
||||
t.Logf("ResendCallback empty notify_url: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.CallBackConfirm != mdb.CallBackConfirmOk || reloaded.CallbackNum != 3 {
|
||||
t.Fatalf("callback state changed: confirm=%d num=%d", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade-admin-resend-ok",
|
||||
OrderId: "order-admin-resend-ok",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: "https://merchant.example/notify",
|
||||
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||
CallbackNum: 3,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
||||
t.Logf("ResendCallback success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.CallBackConfirm != mdb.CallBackConfirmNo || reloaded.CallbackNum != 0 {
|
||||
t.Fatalf("callback state = confirm %d num %d, want no/0", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
|
||||
|
||||
+173
-36
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/spf13/viper"
|
||||
@@ -578,6 +579,12 @@ func TestGetPublicConfig(t *testing.T) {
|
||||
row := item.(map[string]interface{})
|
||||
network := row["network"].(string)
|
||||
seen[network] = true
|
||||
if _, ok := row["display_name"].(string); !ok {
|
||||
t.Fatalf("supported_assets[%s].display_name missing or not string: %#v", network, row["display_name"])
|
||||
}
|
||||
if network == "tron" && row["display_name"] != "TRON" {
|
||||
t.Fatalf("supported_assets.tron.display_name = %v, want TRON", row["display_name"])
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"tron", "solana"} {
|
||||
if !seen[n] {
|
||||
@@ -989,51 +996,64 @@ func TestCheckStatus_NotFound(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
|
||||
t.Fatalf("route returned 404 with empty body — route may not be registered")
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal check-status error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10008 {
|
||||
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
|
||||
// and a status field when the order exists.
|
||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns the
|
||||
// real order status for existing orders in all checkout-relevant states.
|
||||
func TestCheckStatus_WithOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create an order first via the GMPAY route.
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "check-status-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "tron",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var createResp map[string]interface{}
|
||||
json.Unmarshal(createRec.Body.Bytes(), &createResp)
|
||||
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||
if tradeId == "" {
|
||||
t.Fatal("no trade_id in create response")
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
}{
|
||||
{name: "wait-pay", status: mdb.StatusWaitPay},
|
||||
{name: "paid", status: mdb.StatusPaySuccess},
|
||||
{name: "expired", status: mdb.StatusExpired},
|
||||
}
|
||||
|
||||
// Now check status.
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["data"] == nil {
|
||||
t.Fatal("expected data in check-status response")
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "check-status-"+tc.name)
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Update("status", tc.status).Error; err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus(%s): status=%d body=%s", tc.name, rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal check-status response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in check-status response, got: %v", resp)
|
||||
}
|
||||
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||
t.Fatalf("trade_id = %q, want %q", got, tradeID)
|
||||
}
|
||||
if got := int(data["status"].(float64)); got != tc.status {
|
||||
t.Fatalf("status = %d, want %d", got, tc.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1061,6 +1081,123 @@ func TestCheckoutCounter_NotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-paid-001")
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Update("status", mdb.StatusPaySuccess).Error; err != nil {
|
||||
t.Fatalf("mark order paid: %v", err)
|
||||
}
|
||||
|
||||
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
|
||||
}
|
||||
if got, _ := data["redirect_url"].(string); got != "https://merchant.example/return" {
|
||||
t.Fatalf("redirect_url = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
|
||||
expiredCreatedAt := carbon.Now().SubMinutes(config.GetOrderExpirationTime() + 1)
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusExpired,
|
||||
"created_at": expiredCreatedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("mark order expired: %v", err)
|
||||
}
|
||||
|
||||
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
|
||||
}
|
||||
expirationTime, _ := data["expiration_time"].(float64)
|
||||
if int64(expirationTime) > carbon.Now().TimestampMilli() {
|
||||
t.Fatalf("expiration_time = %.0f, want expired timestamp", expirationTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutCounterResp_UnknownOrderReturnsClearError(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/nonexistent-trade-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal checkout counter error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10008 {
|
||||
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
|
||||
}
|
||||
if resp["message"] == "" {
|
||||
t.Fatalf("expected error message, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID string) string {
|
||||
t.Helper()
|
||||
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "tron",
|
||||
"notify_url": "http://localhost/notify",
|
||||
"redirect_url": "https://merchant.example/return",
|
||||
})
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create order failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected create response data, got: %v", resp)
|
||||
}
|
||||
tradeID, _ := data["trade_id"].(string)
|
||||
if tradeID == "" {
|
||||
t.Fatalf("missing trade_id in create response: %v", resp)
|
||||
}
|
||||
return tradeID
|
||||
}
|
||||
|
||||
func getCheckoutCounterRespData(t *testing.T, e *echo.Echo, tradeID string) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/"+tradeID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal checkout counter response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected checkout counter data, got: %v", resp)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
|
||||
// required fields and returns a graceful error when they are missing.
|
||||
func TestSwitchNetwork_MissingFields(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user