refactor: add service layer architecture with Pydantic validation

- Add services layer (PartyService, ScheduleService, ProductService, ServiceCenterService)
- Add ServiceProvider for dependency injection
- Add Pydantic input validation on all MCP tools
- Add thread-safety to TrytonSettings with double-checked locking
- Update tests to mock ServiceProvider instead of direct client calls
- Add pydantic dependency
This commit is contained in:
2026-03-14 22:12:33 -05:00
parent 6154b83455
commit 861f5030f4
13 changed files with 545 additions and 199 deletions

View File

@@ -1,48 +1,61 @@
import pytest
import sys
from pathlib import Path
import pytest_asyncio
from unittest.mock import MagicMock, patch
import json
from tryton_mcp.server import create_customer
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
@pytest.fixture
def mock_client():
return MagicMock()
@pytest.fixture
def mock_service_provider(mock_client):
provider_mock = MagicMock()
mock_response = MagicMock()
mock_response.is_error = False
mock_response.data = []
provider_mock.party.find_by_identifier.return_value = mock_response
provider_mock.party.create.return_value = mock_response
return provider_mock
@pytest.fixture
def server_with_mocks(mock_service_provider):
with patch("tryton_mcp.server._service_provider", mock_service_provider):
yield mock_service_provider
@pytest.mark.asyncio
class TestParty:
async def test_create_new_customer(self, server_with_mocks):
from tryton_mcp.server import create_customer
async def test_create_new_customer(self, mcp_client, mock_settings):
mock_client = mock_settings.get_client.return_value
mock_client.call.return_value = [1]
server_with_mocks.party.create.return_value.data = [1]
result = await mcp_client.call_tool(
"create_customer",
{
"name": "Alejandro Zapata",
"identifiers": [["create", [{"type": "mobile", "code": "310659595"}]]],
},
result = create_customer(
name="Alejandro Zapata",
identifiers=[["create", [{"type": "mobile", "code": "310659595"}]]],
)
assert result.content is not None
assert len(result.content) == 1
assert result.content[0].text == "[1]"
assert result == [1]
async def test_find_customer(self, mcp_client, mock_settings):
async def test_find_customer(self, server_with_mocks):
from tryton_mcp.server import find_customer_by_identifier
expected_return = [
{'id': 19, 'party': 44, 'party.': {'name': 'Alejandro Zapata', 'id': 44}}
{"id": 19, "party": 44, "party.": {"name": "Alejandro Zapata", "id": 44}}
]
mock_client = mock_settings.get_client.return_value
mock_client.call.return_value = expected_return
result = await mcp_client.call_tool(
"find_customer_by_identifier",
{
"identifier": "310659595"
})
assert result.content is not None
assert len(result.content) == 1
assert json.loads(result.content[0].text) == expected_return
server_with_mocks.party.find_by_identifier.return_value.data = expected_return
result = find_customer_by_identifier("310659595")
assert result == expected_return

View File

@@ -1,8 +1,8 @@
import pytest
import pytest_asyncio
from unittest.mock import MagicMock, patch
import datetime
import json
from fastmcp.exceptions import ToolError
from contextlib import asynccontextmanager
import sys
from pathlib import Path
@@ -10,146 +10,199 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
@pytest.fixture
def mock_client():
return MagicMock()
@pytest.fixture
def mock_service_provider(mock_client):
provider_mock = MagicMock()
mock_response = MagicMock()
mock_response.is_error = False
mock_response.data = []
provider_mock.party.find_by_identifier.return_value = mock_response
provider_mock.party.create.return_value = mock_response
provider_mock.schedule.create.return_value = mock_response
provider_mock.service_center.find_all.return_value = mock_response
provider_mock.product.find_salable.return_value = mock_response
return provider_mock
@pytest.fixture
def server_with_mocks(mock_service_provider):
with patch("tryton_mcp.server._service_provider", mock_service_provider):
yield mock_service_provider
@pytest.mark.asyncio
class TestTrytonCall:
async def test_tryton_call_success(self, mock_settings):
from tryton_mcp.server import tryton_call
class TestFindCustomerByIdentifier:
async def test_find_customer_success(self, server_with_mocks):
from tryton_mcp.server import find_customer_by_identifier
mock_client = mock_settings.get_client.return_value
mock_client.call.return_value = [{"id": 1, "name": "Test"}]
server_with_mocks.party.find_by_identifier.return_value.data = [
{"id": 1, "party": {"name": "Test Customer"}}
]
result = tryton_call("model.test.read", [[1], ["name"]])
result = find_customer_by_identifier("12345")
assert result["success"] is True
assert result["data"] == [{"id": 1, "name": "Test"}]
mock_client.call.assert_called_once_with("model.test.read", [[1], ["name"]])
assert result == [{"id": 1, "party": {"name": "Test Customer"}}]
server_with_mocks.party.find_by_identifier.assert_called_once_with("12345")
async def test_tryton_call_failure(self, mock_settings):
from tryton_mcp.server import tryton_call
async def test_find_customer_not_found(self, server_with_mocks):
from tryton_mcp.server import find_customer_by_identifier
mock_client = mock_settings.get_client.return_value
mock_client.call.side_effect = Exception("Connection error")
server_with_mocks.party.find_by_identifier.return_value.is_error = True
result = tryton_call("model.test.read", [[1], ["name"]])
with pytest.raises(Exception, match="Customer with identifier"):
find_customer_by_identifier("invalid")
assert result["success"] is False
assert "Connection error" in result["error"]
@pytest.mark.asyncio
class TestCreateCustomer:
async def test_create_customer_success(self, server_with_mocks):
from tryton_mcp.server import create_customer
server_with_mocks.party.create.return_value.data = [1]
result = create_customer("New Customer", [])
assert result == [1]
async def test_create_customer_invalid_name(self, server_with_mocks):
from tryton_mcp.server import create_customer
from pydantic import ValidationError
with pytest.raises(ValidationError):
create_customer("", [])
@pytest.mark.asyncio
class TestCreateSchedule:
async def test_create_schedule_success(self, mcp_client, mock_settings):
async def test_create_schedule_success(self, server_with_mocks):
from tryton_mcp.server import create_schedule
mock_client = mock_settings.get_client.return_value
mock_client.call.return_value = [1]
server_with_mocks.schedule.create.return_value.data = [1]
result = await mcp_client.call_tool(
"create_schedule",
{
"professional": 6,
"description": "Test appointment",
"customer": 4,
"date": "2026-03-15T10:00:00",
"service_center": 11,
},
result = create_schedule(
professional=6,
description="Test appointment",
customer=4,
date="2026-03-15T10:00:00",
service_center=11,
)
assert result.content is not None
assert len(result.content) == 1
assert result.content[0].text == "[1]"
assert result == [1]
mock_client.call.assert_called_once()
async def test_create_schedule_failure(self, mcp_client, mock_settings):
async def test_create_schedule_failure(self, server_with_mocks):
from tryton_mcp.server import create_schedule
mock_client = mock_settings.get_client.return_value
mock_client.call.side_effect = Exception("Create failed")
server_with_mocks.schedule.create.return_value.is_error = True
server_with_mocks.schedule.create.return_value.error = "Create failed"
with pytest.raises(Exception, match="Failed to create schedule"):
await mcp_client.call_tool(
"create_schedule",
{
"professional": 6,
"description": "Test appointment",
"customer": 4,
"date": "2026-03-15T10:00:00",
"service_center": 11,
},
create_schedule(
professional=6,
description="Test appointment",
customer=4,
date="2026-03-15T10:00:00",
service_center=11,
)
async def test_create_schedule_invalid_date_format(self, mcp_client, mock_settings):
with pytest.raises(ToolError, match="Invalid date format"):
await mcp_client.call_tool(
"create_schedule",
{
"professional": 6,
"description": "Test appointment",
"customer": 4,
"date": "invalid-date",
"service_center": 11,
},
async def test_create_schedule_invalid_date_format(self, server_with_mocks):
from tryton_mcp.server import create_schedule
from pydantic import ValidationError
with pytest.raises(ValidationError):
create_schedule(
professional=6,
description="Test appointment",
customer=4,
date="invalid-date",
service_center=11,
)
async def test_create_schedule_invalid_professional(self, server_with_mocks):
from tryton_mcp.server import create_schedule
from pydantic import ValidationError
with pytest.raises(ValidationError):
create_schedule(
professional=0,
description="Test appointment",
customer=4,
date="2026-03-15T10:00:00",
service_center=11,
)
@pytest.mark.asyncio
class TestFindServiceCenters:
async def test_find_service_centers_success(self, mcp_client, mock_settings):
async def test_find_service_centers_success(self, server_with_mocks):
from tryton_mcp.server import find_service_centers
mock_client = mock_settings.get_client.return_value
mock_client.call.return_value = [
server_with_mocks.service_center.find_all.return_value.data = [
{"id": 11, "name": "Center 1", "address.street": "Street 1"}
]
result = await mcp_client.call_tool("find_service_centers", {})
result = find_service_centers()
result_data = json.loads(result.content[0].text)
assert result_data == [
{"id": 11, "name": "Center 1", "address.street": "Street 1"}
]
call_args = mock_client.call.call_args[0]
assert call_args[0] == "model.naliia.service_center.search_read"
assert call_args[1][0] == [[]]
assert result == [{"id": 11, "name": "Center 1", "address.street": "Street 1"}]
server_with_mocks.service_center.find_all.assert_called_once_with(
offset=0, limit=None
)
async def test_find_service_centers_failure(self, mcp_client, mock_settings):
async def test_find_service_centers_with_pagination(self, server_with_mocks):
from tryton_mcp.server import find_service_centers
mock_client = mock_settings.get_client.return_value
mock_client.call.side_effect = Exception("Search failed")
find_service_centers(offset=10, limit=5)
server_with_mocks.service_center.find_all.assert_called_once_with(
offset=10, limit=5
)
async def test_find_service_centers_failure(self, server_with_mocks):
from tryton_mcp.server import find_service_centers
server_with_mocks.service_center.find_all.return_value.is_error = True
server_with_mocks.service_center.find_all.return_value.error = "Search failed"
with pytest.raises(Exception, match="Failed to find service centers"):
await mcp_client.call_tool("find_service_centers", {})
find_service_centers()
@pytest.mark.asyncio
class TestFindProductsAndServices:
async def test_find_products_and_services_success(self, mcp_client, mock_settings):
async def test_find_products_and_services_success(self, server_with_mocks):
from tryton_mcp.server import find_products_and_services
mock_client = mock_settings.get_client.return_value
mock_client.call.return_value = [
server_with_mocks.product.find_salable.return_value.data = [
{"id": 1, "name": "Product 1", "list_price": 100.0}
]
result = await mcp_client.call_tool("find_products_and_services", {})
result = find_products_and_services()
result_data = json.loads(result.content[0].text)
assert result_data == [{"id": 1, "name": "Product 1", "list_price": 100.0}]
assert result == [{"id": 1, "name": "Product 1", "list_price": 100.0}]
server_with_mocks.product.find_salable.assert_called_once_with(
offset=0, limit=None
)
mock_client.call.assert_called_once()
call_args = mock_client.call.call_args[0]
assert call_args[0] == "model.product.product.search_read"
assert call_args[1][0] == [["active", "=", True], ["salable", "=", True]]
async def test_find_products_and_services_failure(self, mcp_client, mock_settings):
async def test_find_products_with_pagination(self, server_with_mocks):
from tryton_mcp.server import find_products_and_services
mock_client = mock_settings.get_client.return_value
mock_client.call.side_effect = Exception("Search failed")
find_products_and_services(offset=5, limit=10)
server_with_mocks.product.find_salable.assert_called_once_with(
offset=5, limit=10
)
async def test_find_products_failure(self, server_with_mocks):
from tryton_mcp.server import find_products_and_services
server_with_mocks.product.find_salable.return_value.is_error = True
server_with_mocks.product.find_salable.return_value.error = "Search failed"
with pytest.raises(Exception, match="Failed to find products"):
await mcp_client.call_tool("find_products_and_services", {})
find_products_and_services()