From c670ea5bc931bbb5da148e988503bd6f038381fc Mon Sep 17 00:00:00 2001 From: aserrador Date: Mon, 9 Mar 2026 16:04:34 -0500 Subject: [PATCH] feat: add pytest testing infrastructure - Add test optional dependencies to pyproject.toml - Add conftest.py with fixtures for mocking settings and MCP client - Add test_server.py with tests for tryton_call and MCP tools --- pyproject.toml | 7 +++ tests/conftest.py | 25 ++++++++ tests/test_server.py | 141 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 61 ++++++++++++++++++- 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_server.py diff --git a/pyproject.toml b/pyproject.toml index 4760c19..609eaaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,13 @@ dependencies = [ "python-dotenv>=1.0.0", ] +[project.optional-dependencies] +test = [ + "pytest>=8.0.0", + "pytest-mock>=3.14.0", + "pytest-asyncio>=0.25.0", +] + [tool.setuptools.packages.find] where = ["src"] include = ["*"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7784207 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest +import pytest_asyncio +from unittest.mock import MagicMock, patch +from fastmcp import Client + + +@pytest.fixture +def mock_settings(): + with patch("tryton_mcp.server.settings") as mock: + mock_client = MagicMock() + mock.get_client.return_value = mock_client + yield mock + + +@pytest.fixture +def mcp_server(): + from tryton_mcp.server import mcp + + return mcp + + +@pytest_asyncio.fixture +async def mcp_client(mcp_server): + async with Client(mcp_server) as client: + yield client diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..6db3d4d --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,141 @@ +import pytest +from unittest.mock import MagicMock, patch +import datetime +import json + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "src")) + + +@pytest.mark.asyncio +class TestTrytonCall: + async def test_tryton_call_success(self, mock_settings): + from tryton_mcp.server import tryton_call + + mock_client = mock_settings.get_client.return_value + mock_client.call.return_value = [{"id": 1, "name": "Test"}] + + result = tryton_call("model.test.read", [[1], ["name"]]) + + assert result["success"] is True + assert result["data"] == [{"id": 1, "name": "Test"}] + mock_client.call.assert_called_once_with("model.test.read", [[1], ["name"]]) + + async def test_tryton_call_failure(self, mock_settings): + from tryton_mcp.server import tryton_call + + mock_client = mock_settings.get_client.return_value + mock_client.call.side_effect = Exception("Connection error") + + result = tryton_call("model.test.read", [[1], ["name"]]) + + assert result["success"] is False + assert "Connection error" in result["error"] + + +@pytest.mark.asyncio +class TestCreateSchedule: + async def test_create_schedule_success(self, mcp_client, mock_settings): + from tryton_mcp.server import create_schedule + + mock_client = mock_settings.get_client.return_value + mock_client.call.return_value = [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, + }, + ) + + assert result.content is not None + assert len(result.content) == 1 + assert result.content[0].text == "[1]" + + mock_client.call.assert_called_once() + + async def test_create_schedule_failure(self, mcp_client, mock_settings): + from tryton_mcp.server import create_schedule + + mock_client = mock_settings.get_client.return_value + mock_client.call.side_effect = Exception("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, + }, + ) + + +@pytest.mark.asyncio +class TestFindServiceCenters: + async def test_find_service_centers_success(self, mcp_client, mock_settings): + from tryton_mcp.server import find_service_centers + + mock_client = mock_settings.get_client.return_value + mock_client.call.return_value = [ + {"id": 11, "name": "Center 1", "address.street": "Street 1"} + ] + + result = await mcp_client.call_tool("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] == [[]] + + async def test_find_service_centers_failure(self, mcp_client, mock_settings): + from tryton_mcp.server import find_service_centers + + mock_client = mock_settings.get_client.return_value + mock_client.call.side_effect = Exception("Search failed") + + with pytest.raises(Exception, match="Failed to find service centers"): + await mcp_client.call_tool("find_service_centers", {}) + + +@pytest.mark.asyncio +class TestFindProductsAndServices: + async def test_find_products_and_services_success(self, mcp_client, mock_settings): + from tryton_mcp.server import find_products_and_services + + mock_client = mock_settings.get_client.return_value + mock_client.call.return_value = [ + {"id": 1, "name": "Product 1", "list_price": 100.0} + ] + + result = await mcp_client.call_tool("find_products_and_services", {}) + + result_data = json.loads(result.content[0].text) + assert result_data == [{"id": 1, "name": "Product 1", "list_price": 100.0}] + + 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): + 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") + + with pytest.raises(Exception, match="Failed to find products"): + await mcp_client.call_tool("find_products_and_services", {}) diff --git a/uv.lock b/uv.lock index 1a88366..35d30e3 100644 --- a/uv.lock +++ b/uv.lock @@ -91,6 +91,8 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -418,6 +420,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -664,6 +675,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prometheus-client" version = "0.24.1" @@ -839,6 +859,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1092,19 +1140,30 @@ wheels = [ ] [[package]] -name = "trytonmcp" +name = "tryton-mcp" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastmcp", extra = ["tasks"] }, + { name = "python-dotenv" }, { name = "sabatron-tryton-rpc-client" }, ] +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + [package.metadata] requires-dist = [ { name = "fastmcp", extras = ["tasks"], specifier = ">=3.1.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.14.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "sabatron-tryton-rpc-client", specifier = ">=7.4.0" }, ] +provides-extras = ["test"] [[package]] name = "typer"