blob: 119f38f2d6362ba036d8183f4374c0589a77650a [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Tools manager tests
"""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from doris_mcp_server.schema_validation import ToolSchemaGuard
from doris_mcp_server.tools.domain_dispatcher import ToolNotFoundError
from doris_mcp_server.tools.doris_feature_matrix import (
EXPECTED_DOMAIN_CHILDREN,
)
from doris_mcp_server.tools.tools_manager import DorisToolsManager
from doris_mcp_server.utils.config import DorisConfig
from doris_mcp_server.utils.query_runtime import QueryRuntimeFailure
class TestDorisToolsManager:
"""Doris tools manager tests"""
@pytest.fixture
def mock_config(self):
"""Create mock configuration"""
from doris_mcp_server.utils.config import DatabaseConfig, SecurityConfig
config = Mock(spec=DorisConfig)
# Add database config
config.database = Mock(spec=DatabaseConfig)
config.database.host = "localhost"
config.database.port = 9030
config.database.user = "test_user"
config.database.password = "test_password"
config.database.database = "test_db"
config.database.health_check_interval = 60
config.database.max_connections = 20
config.database.connection_timeout = 30
config.database.max_connection_age = 3600
# Add security config
config.security = Mock(spec=SecurityConfig)
config.security.enable_masking = True
config.security.auth_type = "token"
config.security.token_secret = "test_secret"
config.security.token_expiry = 3600
return config
@pytest.fixture
def tools_manager(self, mock_config):
"""Create tools manager instance"""
# Create a proper mock connection manager
mock_connection_manager = Mock()
mock_connection_manager.get_connection = AsyncMock()
mock_connection_manager.config.adbc.default_max_rows = 1000
mock_connection_manager.config.adbc.default_timeout = 30
mock_connection_manager.config.adbc.default_return_format = "dict"
return DorisToolsManager(mock_connection_manager)
@pytest.mark.asyncio
async def test_get_available_tools(self, tools_manager):
"""Test getting available tools"""
tools = await tools_manager.list_tools()
tool_names = [tool.name for tool in tools]
assert tool_names == list(EXPECTED_DOMAIN_CHILDREN)
@pytest.mark.asyncio
async def test_exec_query_tool(self, tools_manager):
"""Test exec_query tool"""
with patch.object(
tools_manager.query_runtime,
"execute_query",
) as mock_execute:
mock_execute.return_value = {
"status": "success",
"data": {
"columns": [{"name": "id"}, {"name": "name"}],
"rows": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
"row_count": 2,
"truncated": False,
},
"warnings": [],
"metadata": {},
}
arguments = {
"sql": "SELECT id, name FROM users LIMIT 2",
"max_rows": 100
}
result = await tools_manager._exec_query_tool(arguments)
assert result["status"] == "success"
assert len(result["data"]["rows"]) == 2
mock_execute.assert_awaited_once_with(
sql=arguments["sql"],
catalog=None,
database=None,
parameters=None,
max_rows=100,
timeout_ms=None,
)
@pytest.mark.asyncio
async def test_exec_query_with_error(self, tools_manager):
"""Test exec_query tool with error"""
with patch.object(
tools_manager.query_runtime,
"execute_query",
) as mock_execute:
mock_execute.side_effect = QueryRuntimeFailure(
"Doris query execution is temporarily unavailable.",
reason_code="QUERY_BACKEND_UNAVAILABLE",
status_code=503,
retryable=True,
)
arguments = {
"sql": "SELECT * FROM users"
}
with pytest.raises(
QueryRuntimeFailure,
match="temporarily unavailable",
):
await tools_manager._exec_query_tool(arguments)
@pytest.mark.asyncio
async def test_get_db_list_tool(self, tools_manager):
"""Test get_db_list tool"""
expected = {
"success": True,
"databases": ["test_db", "information_schema", "mysql"],
}
with patch.object(
tools_manager.metadata_extractor,
"get_db_list_for_mcp",
return_value=expected,
) as mock_execute:
result = await tools_manager._get_db_list_tool({})
assert result == expected
mock_execute.assert_awaited_once_with(None)
@pytest.mark.asyncio
async def test_get_db_table_list_tool(self, tools_manager):
"""Test get_db_table_list tool"""
expected = {"success": True, "tables": ["users", "orders", "products"]}
with patch.object(
tools_manager.metadata_extractor,
"get_db_table_list_for_mcp",
return_value=expected,
) as mock_execute:
arguments = {"db_name": "test_db"}
result = await tools_manager._get_db_table_list_tool(arguments)
assert result == expected
mock_execute.assert_awaited_once_with("test_db", None)
@pytest.mark.asyncio
async def test_get_table_schema_tool(self, tools_manager):
"""Test get_table_schema tool"""
expected = {
"success": True,
"schema": [
{
"Field": "id",
"Type": "int(11)",
"Null": "NO",
"Key": "PRI",
"Default": None,
"Extra": "auto_increment"
},
{
"Field": "name",
"Type": "varchar(100)",
"Null": "YES",
"Key": "",
"Default": None,
"Extra": ""
}
],
}
with patch.object(
tools_manager.metadata_extractor,
"get_table_schema_for_mcp",
return_value=expected,
) as mock_execute:
arguments = {"table_name": "users"}
result = await tools_manager._get_table_schema_tool(arguments)
assert result == expected
mock_execute.assert_awaited_once_with("users", None, None)
@pytest.mark.asyncio
async def test_get_catalog_list_tool(self, tools_manager):
"""Test get_catalog_list tool"""
expected = {
"success": True,
"catalogs": ["internal", "hive_catalog", "iceberg_catalog"],
}
with patch.object(
tools_manager.metadata_extractor,
"get_catalog_list_for_mcp",
return_value=expected,
) as mock_execute:
result = await tools_manager._get_catalog_list_tool(
{"random_string": "test_123"}
)
assert result == expected
mock_execute.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_formal_catalog_discovery_routes_to_strict_reader(
self,
tools_manager,
):
expected = {"success": True, "result": [{"name": "orders"}]}
tools_manager.catalog_metadata.list_catalogs = AsyncMock(
return_value=expected
)
tools_manager.catalog_metadata.list_databases = AsyncMock(
return_value=expected
)
tools_manager.catalog_metadata.list_tables = AsyncMock(
return_value=expected
)
catalogs = await tools_manager._get_catalog_list_tool(
{"_formal_catalog": True}
)
databases = await tools_manager._get_db_list_tool(
{"catalog_name": "lake", "_formal_catalog": True}
)
tables = await tools_manager._get_db_table_list_tool(
{
"catalog_name": "lake",
"db_name": "analytics",
"_formal_catalog": True,
}
)
assert catalogs == databases == tables == expected
tools_manager.catalog_metadata.list_catalogs.assert_awaited_once_with()
tools_manager.catalog_metadata.list_databases.assert_awaited_once_with(
catalog_name="lake"
)
tools_manager.catalog_metadata.list_tables.assert_awaited_once_with(
database_name="analytics",
catalog_name="lake",
)
@pytest.mark.asyncio
async def test_formal_table_context_routes_all_five_section_handlers(
self,
tools_manager,
):
expected = {"success": True, "result": {}}
tools_manager.catalog_metadata.get_table_schema = AsyncMock(
return_value=expected
)
tools_manager.catalog_metadata.get_table_comment = AsyncMock(
return_value=expected
)
tools_manager.catalog_metadata.get_column_comments = AsyncMock(
return_value=expected
)
tools_manager.catalog_metadata.get_table_indexes = AsyncMock(
return_value=expected
)
tools_manager.catalog_metadata.get_table_basic = AsyncMock(
return_value=expected
)
arguments = {
"catalog_name": "internal",
"db_name": "analytics",
"table_name": "orders",
"_formal_catalog": True,
}
results = [
await tools_manager._get_table_schema_tool(dict(arguments)),
await tools_manager._get_table_comment_tool(dict(arguments)),
await tools_manager._get_table_column_comments_tool(
dict(arguments)
),
await tools_manager._get_table_indexes_tool(dict(arguments)),
await tools_manager._get_table_basic_info_tool(dict(arguments)),
]
assert results == [expected] * 5
for method in (
tools_manager.catalog_metadata.get_table_schema,
tools_manager.catalog_metadata.get_table_comment,
tools_manager.catalog_metadata.get_column_comments,
tools_manager.catalog_metadata.get_table_indexes,
tools_manager.catalog_metadata.get_table_basic,
):
method.assert_awaited_once_with(
table_name="orders",
database_name="analytics",
catalog_name="internal",
)
@pytest.mark.asyncio
async def test_formal_table_size_preserves_partition_request(
self,
tools_manager,
):
expected = {"success": True, "result": {"row_count": 1}}
tools_manager.catalog_metadata.get_table_size = AsyncMock(
return_value=expected
)
result = await tools_manager._get_table_data_size_tool(
{
"catalog_name": "internal",
"db_name": "analytics",
"table_name": "orders",
"include_partitions": True,
"_formal_catalog": True,
}
)
assert result == expected
tools_manager.catalog_metadata.get_table_size.assert_awaited_once_with(
table_name="orders",
database_name="analytics",
catalog_name="internal",
include_partitions=True,
)
@pytest.mark.asyncio
async def test_invalid_tool_name(self, tools_manager):
"""Test calling invalid tool"""
with pytest.raises(ToolNotFoundError, match="Tool not found") as exc_info:
await tools_manager.call_tool("invalid_tool", {})
assert exc_info.value.name == "invalid_tool"
@pytest.mark.asyncio
async def test_missing_required_arguments(self, tools_manager):
"""Test handler rejects missing required arguments."""
with pytest.raises(ValueError, match="sql"):
await tools_manager._exec_query_tool({})
@pytest.mark.asyncio
async def test_tool_definitions_structure(self, tools_manager):
"""Test tool definitions have correct structure"""
tools = await tools_manager.list_tools()
for tool in tools:
# Each tool should have required fields
assert hasattr(tool, 'name')
assert hasattr(tool, 'description')
assert hasattr(tool, 'input_schema')
# Input schema should have properties
assert 'properties' in tool.input_schema
# Required fields should be defined
if 'required' in tool.input_schema:
assert isinstance(tool.input_schema['required'], list)
@pytest.mark.asyncio
async def test_all_production_tool_schemas_compile_as_bounded_2020_12(
self,
tools_manager,
):
tools = await tools_manager.list_tools()
compiled = ToolSchemaGuard().compile_catalog(tools)
assert set(compiled) == {tool.name for tool in tools}