Skip to main content
Glama
tools_test.go47.6 kB
package tools import ( "context" "encoding/json" "errors" "strconv" "testing" "time" "github.com/luno/luno-go" "github.com/luno/luno-go/decimal" "github.com/luno/luno-mcp/internal/config" "github.com/luno/luno-mcp/sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) // NewFromString is a test helper that creates a decimal from a string, failing the test on error. func NewFromString(t *testing.T, s string) decimal.Decimal { t.Helper() d, err := decimal.NewFromString(s) if err != nil { t.Fatalf("NewFromString(%q) failed: %v", s, err) } return d } // NewFromFloat64 is a test helper that creates a decimal from a float64, failing the test on error. func NewFromFloat64(t *testing.T, f float64) decimal.Decimal { t.Helper() d := decimal.NewFromFloat64(f, 8) return d } const ( apiErrorStr = "API error" missingPairParameterStr = "missing pair parameter" gettingPairFromRequestStr = "getting pair from request" invalidPairStr = "invalid pair" testTimestamp = 1640995200000 // January 1, 2022 00:00:00 UTC ) func TestNormalizeCurrencyPair(t *testing.T) { testCases := []struct { name string input string expected string }{ {"Simple BTC to XBT", "BTC", "XBT"}, {"BTC in pair", "BTCGBP", "XBTGBP"}, {"BTC with hyphen separator", "BTC-GBP", "XBTGBP"}, {"BTC with slash separator", "BTC/GBP", "XBTGBP"}, {"BTC with underscore separator", "BTC_GBP", "XBTGBP"}, {"Lowercase input", "btcgbp", "XBTGBP"}, {"Mixed case input", "xbTGbP", "XBTGBP"}, {"Non-BTC pair", "ETHZAR", "ETHZAR"}, {"Non-BTC pair with separator", "ETH-ZAR", "ETHZAR"}, {"BITCOIN text conversion", "BITCOIN", "XBT"}, {"BITCOIN in pair", "BITCOINUSD", "XBTUSD"}, {"Multiple separators", "BTC-_/GBP", "XBTGBP"}, {"Combo of mappings", "BITCOIN/GBP", "XBTGBP"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := normalizeCurrencyPair(tc.input) if result != tc.expected { t.Errorf("normalizeCurrencyPair(%q) = %q, want %q", tc.input, result, tc.expected) } }) } } func TestToolCreation(t *testing.T) { tests := []struct { name string toolFunc func() mcp.Tool toolName string params []string }{ { name: "GetBalances tool", toolFunc: NewGetBalancesTool, toolName: GetBalancesToolID, params: []string{}, }, { name: "GetTicker tool", toolFunc: NewGetTickerTool, toolName: GetTickerToolID, params: []string{"pair"}, }, { name: "GetOrderBook tool", toolFunc: NewGetOrderBookTool, toolName: GetOrderBookToolID, params: []string{"pair"}, }, { name: "CreateOrder tool", toolFunc: NewCreateOrderTool, toolName: CreateOrderToolID, params: []string{"pair", "type", "volume", "price"}, }, { name: "CancelOrder tool", toolFunc: NewCancelOrderTool, toolName: CancelOrderToolID, params: []string{"order_id"}, }, { name: "ListOrders tool", toolFunc: NewListOrdersTool, toolName: ListOrdersToolID, params: []string{"pair", "limit"}, }, { name: "ListTransactions tool", toolFunc: NewListTransactionsTool, toolName: ListTransactionsToolID, params: []string{"account_id", "min_row", "max_row"}, }, { name: "GetTransaction tool", toolFunc: NewGetTransactionTool, toolName: GetTransactionToolID, params: []string{"account_id", "transaction_id"}, }, { name: "ListTrades tool", toolFunc: NewListTradesTool, toolName: ListTradesToolID, params: []string{"pair", "since"}, }, { name: "GetTickers tool", toolFunc: NewGetTickersTool, toolName: GetTickersToolID, params: []string{"pair"}, }, { name: "GetCandles tool", toolFunc: NewGetCandlesTool, toolName: GetCandlesToolID, params: []string{"pair", "since", "duration"}, }, { name: "GetMarketsInfo tool", toolFunc: NewGetMarketsInfoTool, toolName: GetMarketsInfoToolID, params: []string{"pair"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tool := tt.toolFunc() if tool.Name != tt.toolName { t.Errorf("Expected tool name %q, got %q", tt.toolName, tool.Name) } if tool.Description == "" { t.Error("Tool description should not be empty") } // Verify tool has proper schema structure if tool.InputSchema.Type == "" { t.Error("Tool should have an input schema type") return } // Verify expected parameters exist if tool.InputSchema.Properties == nil { if len(tt.params) > 0 { t.Error("Tool should have properties for parameters") } return } for _, param := range tt.params { if _, exists := tool.InputSchema.Properties[param]; !exists { t.Errorf("Expected parameter %q to exist", param) } } }) } } // Helper function to extract text content from mcp.CallToolResult func getTextContentFromResult(t *testing.T, result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { t.Fatal("result or content is nil or empty") } // Extract text content from the first content item. // This assumes single text content, which is the current pattern. if len(result.Content) > 1 { t.Fatalf("expected only one content item, got multiple") } if textContent, ok := result.Content[0].(mcp.TextContent); ok { return textContent.Text } t.Fatalf("expected mcp.TextContent but got %T", result.Content[0]) return "" // Should not be reached } func TestHandleGetBalances(t *testing.T) { tests := []struct { name string mockSetup func(*testing.T, *sdk.MockLunoClient) isAuthenticated bool expectedError bool errorContains string }{ { name: "successful get balances", mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { balance1 := NewFromString(t, "1.5") reserved1 := NewFromString(t, "0.1") unconfirmed1 := NewFromString(t, "0.0") balance2 := NewFromString(t, "10000.0") reserved2 := NewFromString(t, "0.0") unconfirmed2 := NewFromString(t, "0.0") mockResponse := &luno.GetBalancesResponse{ Balance: []luno.AccountBalance{ { AccountId: "123456", Asset: "XBT", Balance: balance1, Reserved: reserved1, Unconfirmed: unconfirmed1, Name: "XBT Account", }, { AccountId: "789012", Asset: "ZAR", Balance: balance2, Reserved: reserved2, Unconfirmed: unconfirmed2, Name: "ZAR Account", }, }, } mockClient.EXPECT().GetBalances(context.Background(), &luno.GetBalancesRequest{}). Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "GetBalances API error", mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetBalances(context.Background(), &luno.GetBalancesRequest{}). Return(nil, errors.New(apiErrorStr)) }, isAuthenticated: true, expectedError: true, errorContains: "Failed to get balances", }, { name: "unauthenticated get balances", mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, isAuthenticated: false, expectedError: true, errorContains: ErrAPICredentialsRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: tt.isAuthenticated, } handler := HandleGetBalances(cfg) request := createMockRequest(nil) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorText := getTextContentFromResult(t, result) assert.Contains(t, errorText, tt.errorContains) } } else { textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var balances []map[string]any err := json.Unmarshal([]byte(textContent), &balances) assert.NoError(t, err) assert.Len(t, balances, 2, "Should have 2 balances") } }) } } func TestHandleGetTicker(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) expectedError bool errorContains string }{ { name: "successful get ticker", requestParams: map[string]any{ "pair": "XBTZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.GetTickerResponse{ Pair: "XBTZAR", Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Bid: decimal.NewFromInt64(800000), Ask: decimal.NewFromInt64(800100), LastTrade: decimal.NewFromInt64(800050), Rolling24HourVolume: decimal.NewFromFloat64(100.5, -1), Status: "ACTIVE", } mockClient.EXPECT().GetTicker(context.Background(), &luno.GetTickerRequest{Pair: "XBTZAR"}). Return(mockResponse, nil) }, expectedError: false, }, { name: "BTC to XBT normalization", requestParams: map[string]any{ "pair": "BTCZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.GetTickerResponse{ Pair: "XBTZAR", Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Bid: decimal.NewFromFloat64(800000, -1), Ask: decimal.NewFromFloat64(800100, -1), LastTrade: decimal.NewFromFloat64(800050, -1), Rolling24HourVolume: decimal.NewFromFloat64(100.5, -1), Status: "ACTIVE", } mockClient.EXPECT().GetTicker(context.Background(), &luno.GetTickerRequest{Pair: "XBTZAR"}). Return(mockResponse, nil) }, expectedError: false, }, { name: "missing pair for getTicker", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed for this case */ }, expectedError: true, errorContains: gettingPairFromRequestStr, }, { name: "GetTicker API error", requestParams: map[string]any{ "pair": "INVALID", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetTicker(context.Background(), &luno.GetTickerRequest{Pair: "INVALID"}). Return(nil, errors.New(invalidPairStr)) }, expectedError: true, errorContains: "getting ticker", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: true, // Public endpoint, but setting to true for consistency } handler := HandleGetTicker(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorText := getTextContentFromResult(t, result) assert.Contains(t, errorText, tt.errorContains) } } else { textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var ticker map[string]any err := json.Unmarshal([]byte(textContent), &ticker) assert.NoError(t, err) assert.Equal(t, "XBTZAR", ticker["pair"]) } }) } } func TestHandleGetOrderBook(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) expectedError bool errorContains string }{ { name: "successful get order book", requestParams: map[string]any{ "pair": "XBTZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.GetOrderBookResponse{ Timestamp: testTimestamp, Bids: []luno.OrderBookEntry{ {Price: decimal.NewFromInt64(800000), Volume: decimal.NewFromFloat64(0.5, -1)}, {Price: decimal.NewFromInt64(799900), Volume: decimal.NewFromFloat64(1.0, -1)}, }, Asks: []luno.OrderBookEntry{ {Price: decimal.NewFromInt64(800100), Volume: decimal.NewFromFloat64(0.8, -1)}, {Price: decimal.NewFromInt64(800200), Volume: decimal.NewFromFloat64(1.2, -1)}, }, } mockClient.EXPECT().GetOrderBook(context.Background(), &luno.GetOrderBookRequest{Pair: "XBTZAR"}). Return(mockResponse, nil) }, expectedError: false, }, { name: "missing pair for GetOrderBook", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed for this case */ }, expectedError: true, errorContains: gettingPairFromRequestStr, }, { name: "GetOrderBook API error", requestParams: map[string]any{ "pair": "INVALID", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetOrderBook(context.Background(), &luno.GetOrderBookRequest{Pair: "INVALID"}). Return(nil, errors.New(invalidPairStr)) }, expectedError: true, errorContains: "getting order book", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: true, // Public endpoint } handler := HandleGetOrderBook(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var orderBook map[string]any err := json.Unmarshal([]byte(textContent), &orderBook) assert.NoError(t, err) assert.Contains(t, orderBook, "bids") assert.Contains(t, orderBook, "asks") } }) } } func TestHandleCancelOrder(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) isAuthenticated bool expectedError bool errorContains string }{ { name: "successful cancel order", requestParams: map[string]any{ "order_id": "12345", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.StopOrderResponse{ Success: true, } mockClient.EXPECT().StopOrder(context.Background(), &luno.StopOrderRequest{OrderId: "12345"}). Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "missing order_id parameter", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed for this case */ }, isAuthenticated: true, expectedError: true, errorContains: "getting order_id from request", }, { name: "CancelOrder API error", requestParams: map[string]any{ "order_id": "invalid_id", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().StopOrder(context.Background(), &luno.StopOrderRequest{OrderId: "invalid_id"}). Return(nil, errors.New("Order not found")) }, isAuthenticated: true, expectedError: true, errorContains: "Failed to cancel order", }, { name: "unauthenticated cancel order", requestParams: map[string]any{"order_id": "12345"}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, isAuthenticated: false, expectedError: true, errorContains: ErrAPICredentialsRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: tt.isAuthenticated, } handler := HandleCancelOrder(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) } }) } } func TestHandleListOrders(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) isAuthenticated bool expectedError bool errorContains string }{ { name: "successful list orders with pair", requestParams: map[string]any{ "pair": "XBTZAR", "limit": float64(50), }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListOrdersResponse{ Orders: []luno.Order{ { OrderId: "12345", CreationTimestamp: luno.Time(time.UnixMilli(testTimestamp)), ExpirationTimestamp: luno.Time(time.UnixMilli(testTimestamp + 86400000)), CompletedTimestamp: luno.Time(time.UnixMilli(0)), Type: luno.OrderTypeBid, State: luno.OrderStatePending, LimitPrice: decimal.NewFromInt64(800000), LimitVolume: decimal.NewFromFloat64(0.001, -1), Base: decimal.NewFromFloat64(0.0, -1), Counter: decimal.NewFromFloat64(0.0, -1), FeeBase: decimal.NewFromFloat64(0.0, -1), FeeCounter: decimal.NewFromFloat64(0.0, -1), Pair: "XBTZAR", }, }, } mockClient.EXPECT().ListOrders(context.Background(), &luno.ListOrdersRequest{ Pair: "XBTZAR", Limit: 50, }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "successful list orders with pair normalization", requestParams: map[string]any{ "pair": "BTCZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListOrdersResponse{ Orders: []luno.Order{}, } mockClient.EXPECT().ListOrders(context.Background(), &luno.ListOrdersRequest{ Pair: "XBTZAR", Limit: 100, // Default limit }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "successful list orders without pair", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListOrdersResponse{ Orders: []luno.Order{}, } mockClient.EXPECT().ListOrders(context.Background(), &luno.ListOrdersRequest{ Pair: "", Limit: 100, // Default limit }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "ListOrders API error", requestParams: map[string]any{ "pair": "INVALID", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().ListOrders(context.Background(), &luno.ListOrdersRequest{ Pair: "INVALID", Limit: 100, // Default limit }).Return(nil, errors.New(invalidPairStr)) }, isAuthenticated: true, expectedError: true, errorContains: "Failed to list orders", }, { name: "unauthenticated list orders", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, isAuthenticated: false, expectedError: true, errorContains: ErrAPICredentialsRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: tt.isAuthenticated, } handler := HandleListOrders(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var ordersResponse map[string]any err := json.Unmarshal([]byte(textContent), &ordersResponse) assert.NoError(t, err) assert.Contains(t, ordersResponse, "orders") } }) } } func TestHandleListTransactions(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) isAuthenticated bool expectedError bool errorContains string }{ { name: "successful list transactions", requestParams: map[string]any{ "account_id": "123456", "min_row": float64(1), "max_row": float64(10), }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListTransactionsResponse{ Id: "123456", Transactions: []luno.Transaction{ { RowIndex: 1, Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Balance: decimal.NewFromFloat64(1.5, -1), Available: decimal.NewFromFloat64(1.4, -1), AvailableDelta: decimal.NewFromFloat64(0.1, -1), BalanceDelta: decimal.NewFromFloat64(0.1, -1), Currency: "XBT", Description: "Test transaction", }, }, } // Convert account_id from string to int64 for the request accountIdInt, _ := strconv.ParseInt("123456", 10, 64) mockClient.EXPECT().ListTransactions(context.Background(), &luno.ListTransactionsRequest{ Id: accountIdInt, MinRow: 1, MaxRow: 10, }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "missing account_id parameter", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed for this case */ }, isAuthenticated: true, expectedError: true, errorContains: "getting account_id from request", }, { name: "invalid account_id format", requestParams: map[string]any{ "account_id": "not_a_number", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed for this case */ }, isAuthenticated: true, expectedError: true, errorContains: "Invalid account ID format", }, { name: "ListTransactions API error", requestParams: map[string]any{ "account_id": "999999", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { accountIdInt, _ := strconv.ParseInt("999999", 10, 64) mockClient.EXPECT().ListTransactions(context.Background(), &luno.ListTransactionsRequest{ Id: accountIdInt, MinRow: 1, // Default min_row MaxRow: 100, // Default max_row }).Return(nil, errors.New("Account not found")) }, isAuthenticated: true, expectedError: true, errorContains: "Failed to list transactions", }, { name: "unauthenticated list transactions", requestParams: map[string]any{"account_id": "123456"}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, isAuthenticated: false, expectedError: true, errorContains: ErrAPICredentialsRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: tt.isAuthenticated, } handler := HandleListTransactions(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var transactionsResponse map[string]any err := json.Unmarshal([]byte(textContent), &transactionsResponse) assert.NoError(t, err) assert.Contains(t, transactionsResponse, "transactions") } }) } } func TestHandleGetTransaction(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) isAuthenticated bool expectedError bool errorContains string }{ { name: "successful get transaction", requestParams: map[string]any{ "account_id": "123456", "transaction_id": "5", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListTransactionsResponse{ Id: "123456", Transactions: []luno.Transaction{ { RowIndex: 5, Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Balance: decimal.NewFromFloat64(1.5, -1), Available: decimal.NewFromFloat64(1.4, -1), AvailableDelta: decimal.NewFromFloat64(0.1, -1), BalanceDelta: decimal.NewFromFloat64(0.1, -1), Currency: "XBT", Description: "Target transaction", }, { RowIndex: 6, Timestamp: luno.Time(time.UnixMilli(testTimestamp + 100000)), Balance: decimal.NewFromFloat64(1.6, -1), Available: decimal.NewFromFloat64(1.5, -1), AvailableDelta: decimal.NewFromFloat64(0.1, -1), BalanceDelta: decimal.NewFromFloat64(0.1, -1), Currency: "XBT", Description: "Another transaction", }, }, } accountIdInt, _ := strconv.ParseInt("123456", 10, 64) mockClient.EXPECT().ListTransactions(context.Background(), &luno.ListTransactionsRequest{ Id: accountIdInt, MinRow: 0, // Default min_row for GetTransaction MaxRow: 1000, // Default max_row for GetTransaction }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "transaction not found", requestParams: map[string]any{ "account_id": "123456", "transaction_id": "999", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListTransactionsResponse{ Id: "123456", Transactions: []luno.Transaction{}, } accountIdInt, _ := strconv.ParseInt("123456", 10, 64) mockClient.EXPECT().ListTransactions(context.Background(), &luno.ListTransactionsRequest{ Id: accountIdInt, MinRow: 0, MaxRow: 1000, }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: true, errorContains: "Transaction not found", }, { name: "missing account_id parameter", requestParams: map[string]any{ "transaction_id": "5", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, isAuthenticated: true, expectedError: true, errorContains: "getting account_id from request", }, { name: "missing transaction_id parameter", requestParams: map[string]any{ "account_id": "123456", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, isAuthenticated: true, expectedError: true, errorContains: "getting transaction_id from request", }, { name: "invalid account_id format", requestParams: map[string]any{ "account_id": "not_a_number", "transaction_id": "5", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, isAuthenticated: true, expectedError: true, errorContains: "Invalid account ID format", }, { name: "invalid transaction_id format", requestParams: map[string]any{ "account_id": "123456", "transaction_id": "not_a_number", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, isAuthenticated: true, expectedError: true, errorContains: "Invalid transaction ID format", }, { name: "unauthenticated get transaction", requestParams: map[string]any{"account_id": "123456", "transaction_id": "5"}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, isAuthenticated: false, expectedError: true, errorContains: ErrAPICredentialsRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: tt.isAuthenticated, } handler := HandleGetTransaction(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var transaction map[string]any err := json.Unmarshal([]byte(textContent), &transaction) assert.NoError(t, err) assert.Equal(t, float64(5), transaction["row_index"]) // Ensure correct transaction is returned } }) } } func TestHandleListTrades(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) expectedError bool errorContains string }{ { name: "successful list trades without since", requestParams: map[string]any{ "pair": "XBTZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.ListTradesResponse{ Trades: []luno.PublicTrade{ { Sequence: 123456, Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Price: decimal.NewFromInt64(800000), Volume: decimal.NewFromFloat64(0.001, -1), IsBuy: true, }, }, } mockClient.EXPECT().ListTrades(context.Background(), &luno.ListTradesRequest{ Pair: "XBTZAR", }).Return(mockResponse, nil) }, expectedError: false, }, { name: "successful list trades with since", requestParams: map[string]any{ "pair": "XBTZAR", "since": strconv.FormatInt(testTimestamp, 10), }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { sinceTime := luno.Time(time.UnixMilli(testTimestamp)) mockResponse := &luno.ListTradesResponse{ Trades: []luno.PublicTrade{ { Sequence: 123457, Timestamp: luno.Time(time.UnixMilli(testTimestamp + 60000)), Price: decimal.NewFromFloat64(800100, -1), Volume: decimal.NewFromFloat64(0.002, -1), IsBuy: false, }, }, } mockClient.EXPECT().ListTrades(context.Background(), &luno.ListTradesRequest{ Pair: "XBTZAR", Since: sinceTime, }).Return(mockResponse, nil) }, expectedError: false, }, { name: missingPairParameterStr, requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, expectedError: true, errorContains: gettingPairFromRequestStr, }, { name: "invalid since format", requestParams: map[string]any{ "pair": "XBTZAR", "since": "not_a_number", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, expectedError: true, errorContains: "Invalid 'since' timestamp format", }, { name: "ListTrades API error", requestParams: map[string]any{ "pair": "INVALID", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().ListTrades(context.Background(), &luno.ListTradesRequest{ Pair: "INVALID", }).Return(nil, errors.New(invalidPairStr)) }, expectedError: true, errorContains: "listing trades", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, } handler := HandleListTrades(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) // Verify JSON structure var tradesResponse map[string]any err := json.Unmarshal([]byte(textContent), &tradesResponse) assert.NoError(t, err) assert.Contains(t, tradesResponse, "trades") } }) } } // Helper function to create mock MCP requests func createMockRequest(params map[string]any) mcp.CallToolRequest { arguments := make(map[string]any) for k, v := range params { arguments[k] = v } return mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "test_tool", Arguments: arguments, }, } } func TestHandleCreateOrder(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) isAuthenticated bool expectedError bool errorContains string }{ { name: "successful create order", requestParams: map[string]any{ "pair": "XBTZAR", "type": "BUY", "volume": "0.01", "price": "1000000", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { vol := NewFromString(t, "0.01") price := NewFromString(t, "1000000") // Mock GetTicker call from GetMarketInfo mockTickerResponse := &luno.GetTickerResponse{ Pair: "XBTZAR", Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Bid: decimal.NewFromInt64(800000), Ask: decimal.NewFromInt64(800100), LastTrade: decimal.NewFromInt64(800050), Rolling24HourVolume: decimal.NewFromFloat64(100.5, -1), Status: "ACTIVE", } mockClient.EXPECT().GetTicker(context.Background(), &luno.GetTickerRequest{Pair: "XBTZAR"}). Return(mockTickerResponse, nil) // Mock GetOrderBook call from GetMarketInfo mockOrderBookResponse := &luno.GetOrderBookResponse{ Timestamp: testTimestamp, Bids: []luno.OrderBookEntry{ {Price: decimal.NewFromInt64(800000), Volume: decimal.NewFromFloat64(0.5, -1)}, }, Asks: []luno.OrderBookEntry{ {Price: decimal.NewFromInt64(800100), Volume: decimal.NewFromFloat64(0.8, -1)}, }, } mockClient.EXPECT().GetOrderBook(context.Background(), &luno.GetOrderBookRequest{Pair: "XBTZAR"}). Return(mockOrderBookResponse, nil) // Mock PostLimitOrder call mockResponse := &luno.PostLimitOrderResponse{ OrderId: "BXMC2SEAS4KF5S2", } mockClient.EXPECT().PostLimitOrder(context.Background(), &luno.PostLimitOrderRequest{ Pair: "XBTZAR", Type: luno.OrderTypeBid, Volume: vol, Price: price, }).Return(mockResponse, nil) }, isAuthenticated: true, expectedError: false, }, { name: "CreateOrder PostLimitOrder API error", requestParams: map[string]any{ "pair": "XBTZAR", "type": "BUY", "volume": "0.01", "price": "1000000", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { vol := NewFromString(t, "0.01") price := NewFromString(t, "1000000") // Mock GetTicker call from GetMarketInfo mockTickerResponse := &luno.GetTickerResponse{ Pair: "XBTZAR", Timestamp: luno.Time(time.UnixMilli(testTimestamp)), Bid: decimal.NewFromInt64(800000), Ask: decimal.NewFromInt64(800100), LastTrade: decimal.NewFromInt64(800050), Rolling24HourVolume: decimal.NewFromFloat64(100.5, -1), Status: "ACTIVE", } mockClient.EXPECT().GetTicker(context.Background(), &luno.GetTickerRequest{Pair: "XBTZAR"}). Return(mockTickerResponse, nil) // Mock GetOrderBook call from GetMarketInfo mockOrderBookResponse := &luno.GetOrderBookResponse{ Timestamp: testTimestamp, Bids: []luno.OrderBookEntry{ {Price: decimal.NewFromInt64(800000), Volume: decimal.NewFromFloat64(0.5, -1)}, }, Asks: []luno.OrderBookEntry{ {Price: decimal.NewFromInt64(800100), Volume: decimal.NewFromFloat64(0.8, -1)}, }, } mockClient.EXPECT().GetOrderBook(context.Background(), &luno.GetOrderBookRequest{Pair: "XBTZAR"}). Return(mockOrderBookResponse, nil) // Mock PostLimitOrder call that returns error mockClient.EXPECT().PostLimitOrder(context.Background(), &luno.PostLimitOrderRequest{ Pair: "XBTZAR", Type: luno.OrderTypeBid, Volume: vol, Price: price, }).Return(nil, errors.New(apiErrorStr)) }, isAuthenticated: true, expectedError: true, errorContains: "Failed to create limit order", }, { name: "CreateOrder GetTicker API error", requestParams: map[string]any{ "pair": "XBTZAR", "type": "BUY", "volume": "0.01", "price": "1000000", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetTicker(mock.Anything, mock.Anything).Return(nil, errors.New("API error")) }, isAuthenticated: true, expectedError: true, errorContains: "Unable to create order: Failed to retrieve market information for pair XBTZAR", }, { name: "CreateOrder GetOrderBook API error", requestParams: map[string]any{ "pair": "XBTZAR", "type": "BUY", "volume": "0.01", "price": "1000000", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetTicker(mock.Anything, mock.Anything).Return(&luno.GetTickerResponse{Pair: "XBTZAR"}, nil) mockClient.EXPECT().GetOrderBook(mock.Anything, mock.Anything).Return(nil, errors.New("API error")) }, isAuthenticated: true, expectedError: true, errorContains: "Unable to create order: Failed to retrieve market information for pair XBTZAR", }, { name: "no pair for create order", requestParams: map[string]any{ "type": "BUY", "volume": "0.01", "price": "1000000", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, isAuthenticated: true, expectedError: true, errorContains: "required argument \"pair\" not found", }, { name: "invalid volume for create order", requestParams: map[string]any{ "pair": "XBTZAR", "type": "BUY", "volume": "invalid_volume", "price": "1000000", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { /* No mock setup needed */ }, isAuthenticated: true, expectedError: true, errorContains: "Invalid volume format", }, { name: "unauthenticated create order", requestParams: map[string]any{"pair": "XBTZAR", "type": "BUY", "volume": "0.01", "price": "1000000"}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, isAuthenticated: false, expectedError: true, errorContains: ErrAPICredentialsRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{ LunoClient: mockClient, IsAuthenticated: tt.isAuthenticated, } handler := HandleCreateOrder(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) assert.Contains(t, textContent, "Order created successfully!") assert.Contains(t, textContent, "BXMC2SEAS4KF5S2") } }) } } func TestHandleGetTickers(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) expectedError bool errorContains string }{ { name: "successful get tickers with pair", requestParams: map[string]any{ "pair": "XBTZAR,ETHZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.GetTickersResponse{ Tickers: []luno.Ticker{ {Pair: "XBTZAR"}, {Pair: "ETHZAR"}, }, } mockClient.EXPECT().GetTickers(context.Background(), &luno.GetTickersRequest{Pair: []string{"XBTZAR", "ETHZAR"}}). Return(mockResponse, nil) }, expectedError: false, }, { name: "successful get tickers without pair", requestParams: map[string]any{}, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.GetTickersResponse{ Tickers: []luno.Ticker{}, } mockClient.EXPECT().GetTickers(context.Background(), &luno.GetTickersRequest{Pair: nil}). Return(mockResponse, nil) }, expectedError: false, }, { name: "GetTickers API error", requestParams: map[string]any{ "pair": "INVALID", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetTickers(context.Background(), &luno.GetTickersRequest{Pair: []string{"INVALID"}}). Return(nil, errors.New(invalidPairStr)) }, expectedError: true, errorContains: "getting tickers", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{LunoClient: mockClient} handler := HandleGetTickers(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) textContent := getTextContentFromResult(t, result) assert.NotEmpty(t, textContent) } }) } } func TestHandleGetCandles(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) expectedError bool errorContains string }{ { name: "successful get candles", requestParams: map[string]any{ "pair": "XBTZAR", "since": float64(testTimestamp), "duration": float64(3600), }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.GetCandlesResponse{ Candles: []luno.Candle{ {Timestamp: luno.Time(time.UnixMilli(testTimestamp))}, }, } mockClient.EXPECT().GetCandles(context.Background(), &luno.GetCandlesRequest{ Pair: "XBTZAR", Since: luno.Time(time.UnixMilli(testTimestamp)), Duration: 3600, }).Return(mockResponse, nil) }, expectedError: false, }, { name: "missing duration", requestParams: map[string]any{ "pair": "XBTZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) {}, expectedError: true, errorContains: "getting duration from request", }, { name: "GetCandles API error", requestParams: map[string]any{ "pair": "XBTZAR", "duration": float64(3600), }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().GetCandles(mock.Anything, mock.Anything).Return(nil, errors.New("API error")) }, expectedError: true, errorContains: "getting candles", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{LunoClient: mockClient} handler := HandleGetCandles(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) } }) } } func TestHandleGetMarketsInfo(t *testing.T) { tests := []struct { name string requestParams map[string]any mockSetup func(*testing.T, *sdk.MockLunoClient) expectedError bool errorContains string }{ { name: "successful get markets info", requestParams: map[string]any{ "pair": "XBTZAR,ETHZAR", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockResponse := &luno.MarketsResponse{ Markets: []luno.MarketInfo{ {MarketId: "XBTZAR"}, {MarketId: "ETHZAR"}, }, } mockClient.EXPECT().Markets(context.Background(), &luno.MarketsRequest{Pair: []string{"XBTZAR", "ETHZAR"}}). Return(mockResponse, nil) }, expectedError: false, }, { name: "GetMarketsInfo API error", requestParams: map[string]any{ "pair": "INVALID", }, mockSetup: func(t *testing.T, mockClient *sdk.MockLunoClient) { mockClient.EXPECT().Markets(context.Background(), &luno.MarketsRequest{Pair: []string{"INVALID"}}). Return(nil, errors.New("API error")) }, expectedError: true, errorContains: "getting markets info", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := sdk.NewMockLunoClient(t) tt.mockSetup(t, mockClient) cfg := &config.Config{LunoClient: mockClient} handler := HandleGetMarketsInfo(cfg) request := createMockRequest(tt.requestParams) result, err := handler(context.Background(), request) assert.NoError(t, err) if tt.expectedError { assert.True(t, result.IsError) if tt.errorContains != "" { errorMsg := getTextContentFromResult(t, result) assert.Contains(t, errorMsg, tt.errorContains) } } else { assert.False(t, result.IsError) } }) } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/luno/luno-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server