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:
line-6000
2026-04-22 02:28:31 +08:00
parent 097c716714
commit 6bb47d4b00
247 changed files with 9933 additions and 1279 deletions
+806
View File
@@ -0,0 +1,806 @@
package route
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/labstack/echo/v4"
)
const (
testAdminUsername = "admin"
testAdminPassword = "test-admin-pass-123"
)
// setupAdminTestEnv sets up the test environment with a seeded admin user
// and returns the echo instance and a valid JWT token.
func setupAdminTestEnv(t *testing.T) (*echo.Echo, string) {
t.Helper()
e := setupTestEnv(t)
// Seed admin user with a known password.
hash, err := data.HashPassword(testAdminPassword)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
})
// Login to obtain a JWT for subsequent authenticated requests.
token := adminLogin(t, e, testAdminUsername, testAdminPassword)
return e, token
}
// adminLogin performs a login request and returns the JWT token.
func adminLogin(t *testing.T, e *echo.Echo, username, password string) string {
t.Helper()
body := map[string]interface{}{
"username": username,
"password": password,
}
rec := doPost(e, "/admin/api/v1/auth/login", body)
if rec.Code != http.StatusOK {
t.Fatalf("login failed: status=%d body=%s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("login unmarshal: %v", err)
}
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("login response missing data field: %s", rec.Body.String())
}
token, ok := dataObj["token"].(string)
if !ok || token == "" {
t.Fatalf("login response missing token: %s", rec.Body.String())
}
return token
}
// doGetAdmin sends an authenticated GET request with a Bearer JWT.
func doGetAdmin(e *echo.Echo, path, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doPostAdmin sends an authenticated POST request with a Bearer JWT.
func doPostAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *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(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doPatchAdmin sends an authenticated PATCH request with a Bearer JWT.
func doPatchAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPatch, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doDeleteAdmin sends an authenticated DELETE request with a Bearer JWT.
func doDeleteAdmin(e *echo.Echo, path, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, path, nil)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doPutAdmin sends an authenticated PUT request with a Bearer JWT.
func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// assertOK asserts the response status is 200 and the status_code field is 200.
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
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 response: %v", err)
}
code, _ := resp["status_code"].(float64)
if code != 200 {
t.Fatalf("expected status_code=200, got %v: %s", code, rec.Body.String())
}
return resp
}
// assertUnauthorized asserts that a request without a valid JWT returns 401.
func assertUnauthorized(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestAdminLogin_Success verifies correct credentials return 200 + JWT.
func TestAdminLogin_Success(t *testing.T) {
e := setupTestEnv(t)
hash, _ := data.HashPassword(testAdminPassword)
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
})
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
"password": testAdminPassword,
})
t.Logf("Login response: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
if dataObj["token"] == "" || dataObj["token"] == nil {
t.Fatal("expected non-empty token in login response")
}
}
// TestAdminLogin_WrongPassword verifies wrong credentials return 4xx.
func TestAdminLogin_WrongPassword(t *testing.T) {
e := setupTestEnv(t)
hash, _ := data.HashPassword(testAdminPassword)
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
})
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
"password": "wrong-password",
})
t.Logf("Login wrong-password response: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code == 200 {
t.Fatal("expected failure for wrong password, got 200")
}
}
}
// TestAdminLogin_MissingFields verifies missing required fields return 4xx.
func TestAdminLogin_MissingFields(t *testing.T) {
e := setupTestEnv(t)
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
// password missing
})
t.Logf("Login missing-fields response: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code == 200 {
t.Fatal("expected validation failure, got code=200")
}
}
}
// TestAdminProtectedRoute_NoToken verifies protected routes reject unauthenticated requests.
func TestAdminProtectedRoute_NoToken(t *testing.T) {
e := setupTestEnv(t)
protectedRoutes := []string{
"/admin/api/v1/auth/me",
"/admin/api/v1/api-keys",
"/admin/api/v1/chains",
"/admin/api/v1/wallets",
"/admin/api/v1/orders",
"/admin/api/v1/dashboard/overview",
"/admin/api/v1/settings",
"/admin/api/v1/rpc-nodes",
"/admin/api/v1/notification-channels",
}
for _, path := range protectedRoutes {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("GET %s → %d", path, rec.Code)
assertUnauthorized(t, rec)
}
}
// TestAdminMe verifies the /auth/me route returns current user info.
func TestAdminMe(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/auth/me", token)
t.Logf("Me response: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
if dataObj["username"] != testAdminUsername {
t.Fatalf("expected username=%s, got %v", testAdminUsername, dataObj["username"])
}
}
// TestAdminLogout verifies the /auth/logout route succeeds with a valid token.
func TestAdminLogout(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/auth/logout", nil, token)
t.Logf("Logout response: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminChangePassword verifies the change-password route works correctly.
func TestAdminChangePassword(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
"old_password": testAdminPassword,
"new_password": "new-pass-456",
}, token)
t.Logf("ChangePassword response: %s", rec.Body.String())
assertOK(t, rec)
// Verify old password no longer works.
rec2 := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
"password": testAdminPassword,
})
var resp2 map[string]interface{}
json.Unmarshal(rec2.Body.Bytes(), &resp2)
code2, _ := resp2["code"].(float64)
if code2 == 200 {
t.Fatal("old password should no longer work after change")
}
}
// ─── API Keys ────────────────────────────────────────────────────────────────
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
// stats, rotate, and delete for API keys.
func TestAdminApiKeys_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — should contain seeded keys from setupTestEnv.
rec := doGetAdmin(e, "/admin/api/v1/api-keys", token)
t.Logf("ListApiKeys: %s", rec.Body.String())
assertOK(t, rec)
// Create a new key.
rec = doPostAdmin(e, "/admin/api/v1/api-keys", map[string]interface{}{
"name": "test-created-key",
}, token)
t.Logf("CreateApiKey: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
keyID := dataObj["id"]
if keyID == nil {
t.Fatal("CreateApiKey response missing id")
}
keyIDStr := fmt.Sprintf("%.0f", keyID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, map[string]interface{}{
"name": "renamed-key",
}, token)
t.Logf("UpdateApiKey: %s", rec.Body.String())
assertOK(t, rec)
// Get secret.
rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/secret", token)
t.Logf("GetApiKeySecret: %s", rec.Body.String())
assertOK(t, rec)
// Get stats.
rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/stats", token)
t.Logf("GetApiKeyStats: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/status", map[string]interface{}{
"status": 2, // disable
}, token)
t.Logf("ChangeApiKeyStatus: %s", rec.Body.String())
assertOK(t, rec)
// Rotate secret.
rec = doPostAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/rotate-secret", nil, token)
t.Logf("RotateApiKeySecret: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, token)
t.Logf("DeleteApiKey: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Chains ──────────────────────────────────────────────────────────────────
// TestAdminChains_ListAndUpdate verifies listing and updating chains.
func TestAdminChains_ListAndUpdate(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List chains — seeded in setupTestEnv.
rec := doGetAdmin(e, "/admin/api/v1/chains", token)
t.Logf("ListChains: %s", rec.Body.String())
assertOK(t, rec)
// Update the tron chain display name.
rec = doPatchAdmin(e, "/admin/api/v1/chains/tron", map[string]interface{}{
"display_name": "TRON Updated",
}, token)
t.Logf("UpdateChain: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Chain Tokens ─────────────────────────────────────────────────────────────
// TestAdminChainTokens_CRUD verifies CRUD operations for chain tokens.
func TestAdminChainTokens_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — seeded chain tokens.
rec := doGetAdmin(e, "/admin/api/v1/chain-tokens", token)
t.Logf("ListChainTokens: %s", rec.Body.String())
assertOK(t, rec)
// Create.
rec = doPostAdmin(e, "/admin/api/v1/chain-tokens", map[string]interface{}{
"network": "ethereum",
"symbol": "TEST",
"contract_address": "0xTESTADDRESS",
"decimals": 18,
}, token)
t.Logf("CreateChainToken: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
tokenID := dataObj["id"]
if tokenID == nil {
t.Fatal("CreateChainToken missing id")
}
tokenIDStr := fmt.Sprintf("%.0f", tokenID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr, map[string]interface{}{
"symbol": "TEST2",
}, token)
t.Logf("UpdateChainToken: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr+"/status", map[string]interface{}{
"enabled": false,
}, token)
t.Logf("ChangeChainTokenStatus: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr, token)
t.Logf("DeleteChainToken: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── RPC Nodes ────────────────────────────────────────────────────────────────
// TestAdminRpcNodes_CRUD verifies CRUD operations for RPC nodes.
func TestAdminRpcNodes_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — empty initially.
rec := doGetAdmin(e, "/admin/api/v1/rpc-nodes", token)
t.Logf("ListRpcNodes: %s", rec.Body.String())
assertOK(t, rec)
// Create.
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": "http",
}, token)
t.Logf("CreateRpcNode: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
nodeID := dataObj["id"]
if nodeID == nil {
t.Fatal("CreateRpcNode missing id")
}
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
"url": "https://eth-mainnet2.example.com",
}, token)
t.Logf("UpdateRpcNode: %s", rec.Body.String())
assertOK(t, rec)
// Health check — network likely unreachable in test, but route must not 404/500.
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
t.Logf("HealthCheckRpcNode: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusNotFound {
t.Fatalf("health-check route returned 404")
}
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, token)
t.Logf("DeleteRpcNode: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Wallets ─────────────────────────────────────────────────────────────────
// TestAdminWallets_CRUD verifies wallet management routes.
func TestAdminWallets_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — two wallets seeded in setupTestEnv.
rec := doGetAdmin(e, "/admin/api/v1/wallets", token)
t.Logf("ListWallets: %s", rec.Body.String())
resp := assertOK(t, rec)
_ = resp
// Add a new wallet.
rec = doPostAdmin(e, "/admin/api/v1/wallets", map[string]interface{}{
"network": "ethereum",
"address": "0xTestEthAddress001",
}, token)
t.Logf("AddWallet: %s", rec.Body.String())
resp2 := assertOK(t, rec)
dataObj, _ := resp2["data"].(map[string]interface{})
walletID := dataObj["id"]
if walletID == nil {
t.Fatal("AddWallet missing id")
}
walletIDStr := fmt.Sprintf("%.0f", walletID.(float64))
// Get single wallet.
rec = doGetAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, token)
t.Logf("GetWallet: %s", rec.Body.String())
assertOK(t, rec)
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, map[string]interface{}{
"address": "0xTestEthAddress002",
}, token)
t.Logf("UpdateWallet: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/wallets/"+walletIDStr+"/status", map[string]interface{}{
"status": 2, // disable
}, token)
t.Logf("ChangeWalletStatus: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, token)
t.Logf("DeleteWallet: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminWallets_BatchImport verifies batch wallet import.
func TestAdminWallets_BatchImport(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/wallets/batch-import", map[string]interface{}{
"network": "ethereum",
"addresses": []string{"0xBatchAddr001", "0xBatchAddr002"},
}, token)
t.Logf("BatchImportWallets: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Orders ──────────────────────────────────────────────────────────────────
// TestAdminOrders_List verifies listing orders (empty initially).
func TestAdminOrders_List(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/orders", token)
t.Logf("ListOrders: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminOrders_Export verifies the export endpoint is accessible.
func TestAdminOrders_Export(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/orders/export", token)
t.Logf("ExportOrders: status=%d", rec.Code)
// Should return 200 (even with empty result set).
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestAdminOrders_GetNotFound verifies a non-existent trade_id returns a
// non-200 business code (not a 404 or 500 HTTP error).
func TestAdminOrders_GetNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id", token)
t.Logf("GetOrder (not found): status=%d body=%s", rec.Code, rec.Body.String())
// Should not be a server error.
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// TestAdminOrders_CloseNotFound verifies closing a non-existent order returns
// a graceful error (not 500).
func TestAdminOrders_CloseNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/close", nil, token)
t.Logf("CloseOrder (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())
}
}
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/mark-paid", nil, token)
t.Logf("MarkOrderPaid (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())
}
}
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/resend-callback", nil, token)
t.Logf("ResendCallback (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())
}
}
// ─── Dashboard ───────────────────────────────────────────────────────────────
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
func TestAdminDashboard_AllRoutes(t *testing.T) {
e, token := setupAdminTestEnv(t)
routes := []string{
"/admin/api/v1/dashboard/overview",
"/admin/api/v1/dashboard/asset-trend",
"/admin/api/v1/dashboard/revenue-trend",
"/admin/api/v1/dashboard/order-stats",
"/admin/api/v1/dashboard/recent-orders",
}
for _, path := range routes {
rec := doGetAdmin(e, path, token)
t.Logf("GET %s → %d: %s", path, rec.Code, rec.Body.String())
assertOK(t, rec)
}
}
// ─── Settings ────────────────────────────────────────────────────────────────
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
func TestAdminSettings_ListAndUpsert(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List settings.
rec := doGetAdmin(e, "/admin/api/v1/settings", token)
t.Logf("ListSettings: %s", rec.Body.String())
assertOK(t, rec)
// Upsert a setting.
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": "rate.forced_usdt_rate", "value": "7.5", "type": "string"},
},
}, token)
t.Logf("UpsertSettings: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
func TestAdminSettings_DeleteNonExistent(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doDeleteAdmin(e, "/admin/api/v1/settings/nonexistent.key", token)
t.Logf("DeleteSetting (nonexistent): status=%d body=%s", rec.Code, rec.Body.String())
// Should not be a server error.
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// ─── Notification Channels ───────────────────────────────────────────────────
// TestAdminNotificationChannels_CRUD verifies notification channel routes.
func TestAdminNotificationChannels_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — empty initially.
rec := doGetAdmin(e, "/admin/api/v1/notification-channels", token)
t.Logf("ListNotificationChannels: %s", rec.Body.String())
assertOK(t, rec)
// Create a webhook channel.
rec = doPostAdmin(e, "/admin/api/v1/notification-channels", map[string]interface{}{
"name": "test-webhook",
"type": "webhook",
"config": map[string]interface{}{"url": "http://localhost/webhook"},
"events": map[string]bool{"order.paid": true},
}, token)
t.Logf("CreateNotificationChannel: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
chanID := dataObj["id"]
if chanID == nil {
t.Fatal("CreateNotificationChannel missing id")
}
chanIDStr := fmt.Sprintf("%.0f", chanID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr, map[string]interface{}{
"name": "renamed-webhook",
}, token)
t.Logf("UpdateNotificationChannel: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr+"/status", map[string]interface{}{
"enabled": false,
}, token)
t.Logf("ChangeNotificationChannelStatus: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr, token)
t.Logf("DeleteNotificationChannel: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminNotificationChannels_TelegramFrontendPayloadCompatibility verifies
// the admin API accepts common frontend telegram payload variants:
// - type in mixed case ("Telegram")
// - config in camelCase keys (botToken/chatId)
// - events as string array
func TestAdminNotificationChannels_TelegramFrontendPayloadCompatibility(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/notification-channels", map[string]interface{}{
"name": "frontend-telegram",
"type": "Telegram",
"config": map[string]interface{}{
"botToken": "123:ABC",
"chatId": "-1001234567890",
},
"events": []string{"order.paid", "order.expired"},
}, token)
t.Logf("CreateFrontendStyleTelegramChannel: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("missing data object: %s", rec.Body.String())
}
eventsRaw, _ := dataObj["events"].(string)
if eventsRaw == "" {
t.Fatalf("missing events json in response data: %s", rec.Body.String())
}
var events map[string]bool
if err := json.Unmarshal([]byte(eventsRaw), &events); err != nil {
t.Fatalf("unmarshal events: %v", err)
}
if !events["order.paid"] || !events["order.expired"] {
t.Fatalf("events not normalized as expected: %+v", events)
}
}
// ─── Orders list-with-sub ─────────────────────────────────────────────────────
// TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel verifies that
// sub-orders do NOT appear at the top level of /orders/list-with-sub and are
// instead nested inside their parent's sub_orders array.
func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
e, token := setupAdminTestEnv(t)
// Seed Ethereum wallet and chain_token so SwitchNetwork can allocate
// a sub-order on that network. The chain record is already seeded by
// MdbTableInit; only the wallet address and token are needed here.
dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Status: mdb.TokenStatusEnable,
})
if err := dao.Mdb.Where(mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT"}).
FirstOrCreate(&mdb.ChainToken{
Network: mdb.NetworkEthereum, Symbol: "USDT",
ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
Decimals: 6, Enabled: true,
}).Error; err != nil {
t.Fatalf("seed ethereum USDT token: %v", err)
}
// Create a parent order via the gmpay API.
parentBody := signBody(map[string]interface{}{
"order_id": "listsubtest-parent",
"amount": 5.0,
"currency": "CNY",
"token": "USDT",
"network": "tron",
"notify_url": "https://merchant.example/callback",
"redirect_url": "https://merchant.example/redirect",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
t.Logf("CreateParent: %s", rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("create parent: got %d: %s", rec.Code, rec.Body.String())
}
var parentResp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &parentResp)
parentTradeId := parentResp["data"].(map[string]interface{})["trade_id"].(string)
// Switch to Ethereum to create a sub-order (no signature required).
rec = doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": parentTradeId,
"token": "USDT",
"network": "ethereum",
})
t.Logf("SwitchNetwork: %s", rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("switch network: got %d: %s", rec.Code, rec.Body.String())
}
var switchResp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &switchResp)
subTradeId := switchResp["data"].(map[string]interface{})["trade_id"].(string)
// Fetch list-with-sub.
rec = doGetAdmin(e, "/admin/api/v1/orders/list-with-sub", token)
t.Logf("ListWithSub: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("missing data object: %s", rec.Body.String())
}
listRaw, _ := dataObj["list"].([]interface{})
// Collect all trade_ids at the top level and in sub_orders.
topLevelIds := map[string]bool{}
subOrderIds := map[string]bool{}
for _, item := range listRaw {
entry := item.(map[string]interface{})
tradeId := entry["trade_id"].(string)
topLevelIds[tradeId] = true
if subs, ok := entry["sub_orders"].([]interface{}); ok {
for _, s := range subs {
sub := s.(map[string]interface{})
subOrderIds[sub["trade_id"].(string)] = true
}
}
}
// Sub-order must NOT appear at the top level.
if topLevelIds[subTradeId] {
t.Fatalf("sub-order trade_id=%s appears at top level — should be nested only", subTradeId)
}
// Parent must appear at the top level.
if !topLevelIds[parentTradeId] {
t.Fatalf("parent trade_id=%s missing from top level", parentTradeId)
}
// Sub-order must appear under the parent's sub_orders.
if !subOrderIds[subTradeId] {
t.Fatalf("sub-order trade_id=%s not found in any sub_orders array", subTradeId)
}
}
+147 -56
View File
@@ -2,19 +2,22 @@ package route
import (
"bytes"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/controller/admin"
"github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
)
// RegisterRoute 路由注册
@@ -31,55 +34,20 @@ func RegisterRoute(e *echo.Echo) {
// payment routes
paymentRoute := e.Group("/payments")
// for epusdt
epusdtV1 := paymentRoute.Group("/epusdt/v1")
epusdtV1.POST("/order/create-transaction", func(ctx echo.Context) error {
// add default token and currency for old plugin
body := make(map[string]interface{})
if err := ctx.Bind(&body); err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
if _, ok := body["token"]; !ok {
body["token"] = "usdt"
}
if _, ok := body["currency"]; !ok {
body["currency"] = "cny"
}
if _, ok := body["network"]; !ok {
body["network"] = "tron"
}
ctx.Set("request_body", body)
jsonBytes, err := json.Marshal(body)
if err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
ctx.Request().ContentLength = int64(len(jsonBytes))
return comm.Ctrl.CreateTransaction(ctx)
}, middleware.CheckApiSign())
// gmpay v1 routes
gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
gmpayV1.POST("/supported-assets/add", comm.Ctrl.AddSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/update", comm.Ctrl.UpdateSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/delete", comm.Ctrl.DeleteSupportedAsset, middleware.CheckApiToken())
// wallet management routes
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
walletV1.POST("/add", comm.Ctrl.AddWallet)
walletV1.GET("/list", comm.Ctrl.ListWallets)
walletV1.GET("/:id", comm.Ctrl.GetWallet)
walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
// gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
// gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
// epay v1 routes
//
// Signature uses the pid from the request as the api_keys lookup
// key; the matching row's secret_key plays the role of the legacy
// EPAY "key" value. Env-based (EPAY_PID/EPAY_KEY) fallback was
// removed — the default seeded api_key is always available, and
// having two sources of truth led to inbound/outbound sig mismatch.
epayV1 := paymentRoute.Group("/epay/v1")
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
params := make(map[string]interface{})
@@ -107,11 +75,11 @@ func RegisterRoute(e *echo.Echo) {
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
switch t := v.(type) {
case string:
return t
}
return s
return ""
}
signstr := getString(params, "sign")
@@ -122,17 +90,29 @@ func RegisterRoute(e *echo.Echo) {
delete(params, "sign")
delete(params, "sign_type")
// we need to add pid to params for signature verification
params["pid"] = config.GetEpayPid()
pidStr := getString(params, "pid")
if pidStr == "" {
return constant.SignatureErr
}
apiKeyRow, err := data.GetEnabledApiKey(pidStr)
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
return constant.SignatureErr
}
checkSignature, err := sign.Get(params, config.GetApiAuthToken())
checkSignature, err := sign.Get(params, apiKeyRow.SecretKey)
if err != nil {
return constant.SignatureErr
}
if checkSignature != signstr {
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signstr)) != 1 {
return constant.SignatureErr
}
if !middleware.IsIPWhitelisted(apiKeyRow.IpWhitelist, ctx.RealIP()) {
return constant.SignatureErr
}
_ = data.TouchApiKeyUsage(apiKeyRow.ID)
money := getString(params, "money")
name := getString(params, "name")
notifyURL := getString(params, "notify_url")
@@ -145,9 +125,9 @@ func RegisterRoute(e *echo.Echo) {
}
body := map[string]interface{}{
"token": "usdt",
"currency": "cny",
"network": "tron",
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
"amount": amountFloat,
"notify_url": notifyURL,
"order_id": outTradeNo,
@@ -158,6 +138,8 @@ func RegisterRoute(e *echo.Echo) {
}
ctx.Set("request_body", body)
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
jsonBytes, err := json.Marshal(body)
if err != nil {
@@ -170,6 +152,115 @@ func RegisterRoute(e *echo.Echo) {
ctx.Request().Header.Set("Content-Type", "application/json")
return comm.Ctrl.CreateTransactionAndRedirect(ctx)
})
registerAdminRoutes(e)
}
// registerAdminRoutes wires the management console API surface under
// /admin/api/v1. Everything except /auth/login requires a valid JWT.
func registerAdminRoutes(e *echo.Echo) {
// CORS for the management console. The admin SPA is commonly served
// from a different origin (local dev, CDN, etc.), so allow any origin
// but require explicit echoing — browsers refuse wildcard + credentials.
adminCORS := echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOriginFunc: func(origin string) (bool, error) { return true, nil },
AllowMethods: []string{
http.MethodGet,
http.MethodPost,
http.MethodPatch,
http.MethodPut,
http.MethodDelete,
http.MethodOptions,
},
AllowHeaders: []string{
echo.HeaderOrigin,
echo.HeaderContentType,
echo.HeaderAuthorization,
echo.HeaderAccept,
echo.HeaderXRequestedWith,
},
AllowCredentials: true,
MaxAge: 86400,
})
adminV1 := e.Group("/admin/api/v1", adminCORS)
// Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login)
// Authenticated
authed := adminV1.Group("", middleware.CheckAdminJWT())
authed.POST("/auth/logout", admin.Ctrl.Logout)
authed.GET("/auth/me", admin.Ctrl.Me)
authed.POST("/auth/password", admin.Ctrl.ChangePassword)
// API key management
authed.GET("/api-keys", admin.Ctrl.ListApiKeys)
authed.POST("/api-keys", admin.Ctrl.CreateApiKey)
authed.PATCH("/api-keys/:id", admin.Ctrl.UpdateApiKey)
authed.POST("/api-keys/:id/status", admin.Ctrl.ChangeApiKeyStatus)
authed.POST("/api-keys/:id/rotate-secret", admin.Ctrl.RotateApiKeySecret)
authed.DELETE("/api-keys/:id", admin.Ctrl.DeleteApiKey)
authed.GET("/api-keys/:id/stats", admin.Ctrl.GetApiKeyStats)
authed.GET("/api-keys/:id/secret", admin.Ctrl.GetApiKeySecret)
// Notification channels
authed.GET("/notification-channels", admin.Ctrl.ListNotificationChannels)
authed.POST("/notification-channels", admin.Ctrl.CreateNotificationChannel)
authed.PATCH("/notification-channels/:id", admin.Ctrl.UpdateNotificationChannel)
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
// gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
authed.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) // wrap for admin console, same handler as public endpoint
// Chains
authed.GET("/chains", admin.Ctrl.ListChains)
authed.PATCH("/chains/:network", admin.Ctrl.UpdateChain)
// Chain tokens (per-chain token catalog)
authed.GET("/chain-tokens", admin.Ctrl.ListChainTokens)
authed.POST("/chain-tokens", admin.Ctrl.CreateChainToken)
authed.PATCH("/chain-tokens/:id", admin.Ctrl.UpdateChainToken)
authed.POST("/chain-tokens/:id/status", admin.Ctrl.ChangeChainTokenStatus)
authed.DELETE("/chain-tokens/:id", admin.Ctrl.DeleteChainToken)
// RPC nodes
authed.GET("/rpc-nodes", admin.Ctrl.ListRpcNodes)
authed.POST("/rpc-nodes", admin.Ctrl.CreateRpcNode)
authed.PATCH("/rpc-nodes/:id", admin.Ctrl.UpdateRpcNode)
authed.DELETE("/rpc-nodes/:id", admin.Ctrl.DeleteRpcNode)
authed.POST("/rpc-nodes/:id/health-check", admin.Ctrl.HealthCheckRpcNode)
// Wallet (address) management
authed.GET("/wallets", admin.Ctrl.AdminListWallets)
authed.POST("/wallets", admin.Ctrl.AdminAddWallet)
authed.GET("/wallets/:id", admin.Ctrl.AdminGetWallet)
authed.PATCH("/wallets/:id", admin.Ctrl.AdminUpdateWallet)
authed.POST("/wallets/:id/status", admin.Ctrl.AdminChangeWalletStatus)
authed.DELETE("/wallets/:id", admin.Ctrl.AdminDeleteWallet)
authed.POST("/wallets/batch-import", admin.Ctrl.AdminBatchImportWallets)
// Orders
authed.GET("/orders", admin.Ctrl.ListOrders)
authed.GET("/orders/export", admin.Ctrl.ExportOrders)
authed.GET("/orders/list-with-sub", admin.Ctrl.ListOrdersWithSub)
authed.GET("/orders/:trade_id", admin.Ctrl.GetOrder)
authed.GET("/orders/:trade_id/with-sub", admin.Ctrl.GetOrderWithSub)
authed.POST("/orders/:trade_id/close", admin.Ctrl.CloseOrder)
authed.POST("/orders/:trade_id/mark-paid", admin.Ctrl.MarkOrderPaid)
authed.POST("/orders/:trade_id/resend-callback", admin.Ctrl.ResendCallback)
// Dashboard
authed.GET("/dashboard/overview", admin.Ctrl.Overview)
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
// Settings
authed.GET("/settings", admin.Ctrl.ListSettings)
authed.PUT("/settings", admin.Ctrl.UpsertSettings)
authed.DELETE("/settings/:key", admin.Ctrl.DeleteSetting)
}
+392 -309
View File
@@ -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")
}
}