Skip to main content
Glama

MCP Server for Odoo

by ivnvxd
Mozilla Public License 2.0
88
  • Apple
  • Linux
test_search_resources.py•16 kB
"""Tests for search resource functionality.""" import json from unittest.mock import Mock from urllib.parse import quote import pytest from mcp.server.fastmcp import FastMCP from mcp_server_odoo.access_control import AccessControlError, AccessController from mcp_server_odoo.config import OdooConfig, load_config from mcp_server_odoo.error_handling import ( PermissionError as MCPPermissionError, ) from mcp_server_odoo.error_handling import ( ValidationError, ) from mcp_server_odoo.odoo_connection import OdooConnection, OdooConnectionError from mcp_server_odoo.resources import OdooResourceHandler @pytest.fixture def mock_config(): """Create a mock configuration.""" config = Mock(spec=OdooConfig) config.default_limit = 10 config.max_limit = 100 return config @pytest.fixture def mock_connection(): """Create a mock Odoo connection.""" conn = Mock(spec=OdooConnection) conn.is_authenticated = True return conn @pytest.fixture def mock_access_controller(): """Create a mock access controller.""" controller = Mock(spec=AccessController) return controller @pytest.fixture def mock_app(): """Create a mock FastMCP app.""" app = Mock(spec=FastMCP) app.resource = Mock() # Store registered handlers app._handlers = {} def resource_decorator(uri_pattern): def decorator(func): app._handlers[uri_pattern] = func return func return decorator app.resource.side_effect = resource_decorator return app @pytest.fixture def resource_handler(mock_app, mock_connection, mock_access_controller, mock_config): """Create a resource handler instance.""" return OdooResourceHandler(mock_app, mock_connection, mock_access_controller, mock_config) @pytest.fixture def real_config(): """Load real configuration from .env file.""" return load_config() @pytest.fixture def real_connection(real_config): """Create a real Odoo connection.""" return OdooConnection(real_config) class TestSearchResource: """Test search resource functionality.""" @pytest.mark.asyncio async def test_search_basic(self, resource_handler, mock_connection, mock_access_controller): """Test basic search without parameters.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 5 mock_connection.search.return_value = [1, 2, 3, 4, 5] mock_connection.read.return_value = [ {"id": 1, "name": "Partner 1", "email": "p1@example.com"}, {"id": 2, "name": "Partner 2", "email": "p2@example.com"}, {"id": 3, "name": "Partner 3", "email": "p3@example.com"}, {"id": 4, "name": "Partner 4", "email": "p4@example.com"}, {"id": 5, "name": "Partner 5", "email": "p5@example.com"}, ] mock_connection.fields_get.return_value = { "name": {"type": "char", "string": "Name"}, "email": {"type": "char", "string": "Email"}, } # Execute search result = await resource_handler._handle_search("res.partner", None, None, None, None, None) # Verify calls mock_access_controller.validate_model_access.assert_called_once_with("res.partner", "read") mock_connection.search_count.assert_called_once_with("res.partner", []) mock_connection.search.assert_called_once_with( "res.partner", [], limit=10, offset=0, order=None ) mock_connection.read.assert_called_once_with("res.partner", [1, 2, 3, 4, 5], None) # Check result format assert "Search Results: res.partner" in result assert "Showing records 1-5 of 5" in result assert "Partner 1" in result assert "Partner 5" in result @pytest.mark.asyncio async def test_search_with_domain( self, resource_handler, mock_connection, mock_access_controller ): """Test search with domain filter.""" # Setup domain domain = [["is_company", "=", True]] domain_encoded = quote(json.dumps(domain)) # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 2 mock_connection.search.return_value = [1, 3] mock_connection.read.return_value = [ {"id": 1, "name": "Company A", "is_company": True}, {"id": 3, "name": "Company B", "is_company": True}, ] mock_connection.fields_get.return_value = {} # Execute search result = await resource_handler._handle_search( "res.partner", domain_encoded, None, None, None, None ) # Verify domain was parsed and used mock_connection.search_count.assert_called_once_with("res.partner", domain) mock_connection.search.assert_called_once_with( "res.partner", domain, limit=10, offset=0, order=None ) # Check result contains domain info assert "Search criteria: is_company = True" in result assert "Company A" in result assert "Company B" in result @pytest.mark.asyncio async def test_search_with_fields( self, resource_handler, mock_connection, mock_access_controller ): """Test search with specific fields.""" fields = "name,email,phone" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 1 mock_connection.search.return_value = [1] mock_connection.read.return_value = [ {"id": 1, "name": "Test Partner", "email": "test@example.com", "phone": "+1234567890"} ] mock_connection.fields_get.return_value = {} # Execute search result = await resource_handler._handle_search( "res.partner", None, fields, None, None, None ) # Verify fields were parsed and used mock_connection.read.assert_called_once_with("res.partner", [1], ["name", "email", "phone"]) # Check result shows fields assert "Fields: name, email, phone" in result assert "email: test@example.com" in result assert "phone: +1234567890" in result @pytest.mark.asyncio async def test_search_with_pagination( self, resource_handler, mock_connection, mock_access_controller ): """Test search with pagination parameters.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 50 # Total records mock_connection.search.return_value = [11, 12, 13, 14, 15] # Page 2 results mock_connection.read.return_value = [ {"id": i, "name": f"Partner {i}"} for i in range(11, 16) ] mock_connection.fields_get.return_value = {} # Execute search with pagination result = await resource_handler._handle_search( "res.partner", None, None, 5, 10, None, # limit=5, offset=10 ) # Verify pagination in calls mock_connection.search.assert_called_once_with( "res.partner", [], limit=5, offset=10, order=None ) # Check pagination info in result assert "Page 3 of 10" in result # Page 3 because offset 10 with limit 5 assert "Showing records 11-15 of 50" in result assert "→ Next page:" in result assert "← Previous page:" in result @pytest.mark.asyncio async def test_search_with_order( self, resource_handler, mock_connection, mock_access_controller ): """Test search with order parameter.""" order = "name desc, id asc" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 3 mock_connection.search.return_value = [3, 1, 2] # Ordered IDs mock_connection.read.return_value = [ {"id": 3, "name": "Zebra Corp"}, {"id": 1, "name": "Alpha Inc"}, {"id": 2, "name": "Beta LLC"}, ] mock_connection.fields_get.return_value = {} # Execute search result = await resource_handler._handle_search("res.partner", None, None, None, None, order) # Verify order was used mock_connection.search.assert_called_once_with( "res.partner", [], limit=10, offset=0, order="name desc, id asc" ) # Results should show in order assert result.index("Zebra Corp") < result.index("Alpha Inc") @pytest.mark.asyncio async def test_search_empty_results( self, resource_handler, mock_connection, mock_access_controller ): """Test search with no results.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 0 mock_connection.search.return_value = [] mock_connection.fields_get.return_value = {} # Execute search result = await resource_handler._handle_search("res.partner", None, None, None, None, None) # Should not call read for empty results mock_connection.read.assert_not_called() # Check result message assert "No records found matching the criteria" in result assert "Showing records 1-0 of 0" in result @pytest.mark.asyncio async def test_search_access_denied(self, resource_handler, mock_access_controller): """Test search with access denied.""" # Setup access denial mock_access_controller.validate_model_access.side_effect = AccessControlError( "Model 'sale.order' is not enabled for MCP access" ) # Execute search and expect permission error with pytest.raises(MCPPermissionError) as exc_info: await resource_handler._handle_search("sale.order", None, None, None, None, None) assert "Access denied" in str(exc_info.value) @pytest.mark.asyncio async def test_search_connection_error( self, resource_handler, mock_connection, mock_access_controller ): """Test search with connection error.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.side_effect = OdooConnectionError("Connection lost") # Execute search and expect error with pytest.raises(ValidationError) as exc_info: await resource_handler._handle_search("res.partner", None, None, None, None, None) assert "Connection error" in str(exc_info.value) @pytest.mark.asyncio async def test_search_limit_validation( self, resource_handler, mock_connection, mock_access_controller ): """Test search limit parameter validation.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 10 mock_connection.search.return_value = list(range(1, 11)) mock_connection.read.return_value = [{"id": i} for i in range(1, 11)] mock_connection.fields_get.return_value = {} # Test with negative limit (should use default) await resource_handler._handle_search("res.partner", None, None, -5, None, None) mock_connection.search.assert_called_with("res.partner", [], limit=10, offset=0, order=None) # Test with limit over max (should cap at max) mock_connection.search.reset_mock() await resource_handler._handle_search("res.partner", None, None, 200, None, None) mock_connection.search.assert_called_with( "res.partner", [], limit=100, offset=0, order=None ) @pytest.mark.asyncio async def test_search_invalid_domain( self, resource_handler, mock_connection, mock_access_controller ): """Test search with invalid domain parameter.""" # Invalid JSON domain invalid_domain = quote("not-valid-json") # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 5 mock_connection.search.return_value = [1, 2, 3, 4, 5] mock_connection.read.return_value = [{"id": i} for i in range(1, 6)] mock_connection.fields_get.return_value = {} # Should handle gracefully and use empty domain await resource_handler._handle_search("res.partner", invalid_domain, None, None, None, None) # Should use empty domain mock_connection.search_count.assert_called_once_with("res.partner", []) mock_connection.search.assert_called_once_with( "res.partner", [], limit=10, offset=0, order=None ) @pytest.mark.asyncio async def test_search_large_dataset_summary( self, resource_handler, mock_connection, mock_access_controller ): """Test search with large dataset shows summary.""" # Setup mocks for large dataset mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 500 # Large dataset mock_connection.search.return_value = list(range(1, 11)) mock_connection.read.return_value = [ {"id": i, "name": f"Partner {i}"} for i in range(1, 11) ] mock_connection.fields_get.return_value = {} # Execute search result = await resource_handler._handle_search("res.partner", None, None, None, None, None) # Should include dataset summary assert "Dataset Summary:" in result assert "Total records: 500" in result # Only shows filter suggestion when domain is present, which isn't the case here class TestSearchResourceIntegration: """Integration tests for search resource with real Odoo.""" @pytest.mark.integration @pytest.mark.asyncio async def test_search_real_partners(self, real_config, real_connection): """Test search with real Odoo connection.""" # Setup real components app = Mock(spec=FastMCP) app.resource = Mock() app._handlers = {} def resource_decorator(uri_pattern): def decorator(func): app._handlers[uri_pattern] = func return func return decorator app.resource.side_effect = resource_decorator access_controller = AccessController(real_config) handler = OdooResourceHandler(app, real_connection, access_controller, real_config) # Connect and authenticate real_connection.connect() try: real_connection.authenticate() except OdooConnectionError as e: if "429" in str(e) or "Too many requests" in str(e).lower(): pytest.skip("Rate limited by server") raise # Execute real search try: result = await handler._handle_search( "res.partner", quote(json.dumps([["is_company", "=", True]])), # Search for companies "name,email,country_id", # Specific fields 5, # Limit 0, # Offset "name asc", # Order ) except ValidationError as e: if "429" in str(e) or "Too many requests" in str(e).lower() or "Rate limit" in str(e): pytest.skip("Rate limited by server") raise # Verify result structure assert "Search Results: res.partner" in result assert "Search criteria:" in result assert "is_company = True" in result assert "Fields: name, email, country_id" in result assert "Page 1 of" in result # Should have actual partner data assert "email:" in result or "Not set" in result

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/ivnvxd/mcp-server-odoo'

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