mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
1992 lines
72 KiB
Go
1992 lines
72 KiB
Go
package route
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/GMWalletApp/epusdt/config"
|
|
"github.com/GMWalletApp/epusdt/model/dao"
|
|
"github.com/GMWalletApp/epusdt/model/data"
|
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
"github.com/GMWalletApp/epusdt/model/service"
|
|
"github.com/GMWalletApp/epusdt/util/constant"
|
|
appLog "github.com/GMWalletApp/epusdt/util/log"
|
|
"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
|
|
}
|
|
|
|
// doGet sends an unauthenticated GET request.
|
|
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
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/dashboard/rpc-stats",
|
|
"/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")
|
|
}
|
|
}
|
|
|
|
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
|
|
// and hash-based "password changed" detection flow.
|
|
func TestAdminInitPasswordFlow(t *testing.T) {
|
|
e := setupTestEnv(t)
|
|
const initPassword = "init-pass-123456"
|
|
|
|
adminHash, err := data.HashPassword(initPassword)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
dao.Mdb.Create(&mdb.AdminUser{
|
|
Username: testAdminUsername,
|
|
PasswordHash: adminHash,
|
|
Status: mdb.AdminUserStatusEnable,
|
|
})
|
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordPlain, initPassword, mdb.SettingTypeString)
|
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordHash, data.HashInitialAdminPassword(initPassword), mdb.SettingTypeString)
|
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordFetched, "false", mdb.SettingTypeBool)
|
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordChanged, "false", mdb.SettingTypeBool)
|
|
|
|
recHash := doGet(e, "/admin/api/v1/auth/init-password-hash")
|
|
respHash := assertOK(t, recHash)
|
|
hashData, _ := respHash["data"].(map[string]interface{})
|
|
if got := hashData["password_hash"]; got != data.HashInitialAdminPassword(initPassword) {
|
|
t.Fatalf("expected init hash %s, got %v", data.HashInitialAdminPassword(initPassword), got)
|
|
}
|
|
if got, _ := hashData["password_changed"].(bool); got {
|
|
t.Fatalf("expected password_changed=false before change, got true")
|
|
}
|
|
|
|
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
|
|
respFetch := assertOK(t, recFetch)
|
|
fetchData, _ := respFetch["data"].(map[string]interface{})
|
|
if fetchData["username"] != testAdminUsername {
|
|
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
|
|
}
|
|
if fetchData["password"] != initPassword {
|
|
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
|
|
}
|
|
var plaintextRows int64
|
|
if err := dao.Mdb.Unscoped().
|
|
Model(&mdb.Setting{}).
|
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
|
Count(&plaintextRows).Error; err != nil {
|
|
t.Fatalf("count init password plaintext rows: %v", err)
|
|
}
|
|
if plaintextRows != 0 {
|
|
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows)
|
|
}
|
|
|
|
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
|
|
if recFetch2.Code != http.StatusBadRequest {
|
|
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
|
|
}
|
|
var respFetch2 map[string]interface{}
|
|
if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
|
|
t.Fatalf("unmarshal second fetch response: %v", err)
|
|
}
|
|
if got := int(respFetch2["status_code"].(float64)); got != 10040 {
|
|
t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2)
|
|
}
|
|
if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] {
|
|
t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2)
|
|
}
|
|
|
|
token := adminLogin(t, e, testAdminUsername, initPassword)
|
|
|
|
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
|
respMe1 := assertOK(t, recMe1)
|
|
meData1, _ := respMe1["data"].(map[string]interface{})
|
|
if got, _ := meData1["password_is_default"].(bool); !got {
|
|
t.Fatalf("expected password_is_default=true before change, got %v", got)
|
|
}
|
|
|
|
recChange := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
|
|
"old_password": initPassword,
|
|
"new_password": "new-pass-789",
|
|
}, token)
|
|
assertOK(t, recChange)
|
|
|
|
recHash2 := doGet(e, "/admin/api/v1/auth/init-password-hash")
|
|
respHash2 := assertOK(t, recHash2)
|
|
hashData2, _ := respHash2["data"].(map[string]interface{})
|
|
if got, _ := hashData2["password_changed"].(bool); !got {
|
|
t.Fatalf("expected password_changed=true after change, got %v", got)
|
|
}
|
|
|
|
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
|
respMe2 := assertOK(t, recMe2)
|
|
meData2, _ := respMe2["data"].(map[string]interface{})
|
|
if got, _ := meData2["password_is_default"].(bool); got {
|
|
t.Fatalf("expected password_is_default=false after change, got %v", got)
|
|
}
|
|
}
|
|
|
|
// ─── 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")
|
|
}
|
|
if got, _ := dataObj["purpose"].(string); got != mdb.RpcNodePurposeGeneral {
|
|
t.Fatalf("created purpose = %q, want %q", got, mdb.RpcNodePurposeGeneral)
|
|
}
|
|
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",
|
|
"purpose": mdb.RpcNodePurposeManualVerify,
|
|
}, token)
|
|
t.Logf("UpdateRpcNode: %s", rec.Body.String())
|
|
assertOK(t, rec)
|
|
updatedNode, err := data.GetRpcNodeByID(uint64(nodeID.(float64)))
|
|
if err != nil {
|
|
t.Fatalf("reload rpc node: %v", err)
|
|
}
|
|
if updatedNode.Purpose != mdb.RpcNodePurposeManualVerify {
|
|
t.Fatalf("updated purpose = %q, want %q", updatedNode.Purpose, mdb.RpcNodePurposeManualVerify)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func TestAdminRpcNodes_RejectsURLTypeMismatch(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
|
"network": "ethereum",
|
|
"url": "wss://eth-mainnet.example.com",
|
|
"type": mdb.RpcNodeTypeHttp,
|
|
}, token)
|
|
t.Logf("CreateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec, 10022)
|
|
|
|
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
|
"network": "ethereum",
|
|
"url": "https://eth-mainnet.example.com",
|
|
"type": mdb.RpcNodeTypeWs,
|
|
}, token)
|
|
t.Logf("CreateRpcNode(ws with https): status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec, 10023)
|
|
|
|
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
|
"network": "ethereum",
|
|
"url": "https://eth-mainnet.example.com",
|
|
"type": mdb.RpcNodeTypeHttp,
|
|
"purpose": "invalid-purpose",
|
|
}, token)
|
|
t.Logf("CreateRpcNode(invalid purpose): status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected invalid purpose to fail, got 200: %s", rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec, 10020)
|
|
|
|
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
|
"network": "ethereum",
|
|
"url": "https://eth-mainnet.example.com",
|
|
"type": mdb.RpcNodeTypeHttp,
|
|
}, token)
|
|
resp := assertOK(t, rec)
|
|
dataObj, _ := resp["data"].(map[string]interface{})
|
|
nodeID := uint64(dataObj["id"].(float64))
|
|
nodeIDStr := fmt.Sprintf("%d", nodeID)
|
|
|
|
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
|
|
"url": "wss://eth-mainnet.example.com",
|
|
}, token)
|
|
t.Logf("UpdateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected update mismatch to fail, got 200: %s", rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec, 10022)
|
|
|
|
node, err := data.GetRpcNodeByID(nodeID)
|
|
if err != nil {
|
|
t.Fatalf("reload rpc node: %v", err)
|
|
}
|
|
if node.Url != "https://eth-mainnet.example.com" {
|
|
t.Fatalf("node url changed after rejected update: %q", node.Url)
|
|
}
|
|
}
|
|
|
|
func TestAdminPathIDParseErrorUsesParamsErrno(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doGetAdmin(e, "/admin/api/v1/api-keys/not-a-number/stats", token)
|
|
assertErrorCode(t, rec, 10009)
|
|
}
|
|
|
|
func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, wantCode int) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected HTTP 400, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal error response: %v", err)
|
|
}
|
|
if got := int(resp["status_code"].(float64)); got != wantCode {
|
|
t.Fatalf("status_code = %d, want %d; response=%v", got, wantCode, resp)
|
|
}
|
|
if got, _ := resp["message"].(string); got != constant.Errno[wantCode] {
|
|
t.Fatalf("message = %q, want %q; response=%v", got, constant.Errno[wantCode], resp)
|
|
}
|
|
}
|
|
|
|
// ─── 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())
|
|
}
|
|
}
|
|
|
|
func TestAdminOrders_CloseWaitSelectOrder(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-admin-close-wait-select",
|
|
OrderId: "order-admin-close-wait-select",
|
|
Amount: 10,
|
|
Currency: "CNY",
|
|
Status: mdb.StatusWaitSelect,
|
|
NotifyUrl: "https://merchant.example/notify",
|
|
PayProvider: mdb.PaymentProviderOnChain,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create wait-select order: %v", err)
|
|
}
|
|
|
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/close", nil, token)
|
|
t.Logf("CloseOrder wait-select: status=%d body=%s", rec.Code, rec.Body.String())
|
|
assertOK(t, rec)
|
|
|
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
|
if err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != mdb.StatusExpired {
|
|
t.Fatalf("status = %d, want %d", reloaded.Status, mdb.StatusExpired)
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
|
|
func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-admin-mark-paid-ok",
|
|
OrderId: "order-admin-mark-paid-ok",
|
|
Amount: 10,
|
|
Currency: "CNY",
|
|
ActualAmount: 1.23,
|
|
ReceiveAddress: "TTestTronAddress001",
|
|
Token: "USDT",
|
|
Network: mdb.NetworkTron,
|
|
Status: mdb.StatusWaitPay,
|
|
NotifyUrl: "https://merchant.example/notify",
|
|
PayProvider: mdb.PaymentProviderOnChain,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
|
|
verified := false
|
|
restore := service.SetManualOrderPaymentValidatorForTest(func(got *mdb.Orders, blockID string) (string, error) {
|
|
verified = true
|
|
if got.TradeId != order.TradeId {
|
|
t.Fatalf("validator trade_id = %s, want %s", got.TradeId, order.TradeId)
|
|
}
|
|
if blockID != "block-admin-ok" {
|
|
t.Fatalf("validator block id = %s, want block-admin-ok", blockID)
|
|
}
|
|
return "canonical-block-admin-ok", nil
|
|
})
|
|
defer restore()
|
|
|
|
jsonBytes, _ := json.Marshal(map[string]interface{}{
|
|
"block_transaction_id": "block-admin-ok",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", 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)
|
|
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
|
|
assertOK(t, rec)
|
|
if !verified {
|
|
t.Fatal("expected chain verifier to be called")
|
|
}
|
|
|
|
paid, err := data.GetOrderInfoByTradeId(order.TradeId)
|
|
if err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if paid.Status != mdb.StatusPaySuccess {
|
|
t.Fatalf("status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
|
|
}
|
|
if paid.BlockTransactionId != "canonical-block-admin-ok" {
|
|
t.Fatalf("block_transaction_id = %q", paid.BlockTransactionId)
|
|
}
|
|
if paid.CallBackConfirm != mdb.CallBackConfirmNo {
|
|
t.Fatalf("callback_confirm = %d, want %d", paid.CallBackConfirm, mdb.CallBackConfirmNo)
|
|
}
|
|
}
|
|
|
|
func TestAdminOrders_MarkPaidRejectsVerificationFailure(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-admin-mark-paid-bad-proof",
|
|
OrderId: "order-admin-mark-paid-bad-proof",
|
|
Amount: 10,
|
|
Currency: "CNY",
|
|
ActualAmount: 1.23,
|
|
ReceiveAddress: "TTestTronAddress001",
|
|
Token: "USDT",
|
|
Network: mdb.NetworkTron,
|
|
Status: mdb.StatusWaitPay,
|
|
NotifyUrl: "https://merchant.example/notify",
|
|
PayProvider: mdb.PaymentProviderOnChain,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
|
return "", errors.New("transaction amount mismatch")
|
|
})
|
|
defer restore()
|
|
|
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
|
"block_transaction_id": "block-admin-bad",
|
|
}, token)
|
|
t.Logf("MarkOrderPaid verifier failure: status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
|
}
|
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
|
if err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
|
t.Fatalf("order changed after failed verification: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
|
}
|
|
}
|
|
|
|
func TestAdminOrders_MarkPaidRejectsNonOnChainOrder(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-admin-mark-paid-provider",
|
|
OrderId: "order-admin-mark-paid-provider",
|
|
Amount: 10,
|
|
Currency: "CNY",
|
|
ActualAmount: 1.23,
|
|
ReceiveAddress: "TTestTronAddress001",
|
|
Token: "USDT",
|
|
Network: mdb.NetworkTron,
|
|
Status: mdb.StatusWaitPay,
|
|
NotifyUrl: "https://merchant.example/notify",
|
|
PayProvider: mdb.PaymentProviderOkPay,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
called := false
|
|
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
|
called = true
|
|
return "block-admin-provider", nil
|
|
})
|
|
defer restore()
|
|
|
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
|
"block_transaction_id": "block-admin-provider",
|
|
}, token)
|
|
t.Logf("MarkOrderPaid provider order: status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
|
}
|
|
if called {
|
|
t.Fatal("verifier should not run for non-on-chain order")
|
|
}
|
|
}
|
|
|
|
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
|
|
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
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())
|
|
}
|
|
}
|
|
|
|
func TestAdminOrders_ResendCallbackRejectsEmptyNotifyURL(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-admin-resend-empty-url",
|
|
OrderId: "order-admin-resend-empty-url",
|
|
Status: mdb.StatusPaySuccess,
|
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
|
CallbackNum: 3,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
|
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
|
t.Logf("ResendCallback empty notify_url: status=%d body=%s", rec.Code, rec.Body.String())
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
|
}
|
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
|
if err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.CallBackConfirm != mdb.CallBackConfirmOk || reloaded.CallbackNum != 3 {
|
|
t.Fatalf("callback state changed: confirm=%d num=%d", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
|
}
|
|
}
|
|
|
|
func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-admin-resend-ok",
|
|
OrderId: "order-admin-resend-ok",
|
|
Status: mdb.StatusPaySuccess,
|
|
NotifyUrl: "https://merchant.example/notify",
|
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
|
CallbackNum: 3,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
|
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
|
t.Logf("ResendCallback success: status=%d body=%s", rec.Code, rec.Body.String())
|
|
assertOK(t, rec)
|
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
|
if err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.CallBackConfirm != mdb.CallBackConfirmNo || reloaded.CallbackNum != 0 {
|
|
t.Fatalf("callback state = confirm %d num %d, want no/0", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
|
}
|
|
}
|
|
|
|
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
|
|
|
// TestAdminDashboard_AllRoutes verifies ordinary JSON 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)
|
|
}
|
|
}
|
|
|
|
func TestAdminDashboardRpcStatsSSE(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
data.RecordRpcSuccess("TRON")
|
|
data.RecordRpcSuccess("tron")
|
|
data.RecordRpcFailure(" tron ")
|
|
data.RecordRpcBlockHeight("tron", 64532100)
|
|
|
|
server := httptest.NewServer(e)
|
|
defer server.Close()
|
|
|
|
reqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, server.URL+"/admin/api/v1/dashboard/rpc-stats", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
|
req.Header.Set(echo.HeaderAccept, "text/event-stream")
|
|
|
|
resp, err := server.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("sse request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("sse status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get(echo.HeaderContentType); !strings.Contains(ct, "text/event-stream") {
|
|
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
|
}
|
|
|
|
reader := bufio.NewReader(resp.Body)
|
|
var eventName string
|
|
var dataLine string
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
t.Fatalf("read sse line: %v", err)
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
if strings.HasPrefix(line, "event: ") {
|
|
eventName = strings.TrimPrefix(line, "event: ")
|
|
}
|
|
if strings.HasPrefix(line, "data: ") {
|
|
dataLine = strings.TrimPrefix(line, "data: ")
|
|
}
|
|
if line == "" && dataLine != "" {
|
|
break
|
|
}
|
|
}
|
|
if eventName != "rpc_stats" {
|
|
t.Fatalf("event = %q, want rpc_stats", eventName)
|
|
}
|
|
|
|
var envelope map[string]interface{}
|
|
if err := json.Unmarshal([]byte(dataLine), &envelope); err != nil {
|
|
t.Fatalf("unmarshal sse data: %v data=%s", err, dataLine)
|
|
}
|
|
if got, _ := envelope["status_code"].(float64); got != 200 {
|
|
t.Fatalf("sse status_code = %v, want 200", got)
|
|
}
|
|
dataObj, ok := envelope["data"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("sse data = %#v, want object", envelope["data"])
|
|
}
|
|
rows, ok := dataObj["chains"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("sse chains = %#v, want array", dataObj["chains"])
|
|
}
|
|
|
|
summary, ok := dataObj["summary"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("summary = %#v, want object", dataObj["summary"])
|
|
}
|
|
if got, _ := summary["success_count"].(float64); got != 2 {
|
|
t.Fatalf("summary success_count = %v, want 2", got)
|
|
}
|
|
if got, _ := summary["failure_count"].(float64); got != 1 {
|
|
t.Fatalf("summary failure_count = %v, want 1", got)
|
|
}
|
|
if got, _ := summary["success_rate"].(float64); got < 0.666 || got > 0.667 {
|
|
t.Fatalf("summary success_rate = %v, want about 0.6667", got)
|
|
}
|
|
|
|
var tron map[string]interface{}
|
|
var solana map[string]interface{}
|
|
for _, row := range rows {
|
|
item, _ := row.(map[string]interface{})
|
|
switch item["network"] {
|
|
case mdb.NetworkTron:
|
|
tron = item
|
|
case mdb.NetworkSolana:
|
|
solana = item
|
|
}
|
|
}
|
|
if tron == nil {
|
|
t.Fatalf("missing tron row in %#v", rows)
|
|
}
|
|
if got, _ := tron["success_count"].(float64); got != 2 {
|
|
t.Fatalf("tron success_count = %v, want 2", got)
|
|
}
|
|
if got, _ := tron["failure_count"].(float64); got != 1 {
|
|
t.Fatalf("tron failure_count = %v, want 1", got)
|
|
}
|
|
if got, _ := tron["success_rate"].(float64); got < 0.666 || got > 0.667 {
|
|
t.Fatalf("tron success_rate = %v, want about 0.6667", got)
|
|
}
|
|
if got, _ := tron["latest_block_height"].(float64); got != 64532100 {
|
|
t.Fatalf("tron latest_block_height = %v, want 64532100", got)
|
|
}
|
|
if got, _ := tron["last_sync_at"].(string); got == "" {
|
|
t.Fatalf("tron last_sync_at is empty: %#v", tron)
|
|
}
|
|
if got, _ := tron["display_name"].(string); got != "TRON" {
|
|
t.Fatalf("tron display_name = %q, want TRON", got)
|
|
}
|
|
if got, _ := tron["status"].(string); got != "warning" {
|
|
t.Fatalf("tron status = %q, want warning", got)
|
|
}
|
|
if solana == nil {
|
|
t.Fatalf("missing solana row in %#v", rows)
|
|
}
|
|
if got, _ := solana["success_count"].(float64); got != 0 {
|
|
t.Fatalf("solana success_count = %v, want 0", got)
|
|
}
|
|
if got, _ := solana["failure_count"].(float64); got != 0 {
|
|
t.Fatalf("solana failure_count = %v, want 0", got)
|
|
}
|
|
if got, _ := solana["success_rate"].(float64); got != 0 {
|
|
t.Fatalf("solana success_rate = %v, want 0", got)
|
|
}
|
|
if got, _ := solana["last_sync_at"].(string); got != "" {
|
|
t.Fatalf("solana last_sync_at = %q, want empty", got)
|
|
}
|
|
if got, _ := solana["latest_block_height"].(float64); got != 0 {
|
|
t.Fatalf("solana latest_block_height = %v, want 0", got)
|
|
}
|
|
if got, _ := solana["status"].(string); got != "unknown" {
|
|
t.Fatalf("solana status = %q, want unknown", got)
|
|
}
|
|
}
|
|
|
|
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
yesterday := time.Now().AddDate(0, 0, -1)
|
|
createdAt := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 10, 0, 0, 0, time.Local)
|
|
updatedAt := createdAt.Add(5 * time.Minute)
|
|
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-dashboard-yesterday",
|
|
OrderId: "order-dashboard-yesterday",
|
|
Amount: 100,
|
|
Currency: "CNY",
|
|
ActualAmount: 12.5,
|
|
ReceiveAddress: "TTestDashboardAddress001",
|
|
Token: "USDT",
|
|
Network: mdb.NetworkTron,
|
|
Status: mdb.StatusPaySuccess,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create historical order: %v", err)
|
|
}
|
|
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
|
|
"created_at": createdAt,
|
|
"updated_at": updatedAt,
|
|
}).Error; err != nil {
|
|
t.Fatalf("age historical order: %v", err)
|
|
}
|
|
|
|
todayResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats", token))
|
|
todayData, _ := todayResp["data"].(map[string]interface{})
|
|
if got, _ := todayData["order_count"].(float64); got != 0 {
|
|
t.Fatalf("default order-stats order_count = %v, want 0 for today's range", got)
|
|
}
|
|
|
|
statsResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats?range=7d", token))
|
|
statsData, _ := statsResp["data"].(map[string]interface{})
|
|
if got, _ := statsData["order_count"].(float64); got != 1 {
|
|
t.Fatalf("7d order-stats order_count = %v, want 1", got)
|
|
}
|
|
if got, _ := statsData["success_count"].(float64); got != 1 {
|
|
t.Fatalf("7d order-stats success_count = %v, want 1", got)
|
|
}
|
|
|
|
overviewResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/overview?range=7d", token))
|
|
overviewData, _ := overviewResp["data"].(map[string]interface{})
|
|
if got, _ := overviewData["order_count"].(float64); got != 1 {
|
|
t.Fatalf("7d overview order_count = %v, want 1", got)
|
|
}
|
|
if got, _ := overviewData["volume"].(float64); got != 12.5 {
|
|
t.Fatalf("7d overview volume = %v, want 12.5", got)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/admin/api/v1/dashboard/asset-trend?range=7d",
|
|
"/admin/api/v1/dashboard/revenue-trend?range=7d",
|
|
"/admin/api/v1/dashboard/asset-trend?range=30d",
|
|
"/admin/api/v1/dashboard/revenue-trend?range=30d",
|
|
} {
|
|
resp := assertOK(t, doGetAdmin(e, path, token))
|
|
rows, _ := resp["data"].([]interface{})
|
|
day := createdAt.Format("2006-01-02")
|
|
found := false
|
|
for _, row := range rows {
|
|
item, _ := row.(map[string]interface{})
|
|
if item["day"] != day {
|
|
continue
|
|
}
|
|
found = true
|
|
if got, _ := item["order_count"].(float64); got != 1 {
|
|
t.Fatalf("%s order_count for %s = %v, want 1", path, day, got)
|
|
}
|
|
if got, _ := item["success_count"].(float64); got != 1 {
|
|
t.Fatalf("%s success_count for %s = %v, want 1", path, day, got)
|
|
}
|
|
if got, _ := item["actual_amount"].(float64); got != 12.5 {
|
|
t.Fatalf("%s actual_amount for %s = %v, want 12.5", path, day, got)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("%s missing historical bucket %s: %#v", path, day, rows)
|
|
}
|
|
}
|
|
|
|
addressResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/asset-trend?range=7d&group_by=address", token))
|
|
addressRows, _ := addressResp["data"].([]interface{})
|
|
day := createdAt.Format("2006-01-02")
|
|
foundAddress := false
|
|
for _, row := range addressRows {
|
|
item, _ := row.(map[string]interface{})
|
|
if item["day"] != day || item["address"] != order.ReceiveAddress {
|
|
continue
|
|
}
|
|
foundAddress = true
|
|
if got, _ := item["actual_amount"].(float64); got != 12.5 {
|
|
t.Fatalf("address trend actual_amount for %s = %v, want 12.5", day, got)
|
|
}
|
|
}
|
|
if !foundAddress {
|
|
t.Fatalf("address trend missing historical bucket %s/%s: %#v", day, order.ReceiveAddress, addressRows)
|
|
}
|
|
}
|
|
|
|
func TestAdminDashboard_TodayTrendUsesHourlyBuckets(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
now := time.Now()
|
|
createdAt := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local)
|
|
updatedAt := createdAt.Add(2 * time.Minute)
|
|
|
|
order := &mdb.Orders{
|
|
TradeId: "trade-dashboard-today",
|
|
OrderId: "order-dashboard-today",
|
|
Amount: 80,
|
|
Currency: "CNY",
|
|
ActualAmount: 9.75,
|
|
ReceiveAddress: "TTestDashboardAddressHourly",
|
|
Token: "USDT",
|
|
Network: mdb.NetworkTron,
|
|
Status: mdb.StatusPaySuccess,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("create today order: %v", err)
|
|
}
|
|
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
|
|
"created_at": createdAt,
|
|
"updated_at": updatedAt,
|
|
}).Error; err != nil {
|
|
t.Fatalf("set today order time: %v", err)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/admin/api/v1/dashboard/asset-trend?range=today",
|
|
"/admin/api/v1/dashboard/revenue-trend?range=today",
|
|
} {
|
|
resp := assertOK(t, doGetAdmin(e, path, token))
|
|
rows, _ := resp["data"].([]interface{})
|
|
hour := createdAt.Format("2006-01-02 15:00")
|
|
found := false
|
|
for _, row := range rows {
|
|
item, _ := row.(map[string]interface{})
|
|
if item["day"] != hour {
|
|
continue
|
|
}
|
|
found = true
|
|
if got, _ := item["order_count"].(float64); got != 1 {
|
|
t.Fatalf("%s order_count for %s = %v, want 1", path, hour, got)
|
|
}
|
|
if got, _ := item["success_count"].(float64); got != 1 {
|
|
t.Fatalf("%s success_count for %s = %v, want 1", path, hour, got)
|
|
}
|
|
if got, _ := item["actual_amount"].(float64); got != 9.75 {
|
|
t.Fatalf("%s actual_amount for %s = %v, want 9.75", path, hour, got)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("%s missing hourly bucket %s: %#v", path, hour, rows)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Settings ────────────────────────────────────────────────────────────────
|
|
|
|
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
|
|
func TestAdminSettings_ListAndUpsert(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-visible-in-settings", mdb.SettingTypeString); err != nil {
|
|
t.Fatalf("seed okpay.shop_token: %v", err)
|
|
}
|
|
|
|
// List settings.
|
|
rec := doGetAdmin(e, "/admin/api/v1/settings", token)
|
|
t.Logf("ListSettings: %s", rec.Body.String())
|
|
resp := assertOK(t, rec)
|
|
rows, ok := resp["data"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected settings array, got %T", resp["data"])
|
|
}
|
|
foundToken := false
|
|
for _, row := range rows {
|
|
item, _ := row.(map[string]interface{})
|
|
if item["key"] == mdb.SettingKeyOkPayShopToken {
|
|
foundToken = true
|
|
if item["value"] != "token-visible-in-settings" {
|
|
t.Fatalf("okpay.shop_token value = %v, want token-visible-in-settings", item["value"])
|
|
}
|
|
}
|
|
}
|
|
if !foundToken {
|
|
t.Fatalf("expected admin settings list to expose %s", mdb.SettingKeyOkPayShopToken)
|
|
}
|
|
|
|
// Upsert a setting.
|
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
|
"cny": map[string]interface{}{"usdt": 0.14635},
|
|
"usd": map[string]interface{}{"usdt": 1},
|
|
}, "type": "json"},
|
|
},
|
|
}, token)
|
|
t.Logf("UpsertSettings: %s", rec.Body.String())
|
|
assertOK(t, rec)
|
|
}
|
|
|
|
func TestAdminSettings_ForcedRateListValidation(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
|
" CNY ": map[string]interface{}{" USDT ": 0.14635},
|
|
}, "type": "string"},
|
|
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": `{"cny":`, "type": "json"},
|
|
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
|
"CNY": map[string]interface{}{"USDT": 0.14635},
|
|
"cny": map[string]interface{}{"usdt": 0.14636},
|
|
}, "type": "json"},
|
|
},
|
|
}, token)
|
|
resp := assertOK(t, rec)
|
|
results, ok := resp["data"].([]interface{})
|
|
if !ok || len(results) != 3 {
|
|
t.Fatalf("expected three results, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
first, _ := results[0].(map[string]interface{})
|
|
if first["ok"] != true {
|
|
t.Fatalf("valid forced rate list result = %v", first)
|
|
}
|
|
second, _ := results[1].(map[string]interface{})
|
|
if second["ok"] != false {
|
|
t.Fatalf("invalid forced rate list result = %v, want ok=false", second)
|
|
}
|
|
third, _ := results[2].(map[string]interface{})
|
|
if third["ok"] != false {
|
|
t.Fatalf("duplicate forced rate list result = %v, want ok=false", third)
|
|
}
|
|
|
|
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.14635}}` {
|
|
t.Fatalf("forced rate list value = %q", got)
|
|
}
|
|
if rate := config.GetRateForCoin("USDT", "CNY"); rate != 0.14635 {
|
|
t.Fatalf("GetRateForCoin(USDT, CNY) = %v, want 0.14635", rate)
|
|
}
|
|
var row mdb.Setting
|
|
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&row).Error; err != nil {
|
|
t.Fatalf("load forced rate list: %v", err)
|
|
}
|
|
if row.Type != mdb.SettingTypeJSON {
|
|
t.Fatalf("forced rate list type = %q, want %q", row.Type, mdb.SettingTypeJSON)
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "system", "key": mdb.SettingKeyAmountPrecision, "value": "4", "type": "int"},
|
|
},
|
|
}, token)
|
|
resp := assertOK(t, rec)
|
|
results, ok := resp["data"].([]interface{})
|
|
if !ok || len(results) != 1 {
|
|
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
result, _ := results[0].(map[string]interface{})
|
|
if result["ok"] != true {
|
|
t.Fatalf("amount precision upsert result = %v", result)
|
|
}
|
|
|
|
rec = doGetAdmin(e, "/admin/api/v1/settings?group=system", token)
|
|
resp = assertOK(t, rec)
|
|
rows, ok := resp["data"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected settings array, got %T", resp["data"])
|
|
}
|
|
found := false
|
|
for _, row := range rows {
|
|
item, _ := row.(map[string]interface{})
|
|
if item["key"] == mdb.SettingKeyAmountPrecision {
|
|
found = true
|
|
if item["value"] != "4" {
|
|
t.Fatalf("amount precision value = %v, want 4", item["value"])
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected settings list to include %s", mdb.SettingKeyAmountPrecision)
|
|
}
|
|
|
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "system", "key": mdb.SettingKeyAmountPrecision, "value": "7", "type": "int"},
|
|
{"group": "system", "key": mdb.SettingKeyAmountPrecision, "value": "abc", "type": "int"},
|
|
},
|
|
}, token)
|
|
resp = assertOK(t, rec)
|
|
results, ok = resp["data"].([]interface{})
|
|
if !ok || len(results) != 2 {
|
|
t.Fatalf("expected two results, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
for _, item := range results {
|
|
result, _ := item.(map[string]interface{})
|
|
if result["ok"] != false {
|
|
t.Fatalf("invalid amount precision result = %v, want ok=false", result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_LogLevelValidationRuntimeUpdateAndReset(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
oldLevel := appLog.CurrentLevel()
|
|
t.Cleanup(func() {
|
|
_ = appLog.SetLevel(oldLevel)
|
|
})
|
|
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
|
|
t.Fatalf("reset log level: %v", err)
|
|
}
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": " DEBUG ", "type": "string"},
|
|
},
|
|
}, token)
|
|
resp := assertOK(t, rec)
|
|
results, ok := resp["data"].([]interface{})
|
|
if !ok || len(results) != 1 {
|
|
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
result, _ := results[0].(map[string]interface{})
|
|
if result["ok"] != true {
|
|
t.Fatalf("system.log_level upsert result = %v", result)
|
|
}
|
|
if got := appLog.CurrentLevel(); got != "debug" {
|
|
t.Fatalf("runtime log level = %q, want debug", got)
|
|
}
|
|
if got := data.GetSettingString(mdb.SettingKeySystemLogLevel, ""); got != "debug" {
|
|
t.Fatalf("stored system.log_level = %q, want debug", got)
|
|
}
|
|
|
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "fatal", "type": "string"},
|
|
{"group": "rate", "key": mdb.SettingKeySystemLogLevel, "value": "info", "type": "string"},
|
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "", "type": "string"},
|
|
},
|
|
}, token)
|
|
resp = assertOK(t, rec)
|
|
results, ok = resp["data"].([]interface{})
|
|
if !ok || len(results) != 3 {
|
|
t.Fatalf("expected three results, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
for _, item := range results {
|
|
result, _ := item.(map[string]interface{})
|
|
if result["ok"] != false {
|
|
t.Fatalf("invalid system.log_level result = %v, want ok=false", result)
|
|
}
|
|
}
|
|
if got := appLog.CurrentLevel(); got != "debug" {
|
|
t.Fatalf("invalid upsert changed runtime log level to %q", got)
|
|
}
|
|
|
|
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeySystemLogLevel, token)
|
|
assertOK(t, rec)
|
|
if got := appLog.CurrentLevel(); got != mdb.SettingDefaultSystemLogLevel {
|
|
t.Fatalf("runtime log level after delete = %q, want %q", got, mdb.SettingDefaultSystemLogLevel)
|
|
}
|
|
if got := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel); got != mdb.SettingDefaultSystemLogLevel {
|
|
t.Fatalf("deleted system.log_level fallback = %q, want %q", got, mdb.SettingDefaultSystemLogLevel)
|
|
}
|
|
|
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "warn", "type": "string"},
|
|
},
|
|
}, token)
|
|
assertOK(t, rec)
|
|
if got := appLog.CurrentLevel(); got != "warn" {
|
|
t.Fatalf("runtime log level after restore = %q, want warn", got)
|
|
}
|
|
|
|
var restored mdb.Setting
|
|
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&restored).Error; err != nil {
|
|
t.Fatalf("load restored system.log_level unscoped: %v", err)
|
|
}
|
|
if restored.DeletedAt.Valid {
|
|
t.Fatalf("restored system.log_level still has deleted_at=%v", restored.DeletedAt)
|
|
}
|
|
if restored.Value != "warn" {
|
|
t.Fatalf("restored system.log_level value = %q, want warn", restored.Value)
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_RejectsPrivateRateAPIURL(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "http://127.0.0.1:8080/", "type": "string"},
|
|
},
|
|
}, token)
|
|
resp := assertOK(t, rec)
|
|
results, ok := resp["data"].([]interface{})
|
|
if !ok || len(results) != 1 {
|
|
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
result, _ := results[0].(map[string]interface{})
|
|
if result["ok"] != false {
|
|
t.Fatalf("private rate.api_url result = %v, want ok=false", result)
|
|
}
|
|
if got := int(result["error_code"].(float64)); got != 10043 {
|
|
t.Fatalf("private rate.api_url error_code = %d, want 10043; result=%v", got, result)
|
|
}
|
|
if got, _ := result["error"].(string); got != constant.Errno[10043] {
|
|
t.Fatalf("private rate.api_url error = %q, want %q", got, constant.Errno[10043])
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_AllowsPublicRateAPIURL(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate", "type": "string"},
|
|
},
|
|
}, token)
|
|
resp := assertOK(t, rec)
|
|
results, ok := resp["data"].([]interface{})
|
|
if !ok || len(results) != 1 {
|
|
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
|
}
|
|
result, _ := results[0].(map[string]interface{})
|
|
if result["ok"] != true {
|
|
t.Fatalf("public rate.api_url result = %v, want ok=true", result)
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-old", "type": "string"},
|
|
},
|
|
}, token)
|
|
assertOK(t, rec)
|
|
|
|
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateApiUrl, token)
|
|
assertOK(t, rec)
|
|
if got := data.GetSettingString(mdb.SettingKeyRateApiUrl, "fallback"); got != "fallback" {
|
|
t.Fatalf("deleted setting still in cache/read path: got %q", got)
|
|
}
|
|
|
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-new", "type": "string"},
|
|
},
|
|
}, token)
|
|
assertOK(t, rec)
|
|
|
|
rec = doGetAdmin(e, "/admin/api/v1/settings?group=rate", token)
|
|
resp := assertOK(t, rec)
|
|
rows, ok := resp["data"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected settings array, got %T", resp["data"])
|
|
}
|
|
found := false
|
|
for _, row := range rows {
|
|
item, _ := row.(map[string]interface{})
|
|
if item["key"] == mdb.SettingKeyRateApiUrl {
|
|
found = true
|
|
if item["value"] != "https://93.184.216.34/rate-new" {
|
|
t.Fatalf("rate.api_url value = %v, want new value", item["value"])
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected settings list to include restored %s", mdb.SettingKeyRateApiUrl)
|
|
}
|
|
|
|
var restored mdb.Setting
|
|
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateApiUrl).Take(&restored).Error; err != nil {
|
|
t.Fatalf("load restored setting unscoped: %v", err)
|
|
}
|
|
if restored.DeletedAt.Valid {
|
|
t.Fatalf("restored setting still has deleted_at=%v", restored.DeletedAt)
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_DeleteThenReupsertRestoresForcedRateList(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
|
"CNY": map[string]interface{}{"USDT": 0.14635},
|
|
}, "type": "json"},
|
|
},
|
|
}, token)
|
|
assertOK(t, rec)
|
|
|
|
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateForcedRateList, token)
|
|
assertOK(t, rec)
|
|
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, "fallback"); got != "fallback" {
|
|
t.Fatalf("deleted forced rate list still in cache/read path: got %q", got)
|
|
}
|
|
if got := config.GetRateForCoin("USDT", "CNY"); got != 0 {
|
|
t.Fatalf("deleted forced rate list still used by rate lookup: got %v", got)
|
|
}
|
|
|
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
|
|
"CNY": map[string]interface{}{"USDT": 0.15},
|
|
}, "type": "json"},
|
|
},
|
|
}, token)
|
|
assertOK(t, rec)
|
|
|
|
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.15}}` {
|
|
t.Fatalf("restored forced rate list = %q", got)
|
|
}
|
|
if got := config.GetRateForCoin("USDT", "CNY"); got != 0.15 {
|
|
t.Fatalf("restored forced rate list lookup = %v, want 0.15", got)
|
|
}
|
|
|
|
var restored mdb.Setting
|
|
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&restored).Error; err != nil {
|
|
t.Fatalf("load restored forced rate list unscoped: %v", err)
|
|
}
|
|
if restored.DeletedAt.Valid {
|
|
t.Fatalf("restored forced rate list still has deleted_at=%v", restored.DeletedAt)
|
|
}
|
|
}
|
|
|
|
func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil {
|
|
t.Fatalf("seed brand.checkout_name: %v", err)
|
|
}
|
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
|
|
t.Fatalf("seed okpay.enabled: %v", err)
|
|
}
|
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-admin-1", mdb.SettingTypeString); err != nil {
|
|
t.Fatalf("seed okpay.shop_id: %v", err)
|
|
}
|
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-admin-1", mdb.SettingTypeString); err != nil {
|
|
t.Fatalf("seed okpay.shop_token: %v", err)
|
|
}
|
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/notify", mdb.SettingTypeString); err != nil {
|
|
t.Fatalf("seed okpay.callback_url: %v", err)
|
|
}
|
|
|
|
rec := doGetAdmin(e, "/admin/api/v1/config", token)
|
|
resp := assertOK(t, rec)
|
|
dataObj, _ := resp["data"].(map[string]interface{})
|
|
site, ok := dataObj["site"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected site object, got %T", dataObj["site"])
|
|
}
|
|
if site["cashier_name"] != "admin cashier" {
|
|
t.Fatalf("cashier_name = %v, want admin cashier", site["cashier_name"])
|
|
}
|
|
okpay, ok := dataObj["okpay"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected okpay object, got %T", dataObj["okpay"])
|
|
}
|
|
if okpay["shop_id"] != "shop-admin-1" {
|
|
t.Fatalf("shop_id = %v, want shop-admin-1", okpay["shop_id"])
|
|
}
|
|
if okpay["shop_token"] != "token-admin-1" {
|
|
t.Fatalf("shop_token = %v, want token-admin-1", okpay["shop_token"])
|
|
}
|
|
if okpay["callback_url"] != "https://example.com/notify" {
|
|
t.Fatalf("callback_url = %v", okpay["callback_url"])
|
|
}
|
|
}
|
|
|
|
func TestAdminSettings_UpsertBrandReflectsInConfig(t *testing.T) {
|
|
e, token := setupAdminTestEnv(t)
|
|
|
|
updateBrand := func(cashier, logo, title, support, bgColor, bgImage string) {
|
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
|
"items": []map[string]interface{}{
|
|
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandCheckoutName, "value": cashier, "type": mdb.SettingTypeString},
|
|
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandLogoUrl, "value": logo, "type": mdb.SettingTypeString},
|
|
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandSiteTitle, "value": title, "type": mdb.SettingTypeString},
|
|
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandSupportUrl, "value": support, "type": mdb.SettingTypeString},
|
|
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandBackgroundColor, "value": bgColor, "type": mdb.SettingTypeString},
|
|
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandBackgroundImageUrl, "value": bgImage, "type": mdb.SettingTypeString},
|
|
},
|
|
}, token)
|
|
assertOK(t, rec)
|
|
}
|
|
|
|
assertSite := func(path, wantCashier, wantLogo, wantTitle, wantSupport, wantBgColor, wantBgImage string) {
|
|
rec := doGet(e, path)
|
|
if strings.HasPrefix(path, "/admin/") {
|
|
rec = doGetAdmin(e, path, token)
|
|
}
|
|
resp := assertOK(t, rec)
|
|
dataObj, _ := resp["data"].(map[string]interface{})
|
|
site, ok := dataObj["site"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("%s expected site object, got %T", path, dataObj["site"])
|
|
}
|
|
if site["cashier_name"] != wantCashier {
|
|
t.Fatalf("%s cashier_name=%v, want %s", path, site["cashier_name"], wantCashier)
|
|
}
|
|
if site["logo_url"] != wantLogo {
|
|
t.Fatalf("%s logo_url=%v, want %s", path, site["logo_url"], wantLogo)
|
|
}
|
|
if site["website_title"] != wantTitle {
|
|
t.Fatalf("%s website_title=%v, want %s", path, site["website_title"], wantTitle)
|
|
}
|
|
if site["support_link"] != wantSupport {
|
|
t.Fatalf("%s support_link=%v, want %s", path, site["support_link"], wantSupport)
|
|
}
|
|
if site["background_color"] != wantBgColor {
|
|
t.Fatalf("%s background_color=%v, want %s", path, site["background_color"], wantBgColor)
|
|
}
|
|
if site["background_image_url"] != wantBgImage {
|
|
t.Fatalf("%s background_image_url=%v, want %s", path, site["background_image_url"], wantBgImage)
|
|
}
|
|
}
|
|
|
|
updateBrand("cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1", "#111111", "https://cdn.example.com/bg-v1.png")
|
|
assertSite("/payments/gmpay/v1/config", "cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1", "#111111", "https://cdn.example.com/bg-v1.png")
|
|
assertSite("/admin/api/v1/config", "cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1", "#111111", "https://cdn.example.com/bg-v1.png")
|
|
|
|
updateBrand("cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2", "#222222", "https://cdn.example.com/bg-v2.png")
|
|
assertSite("/payments/gmpay/v1/config", "cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2", "#222222", "https://cdn.example.com/bg-v2.png")
|
|
assertSite("/admin/api/v1/config", "cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2", "#222222", "https://cdn.example.com/bg-v2.png")
|
|
}
|
|
|
|
// ─── 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://93.184.216.34/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)
|
|
}
|
|
}
|