Skip to main content
Glama

MCP Server for Odoo

test_advanced_resources.py•18.2 kB
"""Tests for advanced resource operations (browse, count, fields).""" 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 from mcp_server_odoo.resources import OdooResourceHandler # Import skip_on_rate_limit decorator from .test_xmlrpc_operations import skip_on_rate_limit @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) class TestBrowseResource: """Test browse resource functionality.""" @pytest.mark.asyncio async def test_browse_multiple_records( self, resource_handler, mock_connection, mock_access_controller ): """Test browsing multiple records by IDs.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.read.return_value = [ {"id": 1, "name": "Record 1", "email": "r1@example.com"}, {"id": 3, "name": "Record 3", "email": "r3@example.com"}, {"id": 5, "name": "Record 5", "email": "r5@example.com"}, ] mock_connection.fields_get.return_value = { "name": {"type": "char", "string": "Name"}, "email": {"type": "char", "string": "Email"}, } # Execute browse result = await resource_handler._handle_browse("res.partner", "1,3,5") # Verify calls mock_access_controller.validate_model_access.assert_called_once_with("res.partner", "read") # After smart field filtering, read is called with specific fields mock_connection.read.assert_called_once_with("res.partner", [1, 3, 5], ["name", "email"]) # Check result format assert "Browse Results: res.partner" in result assert "Requested IDs: 1, 3, 5" in result assert "Found: 3 of 3 records" in result assert "Record 1" in result assert "Record 3" in result assert "Record 5" in result @pytest.mark.asyncio async def test_browse_with_missing_records( self, resource_handler, mock_connection, mock_access_controller ): """Test browsing with some missing records.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.read.return_value = [ {"id": 1, "name": "Record 1"}, {"id": 3, "name": "Record 3"}, ] mock_connection.fields_get.return_value = {} # Execute browse result = await resource_handler._handle_browse("res.partner", "1,2,3,4") # Check result shows missing records assert "Requested IDs: 1, 2, 3, 4" in result assert "Found: 2 of 4 records" in result assert "Missing IDs: 2, 4" in result @pytest.mark.asyncio async def test_browse_invalid_ids( self, resource_handler, mock_connection, mock_access_controller ): """Test browse with invalid ID formats.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.read.return_value = [ {"id": 1, "name": "Record 1"}, ] mock_connection.fields_get.return_value = {} # Execute browse with mixed valid/invalid IDs result = await resource_handler._handle_browse("res.partner", "1,abc,2.5,-3,0") # Should only use valid ID (1) mock_connection.read.assert_called_once_with("res.partner", [1]) assert "Record 1" in result @pytest.mark.asyncio async def test_browse_empty_ids(self, resource_handler, mock_access_controller): """Test browse with empty ID list.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None # Execute browse and expect error with pytest.raises(ValidationError) as exc_info: await resource_handler._handle_browse("res.partner", "") assert "No valid IDs provided" in str(exc_info.value) @pytest.mark.asyncio async def test_browse_access_denied(self, resource_handler, mock_access_controller): """Test browse 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 browse and expect permission error with pytest.raises(MCPPermissionError) as exc_info: await resource_handler._handle_browse("sale.order", "1,2,3") assert "Access denied" in str(exc_info.value) class TestCountResource: """Test count resource functionality.""" @pytest.mark.asyncio async def test_count_all_records( self, resource_handler, mock_connection, mock_access_controller ): """Test counting all records without domain.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 150 # Execute count result = await resource_handler._handle_count("res.partner", 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", []) # Check result format assert "Count Result: res.partner" in result assert "Search criteria: All records" in result assert "Total count: 150 record(s)" in result @pytest.mark.asyncio async def test_count_with_domain( self, resource_handler, mock_connection, mock_access_controller ): """Test counting 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 = 45 # Execute count result = await resource_handler._handle_count("res.partner", domain_encoded) # Verify domain was parsed and used mock_connection.search_count.assert_called_once_with("res.partner", domain) # Check result contains domain info assert "Search criteria: is_company = True" in result assert "Total count: 45 record(s)" in result @pytest.mark.asyncio async def test_count_complex_domain( self, resource_handler, mock_connection, mock_access_controller ): """Test counting with complex domain.""" # Setup complex domain domain = [ "|", ["customer_rank", ">", 0], "&", ["is_company", "=", True], ["active", "=", True], ] domain_encoded = quote(json.dumps(domain)) # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 25 # Execute count result = await resource_handler._handle_count("res.partner", domain_encoded) # Check result contains complex domain assert "| customer_rank > 0 & is_company = True active = True" in result assert "Total count: 25 record(s)" in result @pytest.mark.asyncio async def test_count_zero_results( self, resource_handler, mock_connection, mock_access_controller ): """Test counting with no matching records.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.search_count.return_value = 0 # Execute count result = await resource_handler._handle_count( "res.partner", quote(json.dumps([["name", "=", "NonExistent"]])) ) # Check result assert "Total count: 0 record(s)" in result class TestFieldsResource: """Test fields resource functionality.""" @pytest.mark.asyncio async def test_fields_basic(self, resource_handler, mock_connection, mock_access_controller): """Test basic field retrieval.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.fields_get.return_value = { "name": { "type": "char", "string": "Name", "required": True, "help": "The partner name", }, "email": {"type": "char", "string": "Email", "required": False}, "is_company": { "type": "boolean", "string": "Is a Company", "help": "Check if the contact is a company, otherwise it is a person", }, "partner_id": { "type": "many2one", "string": "Related Partner", "relation": "res.partner", }, } # Execute fields result = await resource_handler._handle_fields("res.partner") # Verify calls mock_access_controller.validate_model_access.assert_called_once_with("res.partner", "read") mock_connection.fields_get.assert_called_once_with("res.partner") # Check result format assert "Field Definitions: res.partner" in result assert "Total fields: 4" in result assert "CHAR Fields (2):" in result assert "BOOLEAN Fields (1):" in result assert "MANY2ONE Fields (1):" in result # Check field details assert "name:" in result assert "Label: Name" in result assert "Required: True" in result assert "Help: The partner name" in result assert "partner_id:" in result assert "Related Model: res.partner" in result @pytest.mark.asyncio async def test_fields_with_selection( self, resource_handler, mock_connection, mock_access_controller ): """Test fields with selection type.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.fields_get.return_value = { "state": { "type": "selection", "string": "Status", "selection": [ ("draft", "Draft"), ("open", "Open"), ("done", "Done"), ("cancel", "Cancelled"), ], "required": True, }, "type": { "type": "selection", "string": "Type", "selection": [ ("contact", "Contact"), ("invoice", "Invoice Address"), ("delivery", "Delivery Address"), ("other", "Other"), ("private", "Private"), ], }, } # Execute fields result = await resource_handler._handle_fields("res.partner") # Check selection fields assert "SELECTION Fields (2):" in result assert "state:" in result assert "Options: draft (Draft), open (Open), done (Done), cancel (Cancelled)" in result assert "type:" in result assert ( "Options: contact (Contact), invoice (Invoice Address), delivery (Delivery Address), other (Other), private (Private)" in result ) @pytest.mark.asyncio async def test_fields_with_numeric( self, resource_handler, mock_connection, mock_access_controller ): """Test fields with numeric types.""" # Setup mocks mock_access_controller.validate_model_access.return_value = None mock_connection.fields_get.return_value = { "credit_limit": {"type": "float", "string": "Credit Limit", "digits": (16, 2)}, "color": {"type": "integer", "string": "Color Index"}, "debit": {"type": "monetary", "string": "Total Receivable", "readonly": True}, } # Execute fields result = await resource_handler._handle_fields("account.partner") # Check numeric fields assert "FLOAT Fields (1):" in result assert "credit_limit:" in result assert "Precision: (16, 2)" in result assert "INTEGER Fields (1):" in result assert "color:" in result assert "MONETARY Fields (1):" in result assert "debit:" in result assert "Readonly: True" in result class TestAdvancedResourceIntegration: """Integration tests for advanced resources with real Odoo.""" @pytest.mark.asyncio @pytest.mark.integration @skip_on_rate_limit async def test_browse_real_records(self, real_config, real_connection): """Test browse 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) # Authenticate real_connection.connect() real_connection.authenticate() # Get some partner IDs to browse partner_ids = real_connection.search("res.partner", [], limit=3) if partner_ids: # Execute real browse ids_str = ",".join(map(str, partner_ids)) result = await handler._handle_browse("res.partner", ids_str) # Verify result assert "Browse Results: res.partner" in result assert f"Found: {len(partner_ids)} of {len(partner_ids)} records" in result for pid in partner_ids: assert str(pid) in result @pytest.mark.asyncio @pytest.mark.integration @skip_on_rate_limit async def test_count_real_records(self, real_config, real_connection): """Test count 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) # Authenticate real_connection.connect() real_connection.authenticate() # Count all partners result_all = await handler._handle_count("res.partner", None) assert "Search criteria: All records" in result_all assert "Total count:" in result_all # Count companies only domain = [["is_company", "=", True]] result_companies = await handler._handle_count("res.partner", quote(json.dumps(domain))) assert "is_company = True" in result_companies assert "Total count:" in result_companies @pytest.mark.asyncio @pytest.mark.integration @skip_on_rate_limit async def test_fields_real_model(self, real_config, real_connection): """Test fields with real Odoo model.""" # 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) # Authenticate real_connection.connect() real_connection.authenticate() # Get fields for res.partner result = await handler._handle_fields("res.partner") # Verify result contains expected fields assert "Field Definitions: res.partner" in result assert "Total fields:" in result # Check for common partner fields assert "name:" in result assert "email:" in result assert "is_company:" in result # Check field types are categorized assert "CHAR Fields" in result assert "BOOLEAN Fields" in result assert "MANY2ONE Fields" in result @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)

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

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