mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-08 18:56:14 +00:00
feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets, orders, RPC nodes, settings, dashboard stats, notifications - Add JWT-based admin authentication and API key auth middleware - Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket - Add RPC node health check job with automatic failover - Add Telegram notification channel for order events - Add installer for first-run database initialization - Add new DB models: admin_user, api_key, chain, chain_token, rpc_node, settings, notification_channel - Add order statistics aggregation (daily/monthly/by-chain) - Serve built admin SPA from embedded www/ assets - Add comprehensive test coverage for all new features
This commit is contained in:
+392
-309
@@ -11,11 +11,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/sign"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const testAPIToken = "test-secret-token"
|
||||
@@ -28,8 +30,6 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
||||
// minimal viper config
|
||||
viper.Reset()
|
||||
viper.Set("db_type", "sqlite")
|
||||
viper.Set("api_auth_token", testAPIToken)
|
||||
viper.Set("epay_pid", 1)
|
||||
viper.Set("app_uri", "http://localhost:8080")
|
||||
viper.Set("order_expiration_time", 10)
|
||||
viper.Set("api_rate_url", "")
|
||||
@@ -54,30 +54,62 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
||||
}
|
||||
|
||||
// ensure tables exist (MdbTableInit uses sync.Once, so migrate directly)
|
||||
dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{}, &mdb.SupportedAsset{})
|
||||
dao.Mdb.AutoMigrate(
|
||||
&mdb.Orders{},
|
||||
&mdb.WalletAddress{},
|
||||
&mdb.AdminUser{},
|
||||
&mdb.ApiKey{},
|
||||
&mdb.Setting{},
|
||||
&mdb.NotificationChannel{},
|
||||
&mdb.Chain{},
|
||||
&mdb.ChainToken{},
|
||||
&mdb.RpcNode{},
|
||||
)
|
||||
|
||||
// reset the settings cache so stale entries from a prior test don't leak
|
||||
_ = data.ReloadSettings()
|
||||
|
||||
// seed wallet addresses
|
||||
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
|
||||
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable})
|
||||
// seed supported assets if empty
|
||||
var supportCnt int64
|
||||
dao.Mdb.Model(&mdb.SupportedAsset{}).Count(&supportCnt)
|
||||
if supportCnt == 0 {
|
||||
dao.Mdb.Create(&[]mdb.SupportedAsset{
|
||||
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
})
|
||||
}
|
||||
// seed chains so scanners know the test networks are "enabled".
|
||||
// Use ON CONFLICT DO NOTHING: MdbTableInit (called via DBInit) uses sync.Once
|
||||
// and may have already seeded these rows into this DB on the first test run.
|
||||
dao.Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.Chain{
|
||||
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true},
|
||||
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true},
|
||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true},
|
||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true},
|
||||
})
|
||||
|
||||
// seed chain_tokens — GetSupportedAssets now reads from this table.
|
||||
// Same idempotency rationale as above.
|
||||
dao.Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
})
|
||||
|
||||
// Seed one universal api_keys row. The test's testAPIToken doubles
|
||||
// as both pid and secret_key so sign.Get(body, testAPIToken) calls
|
||||
// stay valid.
|
||||
dao.Mdb.Create(&mdb.ApiKey{
|
||||
Name: "test-universal",
|
||||
Pid: testAPIToken,
|
||||
SecretKey: testAPIToken,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
// Additional numeric-PID row for EPAY tests (EPAY pid must be numeric).
|
||||
dao.Mdb.Create(&mdb.ApiKey{
|
||||
Name: "test-epay-pid-1",
|
||||
Pid: "1",
|
||||
SecretKey: testAPIToken,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
|
||||
e := echo.New()
|
||||
RegisterRoute(e)
|
||||
@@ -85,6 +117,11 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
||||
}
|
||||
|
||||
func signBody(body map[string]interface{}) map[string]interface{} {
|
||||
// Signature middleware looks up api_keys by the "pid" field and
|
||||
// uses that row's secret_key as the signing bizKey.
|
||||
if _, ok := body["pid"]; !ok {
|
||||
body["pid"] = testAPIToken
|
||||
}
|
||||
sig, _ := sign.Get(body, testAPIToken)
|
||||
body["signature"] = sig
|
||||
return body
|
||||
@@ -121,42 +158,6 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network.
|
||||
func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "test-tron-001",
|
||||
"amount": 1.00,
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
|
||||
rec := doPost(e, "/payments/epusdt/v1/order/create-transaction", body)
|
||||
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in response, got: %v", resp)
|
||||
}
|
||||
|
||||
if data["trade_id"] == nil || data["trade_id"] == "" {
|
||||
t.Error("expected trade_id in response")
|
||||
}
|
||||
if data["receive_address"] != "TTestTronAddress001" {
|
||||
t.Errorf("expected tron address, got: %v", data["receive_address"])
|
||||
}
|
||||
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
|
||||
func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
@@ -223,24 +224,6 @@ func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", testAPIToken)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func doPostWithToken(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
|
||||
jsonBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set("Authorization", testAPIToken)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
||||
t.Helper()
|
||||
var resp map[string]interface{}
|
||||
@@ -250,6 +233,183 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac
|
||||
return resp
|
||||
}
|
||||
|
||||
// signGmpayFormValues builds a signed url.Values for the GMPAY create-transaction endpoint.
|
||||
// The GMPAY middleware uses "signature" (not "sign") and "pid" (not numeric pid from EPAY).
|
||||
func signGmpayFormValues(values url.Values) url.Values {
|
||||
if values.Get("pid") == "" {
|
||||
values.Set("pid", testAPIToken)
|
||||
}
|
||||
params := make(map[string]interface{})
|
||||
for k, vs := range values {
|
||||
if k == "signature" || len(vs) == 0 {
|
||||
continue
|
||||
}
|
||||
params[k] = vs[0]
|
||||
}
|
||||
sig, _ := sign.Get(params, testAPIToken)
|
||||
values.Set("signature", sig)
|
||||
return values
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1FormData verifies that the GMPAY create-transaction endpoint
|
||||
// accepts application/x-www-form-urlencoded in addition to JSON.
|
||||
func TestCreateOrderGmpayV1FormData(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
values := signGmpayFormValues(url.Values{
|
||||
"order_id": {"test-form-001"},
|
||||
"amount": {"1.00"},
|
||||
"token": {"usdt"},
|
||||
"currency": {"cny"},
|
||||
"network": {"solana"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
})
|
||||
|
||||
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
|
||||
t.Logf("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{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in response, got: %v", resp)
|
||||
}
|
||||
if data["trade_id"] == nil || data["trade_id"] == "" {
|
||||
t.Error("expected trade_id in response")
|
||||
}
|
||||
t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
|
||||
}
|
||||
|
||||
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/supported-assets
|
||||
// and returns a map of network → []token for easy assertions.
|
||||
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("getSupportedNetworks: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := parseResp(t, rec)
|
||||
rawData, _ := resp["data"].(map[string]interface{})
|
||||
rawSupports, _ := rawData["supports"].([]interface{})
|
||||
result := make(map[string][]string, len(rawSupports))
|
||||
for _, item := range rawSupports {
|
||||
row := item.(map[string]interface{})
|
||||
network := row["network"].(string)
|
||||
rawTokens, _ := row["tokens"].([]interface{})
|
||||
tokens := make([]string, 0, len(rawTokens))
|
||||
for _, tok := range rawTokens {
|
||||
tokens = append(tokens, tok.(string))
|
||||
}
|
||||
result[network] = tokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TestGetSupportedAssets_ChainTokenToggle verifies that:
|
||||
// - disabling a chain_token removes that token (and possibly the whole network)
|
||||
// from the /supported-assets response
|
||||
// - re-enabling it brings it back
|
||||
func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Baseline: tron should appear with USDT and TRX.
|
||||
before := getSupportedNetworks(t, e)
|
||||
if _, ok := before["tron"]; !ok {
|
||||
t.Fatal("expected tron in baseline supported-assets")
|
||||
}
|
||||
|
||||
// Disable the tron USDT chain_token.
|
||||
dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", "tron", "USDT").
|
||||
Update("enabled", false)
|
||||
|
||||
after := getSupportedNetworks(t, e)
|
||||
tronTokens := after["tron"]
|
||||
for _, tok := range tronTokens {
|
||||
if tok == "USDT" {
|
||||
t.Fatal("USDT should be absent after disabling tron USDT chain_token")
|
||||
}
|
||||
}
|
||||
t.Logf("After disabling tron USDT: tron tokens = %v", tronTokens)
|
||||
|
||||
// Disable the tron TRX chain_token as well — now tron has no tokens → network disappears.
|
||||
dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", "tron", "TRX").
|
||||
Update("enabled", false)
|
||||
|
||||
afterAllDisabled := getSupportedNetworks(t, e)
|
||||
if _, ok := afterAllDisabled["tron"]; ok {
|
||||
t.Fatal("tron should disappear from supported-assets when all its chain_tokens are disabled")
|
||||
}
|
||||
t.Logf("After disabling all tron tokens: networks = %v", afterAllDisabled)
|
||||
|
||||
// Re-enable tron USDT — tron reappears with only USDT.
|
||||
dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", "tron", "USDT").
|
||||
Update("enabled", true)
|
||||
|
||||
restored := getSupportedNetworks(t, e)
|
||||
tronRestored, ok := restored["tron"]
|
||||
if !ok {
|
||||
t.Fatal("tron should reappear after re-enabling USDT chain_token")
|
||||
}
|
||||
found := false
|
||||
for _, tok := range tronRestored {
|
||||
if tok == "USDT" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected USDT in restored tron tokens, got %v", tronRestored)
|
||||
}
|
||||
t.Logf("After re-enabling tron USDT: tron tokens = %v", tronRestored)
|
||||
}
|
||||
|
||||
// TestGetSupportedAssets_WalletAddressToggle verifies that:
|
||||
// - disabling ALL wallet addresses for a network removes that network from
|
||||
// the /supported-assets response (even if chain + tokens are still enabled)
|
||||
// - re-enabling any address brings the network back
|
||||
func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Baseline: solana should be present.
|
||||
before := getSupportedNetworks(t, e)
|
||||
if _, ok := before["solana"]; !ok {
|
||||
t.Fatal("expected solana in baseline supported-assets")
|
||||
}
|
||||
|
||||
// Disable the only solana wallet address.
|
||||
dao.Mdb.Model(&mdb.WalletAddress{}).
|
||||
Where("network = ?", mdb.NetworkSolana).
|
||||
Update("status", mdb.TokenStatusDisable)
|
||||
|
||||
after := getSupportedNetworks(t, e)
|
||||
if _, ok := after["solana"]; ok {
|
||||
t.Fatal("solana should disappear from supported-assets when all its wallet addresses are disabled")
|
||||
}
|
||||
t.Logf("After disabling solana wallets: networks = %v", after)
|
||||
|
||||
// Re-enable the solana wallet.
|
||||
dao.Mdb.Model(&mdb.WalletAddress{}).
|
||||
Where("network = ?", mdb.NetworkSolana).
|
||||
Update("status", mdb.TokenStatusEnable)
|
||||
|
||||
restored := getSupportedNetworks(t, e)
|
||||
if _, ok := restored["solana"]; !ok {
|
||||
t.Fatal("solana should reappear after re-enabling its wallet address")
|
||||
}
|
||||
t.Logf("After re-enabling solana wallets: solana tokens = %v", restored["solana"])
|
||||
}
|
||||
|
||||
func TestGetSupportedAssetsPublic(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
@@ -291,239 +451,6 @@ func TestGetSupportedAssetsPublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedAssetCRUD(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// public query list (no auth)
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/records", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
resp := parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("public list failed: %v", resp)
|
||||
}
|
||||
|
||||
// create requires auth
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
|
||||
"network": "arb",
|
||||
"token": "usdt",
|
||||
"status": 1,
|
||||
})
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("add supported asset failed: %v", resp)
|
||||
}
|
||||
created := resp["data"].(map[string]interface{})
|
||||
assetID := fmt.Sprintf("%.0f", created["id"].(float64))
|
||||
|
||||
// get by id is public
|
||||
req = httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/"+assetID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("public get by id failed: %v", resp)
|
||||
}
|
||||
|
||||
// update requires auth
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/update", map[string]interface{}{
|
||||
"network": "arbitrum",
|
||||
"token": "usdc",
|
||||
"status": 1,
|
||||
})
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("update supported asset failed: %v", resp)
|
||||
}
|
||||
updated := resp["data"].(map[string]interface{})
|
||||
if updated["network"] != "arbitrum" || updated["token"] != "USDC" {
|
||||
t.Fatalf("unexpected updated data: %v", updated)
|
||||
}
|
||||
|
||||
// delete requires auth
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/delete", nil)
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("delete supported asset failed: %v", resp)
|
||||
}
|
||||
|
||||
// recreate after delete should restore soft-deleted row, not unique-conflict
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
|
||||
"network": "arbitrum",
|
||||
"token": "usdc",
|
||||
"status": 1,
|
||||
})
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("recreate after delete failed: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletAddAndList tests adding wallets via API and listing them.
|
||||
func TestWalletAddAndList(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Add a solana wallet
|
||||
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
|
||||
"network": "solana",
|
||||
"address": "NewSolWallet001",
|
||||
})
|
||||
t.Logf("Add: %s", rec.Body.String())
|
||||
resp := parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("add wallet failed: %v", resp)
|
||||
}
|
||||
|
||||
// Add a tron wallet
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
|
||||
"network": "tron",
|
||||
"address": "NewTronWallet001",
|
||||
})
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("add tron wallet failed: %v", resp)
|
||||
}
|
||||
|
||||
// List all wallets
|
||||
rec = doGet(e, "/payments/gmpay/v1/wallet/list")
|
||||
resp = parseResp(t, rec)
|
||||
wallets := resp["data"].([]interface{})
|
||||
// 2 seeded + 2 added = 4
|
||||
if len(wallets) != 4 {
|
||||
t.Fatalf("expected 4 wallets, got %d: %v", len(wallets), wallets)
|
||||
}
|
||||
|
||||
// List by network
|
||||
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
|
||||
resp = parseResp(t, rec)
|
||||
wallets = resp["data"].([]interface{})
|
||||
if len(wallets) != 2 {
|
||||
t.Fatalf("expected 2 solana wallets, got %d", len(wallets))
|
||||
}
|
||||
|
||||
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=tron")
|
||||
resp = parseResp(t, rec)
|
||||
wallets = resp["data"].([]interface{})
|
||||
if len(wallets) != 2 {
|
||||
t.Fatalf("expected 2 tron wallets, got %d", len(wallets))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletDuplicateRejected tests that adding the same network+address twice fails.
|
||||
func TestWalletDuplicateRejected(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
body := map[string]interface{}{"network": "solana", "address": "DupWallet001"}
|
||||
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
|
||||
resp := parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("first add failed: %v", resp)
|
||||
}
|
||||
|
||||
// Same network+address should fail
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) == 200 {
|
||||
t.Fatal("expected duplicate to be rejected")
|
||||
}
|
||||
t.Logf("Duplicate rejected: %v", resp["message"])
|
||||
|
||||
// Same address, different network should succeed
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
|
||||
"network": "tron",
|
||||
"address": "DupWallet001",
|
||||
})
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("same address on different network should succeed: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletStatusAndDelete tests enable/disable/delete operations.
|
||||
func TestWalletStatusAndDelete(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Add a wallet
|
||||
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
|
||||
"network": "solana",
|
||||
"address": "StatusTestWallet",
|
||||
})
|
||||
resp := parseResp(t, rec)
|
||||
wallet := resp["data"].(map[string]interface{})
|
||||
walletID := fmt.Sprintf("%.0f", wallet["id"].(float64))
|
||||
|
||||
// Get wallet
|
||||
rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("get wallet failed: %v", resp)
|
||||
}
|
||||
|
||||
// Disable wallet
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/status", map[string]interface{}{
|
||||
"status": 2,
|
||||
})
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("disable wallet failed: %v", resp)
|
||||
}
|
||||
|
||||
// Verify disabled — should not appear in available list
|
||||
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
|
||||
resp = parseResp(t, rec)
|
||||
wallets := resp["data"].([]interface{})
|
||||
for _, w := range wallets {
|
||||
wm := w.(map[string]interface{})
|
||||
if wm["address"] == "StatusTestWallet" && wm["status"].(float64) != 2 {
|
||||
t.Error("wallet should be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete wallet
|
||||
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/delete", nil)
|
||||
resp = parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("delete wallet failed: %v", resp)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
|
||||
resp = parseResp(t, rec)
|
||||
// Should return not found
|
||||
if resp["status_code"].(float64) == 200 {
|
||||
data := resp["data"].(map[string]interface{})
|
||||
if data["id"].(float64) > 0 {
|
||||
t.Error("wallet should be deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletAuthRequired tests that wallet APIs require auth token.
|
||||
func TestWalletAuthRequired(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// No auth header — should not return success
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/wallet/list", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
// The response should indicate auth failure (not 200 success)
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
// echo may return plain text error
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Error("expected auth failure without token")
|
||||
}
|
||||
t.Logf("Auth rejected (non-JSON): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
return
|
||||
}
|
||||
statusCode, _ := resp["status_code"].(float64)
|
||||
if statusCode == 200 {
|
||||
t.Error("expected auth failure without token")
|
||||
}
|
||||
t.Logf("Auth rejected: %v", resp)
|
||||
}
|
||||
|
||||
// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix.
|
||||
func TestCreateOrderNetworkIsolation(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
@@ -602,3 +529,159 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
|
||||
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus_NotFound verifies that /pay/check-status/:trade_id returns a
|
||||
// graceful JSON error (not 500) when the trade_id doesn't exist.
|
||||
func TestCheckStatus_NotFound(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/nonexistent-trade-id", nil)
|
||||
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.StatusNotFound && rec.Body.Len() == 0 {
|
||||
t.Fatalf("route returned 404 with empty body — route may not be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
|
||||
// and a status field when the order exists.
|
||||
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")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckoutCounter_NotFound verifies that /pay/checkout-counter/:trade_id
|
||||
// with an unknown trade_id does not return an empty 404 (which would mean the
|
||||
// route is not registered). When the static HTML template is present the
|
||||
// controller renders it with a 404 status; when it is absent (test env) the
|
||||
// controller returns a 500 with a descriptive body — both outcomes are
|
||||
// acceptable because the route IS registered and functional.
|
||||
func TestCheckoutCounter_NotFound(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter/nonexistent-trade-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckoutCounter(not found): status=%d content-type=%s body=%s",
|
||||
rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||
// A completely empty 404 means the route is not registered at all.
|
||||
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
|
||||
t.Fatalf("route not registered (404 with empty body)")
|
||||
}
|
||||
// A 500 whose body mentions a missing file is expected in test environments
|
||||
// where the static directory is not present.
|
||||
if rec.Code >= 500 && rec.Body.Len() == 0 {
|
||||
t.Fatalf("unexpected server error with empty body: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
e := setupTestEnv(t)
|
||||
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
// trade_id, token, network are all missing
|
||||
})
|
||||
t.Logf("SwitchNetwork(missing fields): 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())
|
||||
}
|
||||
// Must return a non-200 business code for validation failure.
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code == 200 {
|
||||
t.Fatal("expected validation error for missing fields, got code=200")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_OrderNotFound verifies that /pay/switch-network returns a
|
||||
// graceful error when the referenced trade_id doesn't exist.
|
||||
func TestSwitchNetwork_OrderNotFound(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
"trade_id": "nonexistent-trade-id",
|
||||
"token": "USDT",
|
||||
"network": "tron",
|
||||
})
|
||||
t.Logf("SwitchNetwork(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())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_WithOrder verifies /pay/switch-network can create a
|
||||
// sub-order when given a valid parent trade_id.
|
||||
func TestSwitchNetwork_WithOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create a parent order on solana.
|
||||
createBody := signBody(map[string]interface{}{
|
||||
"order_id": "switch-net-parent-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create parent 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")
|
||||
}
|
||||
|
||||
// Switch to tron.
|
||||
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
"trade_id": tradeId,
|
||||
"token": "USDT",
|
||||
"network": "tron",
|
||||
})
|
||||
t.Logf("SwitchNetwork(valid): 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 switch-network response")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user