mirror of
https://github.com/latinogino/dolibarr-mcp.git
synced 2026-04-19 16:52:40 +02:00
Merge pull request #8 from Benju1/feature/dolibarr-search-and-bugfixes
Feat: Atomic Invoice Operations, Enhanced Search & Bugfixes looks good, thank you.
This commit is contained in:
11
README.md
11
README.md
@@ -1,9 +1,13 @@
|
||||
# Dolibarr MCP Server
|
||||
|
||||
Dolibarr MCP delivers a Model Context Protocol (MCP) interface for the Dolibarr
|
||||
ERP/CRM. The project mirrors the layout of [`prestashop-mcp`](https://github.com/latinogino/prestashop-mcp):
|
||||
ERP/CRM. The project mirrors the project structure of [`prestashop-mcp`](https://github.com/latinogino/prestashop-mcp):
|
||||
an async API client, a production-ready STDIO server, and focused
|
||||
documentation. Claude Desktop and other MCP-aware tools can use the server to
|
||||
documentation.
|
||||
|
||||
**Design Note:** While sharing the same architecture, this server implements **specialized search tools** (e.g., `search_products_by_ref`, `resolve_product_ref`) instead of a single unified `get_` tool. This design choice ensures efficient server-side filtering via Dolibarr's SQL API, preventing the agent from accidentally loading thousands of records and exceeding context limits.
|
||||
|
||||
Claude Desktop and other MCP-aware tools can use the server to
|
||||
manage customers, products, invoices, orders, and contacts in a Dolibarr
|
||||
instance.
|
||||
|
||||
@@ -13,7 +17,8 @@ configuration, API coverage, and contributor workflows.
|
||||
## ✨ Features
|
||||
|
||||
- **Full ERP coverage** – CRUD tools for users, customers, products, invoices,
|
||||
orders, contacts, and raw API access.
|
||||
orders, contacts, projects, and raw API access.
|
||||
- **Advanced Search** – Server-side filtering for products, customers, and projects to minimize token usage and costs.
|
||||
- **Async/await HTTP client** – Efficient Dolibarr API wrapper with structured
|
||||
error handling.
|
||||
- **Ready for MCP hosts** – STDIO transport compatible with Claude Desktop out
|
||||
|
||||
@@ -21,11 +21,13 @@ implements for PrestaShop.
|
||||
| Resource | Endpoint(s) | Tool group |
|
||||
| --------------- | --------------------------- | --------------------------------------- |
|
||||
| Status | `GET /status` | `get_status`, `test_connection` |
|
||||
| Search | `/products`, `/thirdparties`| `search_products_by_ref`, `search_customers`, `resolve_product_ref` |
|
||||
| Users | `/users` | CRUD helpers under the *Users* group |
|
||||
| Third parties | `/thirdparties` | Customer CRUD operations |
|
||||
| Products | `/products` | Product CRUD operations |
|
||||
| Invoices | `/invoices` | Invoice CRUD operations |
|
||||
| Orders | `/orders` | Order CRUD operations |
|
||||
| Projects | `/projects` | Project CRUD operations & Search |
|
||||
| Contacts | `/contacts` | Contact CRUD operations |
|
||||
| Raw passthrough | Any relative path | `dolibarr_raw_api` tool for quick tests |
|
||||
|
||||
|
||||
@@ -6,18 +6,25 @@ kept separate in `docker/`.
|
||||
|
||||
## Install development dependencies
|
||||
|
||||
It is recommended to use a virtual environment to avoid conflicts with system packages (especially on Linux systems with externally managed environments).
|
||||
|
||||
```bash
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
# Create a virtual environment
|
||||
python3 -m venv .venv
|
||||
|
||||
### Windows PowerShell
|
||||
# Activate the virtual environment
|
||||
source .venv/bin/activate # On Linux/macOS
|
||||
# .venv\Scripts\activate # On Windows
|
||||
|
||||
```powershell
|
||||
pip install -e .`[dev`]
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Run the test suite
|
||||
|
||||
Once your virtual environment is active:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
@@ -28,6 +35,12 @@ To gather coverage metrics:
|
||||
pytest --cov=src/dolibarr_mcp --cov-report=term-missing
|
||||
```
|
||||
|
||||
If you encounter "command not found" errors, ensure your virtual environment is activated or run via python module:
|
||||
|
||||
```bash
|
||||
python3 -m pytest
|
||||
```
|
||||
|
||||
## Formatting and linting
|
||||
|
||||
The project intentionally avoids heavy linting dependencies. Follow the coding
|
||||
|
||||
@@ -10,6 +10,7 @@ if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
|
||||
from dolibarr_mcp.dolibarr_mcp_server import main
|
||||
import asyncio
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -19,7 +19,7 @@ class Config(BaseSettings):
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
validate_assignment=True,
|
||||
extra="forbid",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
dolibarr_url: str = Field(
|
||||
|
||||
@@ -272,6 +272,12 @@ class DolibarrClient:
|
||||
# CUSTOMER/THIRD PARTY MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
async def search_customers(self, sqlfilters: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Search customers using SQL filters."""
|
||||
params = {"limit": limit, "sqlfilters": sqlfilters}
|
||||
result = await self.request("GET", "thirdparties", params=params)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_customers(self, limit: int = 100, page: int = 1) -> List[Dict[str, Any]]:
|
||||
"""Get list of customers/third parties."""
|
||||
params = {"limit": limit}
|
||||
@@ -330,6 +336,12 @@ class DolibarrClient:
|
||||
# PRODUCT MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
async def search_products(self, sqlfilters: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Search products using SQL filters."""
|
||||
params = {"limit": limit, "sqlfilters": sqlfilters}
|
||||
result = await self.request("GET", "products", params=params)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_products(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""Get list of products."""
|
||||
params = {"limit": limit}
|
||||
@@ -388,6 +400,20 @@ class DolibarrClient:
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new invoice."""
|
||||
payload = self._merge_payload(data, **kwargs)
|
||||
|
||||
# Fix: Map customer_id to socid
|
||||
if "customer_id" in payload and "socid" not in payload:
|
||||
payload["socid"] = payload.pop("customer_id")
|
||||
|
||||
# Fix: Map product_id to fk_product in lines
|
||||
if "lines" in payload and isinstance(payload["lines"], list):
|
||||
for line in payload["lines"]:
|
||||
if "product_id" in line:
|
||||
line["fk_product"] = line.pop("product_id")
|
||||
# Ensure product_type is passed if present (0=Product, 1=Service)
|
||||
if "product_type" in line:
|
||||
line["product_type"] = line["product_type"]
|
||||
|
||||
result = await self.request("POST", "invoices", data=payload)
|
||||
return self._extract_identifier(result)
|
||||
|
||||
@@ -404,6 +430,44 @@ class DolibarrClient:
|
||||
async def delete_invoice(self, invoice_id: int) -> Dict[str, Any]:
|
||||
"""Delete an invoice."""
|
||||
return await self.request("DELETE", f"invoices/{invoice_id}")
|
||||
|
||||
async def add_invoice_line(
|
||||
self,
|
||||
invoice_id: int,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a line to an invoice."""
|
||||
payload = self._merge_payload(data, **kwargs)
|
||||
|
||||
# Map product_id to fk_product if present
|
||||
if "product_id" in payload:
|
||||
payload["fk_product"] = payload.pop("product_id")
|
||||
|
||||
return await self.request("POST", f"invoices/{invoice_id}/lines", data=payload)
|
||||
|
||||
async def update_invoice_line(
|
||||
self,
|
||||
invoice_id: int,
|
||||
line_id: int,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update a line in an invoice."""
|
||||
payload = self._merge_payload(data, **kwargs)
|
||||
return await self.request("PUT", f"invoices/{invoice_id}/lines/{line_id}", data=payload)
|
||||
|
||||
async def delete_invoice_line(self, invoice_id: int, line_id: int) -> Dict[str, Any]:
|
||||
"""Delete a line from an invoice."""
|
||||
return await self.request("DELETE", f"invoices/{invoice_id}/lines/{line_id}")
|
||||
|
||||
async def validate_invoice(self, invoice_id: int, warehouse_id: int = 0, not_trigger: int = 0) -> Dict[str, Any]:
|
||||
"""Validate an invoice."""
|
||||
payload = {
|
||||
"idwarehouse": warehouse_id,
|
||||
"not_trigger": not_trigger
|
||||
}
|
||||
return await self.request("POST", f"invoices/{invoice_id}/validate", data=payload)
|
||||
|
||||
# ============================================================================
|
||||
# ORDER MANAGEMENT
|
||||
@@ -484,6 +548,43 @@ class DolibarrClient:
|
||||
"""Delete a contact."""
|
||||
return await self.request("DELETE", f"contacts/{contact_id}")
|
||||
|
||||
# ============================================================================
|
||||
# PROJECT MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
async def get_projects(self, limit: int = 100, page: int = 1, status: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get list of projects."""
|
||||
params: Dict[str, Any] = {"limit": limit, "page": page}
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
result = await self.request("GET", "projects", params=params)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_project_by_id(self, project_id: int) -> Dict[str, Any]:
|
||||
"""Get specific project by ID."""
|
||||
return await self.request("GET", f"projects/{project_id}")
|
||||
|
||||
async def search_projects(self, sqlfilters: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Search projects using SQL filters."""
|
||||
params = {"limit": limit, "sqlfilters": sqlfilters}
|
||||
result = await self.request("GET", "projects", params=params)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def create_project(self, data: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
||||
"""Create a new project."""
|
||||
payload = self._merge_payload(data, **kwargs)
|
||||
result = await self.request("POST", "projects", data=payload)
|
||||
return self._extract_identifier(result)
|
||||
|
||||
async def update_project(self, project_id: int, data: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
||||
"""Update an existing project."""
|
||||
payload = self._merge_payload(data, **kwargs)
|
||||
return await self.request("PUT", f"projects/{project_id}", data=payload)
|
||||
|
||||
async def delete_project(self, project_id: int) -> Dict[str, Any]:
|
||||
"""Delete a project."""
|
||||
return await self.request("DELETE", f"projects/{project_id}")
|
||||
|
||||
# ============================================================================
|
||||
# RAW API CALL
|
||||
# ============================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,6 +140,52 @@ class TestCRUDOperations:
|
||||
mock_request.return_value = {"success": True}
|
||||
result = await client.delete_invoice(100)
|
||||
assert result["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_creation_with_product_lines(self, client):
|
||||
"""Test invoice creation with linked product lines."""
|
||||
with patch.object(client, 'request') as mock_request:
|
||||
mock_request.return_value = {"id": 101}
|
||||
|
||||
# Create invoice with product link and service type
|
||||
await client.create_invoice({
|
||||
"socid": 1,
|
||||
"lines": [
|
||||
{
|
||||
"desc": "Linked Product",
|
||||
"qty": 1,
|
||||
"subprice": 50,
|
||||
"product_id": 10,
|
||||
"product_type": 0
|
||||
},
|
||||
{
|
||||
"desc": "Service Line",
|
||||
"qty": 2,
|
||||
"subprice": 100,
|
||||
"product_type": 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Verify payload transformation
|
||||
call_args = mock_request.call_args
|
||||
assert call_args is not None
|
||||
method, endpoint = call_args[0]
|
||||
kwargs = call_args[1]
|
||||
|
||||
assert method == "POST"
|
||||
assert endpoint == "invoices"
|
||||
|
||||
payload = kwargs["data"]
|
||||
lines = payload["lines"]
|
||||
|
||||
# Check first line (Product)
|
||||
assert lines[0]["fk_product"] == 10
|
||||
assert "product_id" not in lines[0]
|
||||
assert lines[0]["product_type"] == 0
|
||||
|
||||
# Check second line (Service)
|
||||
assert lines[1]["product_type"] == 1
|
||||
|
||||
# Order CRUD Tests
|
||||
|
||||
|
||||
90
tests/test_invoice_atomic.py
Normal file
90
tests/test_invoice_atomic.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from dolibarr_mcp.config import Config
|
||||
from dolibarr_mcp.dolibarr_client import DolibarrClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInvoiceAtomic:
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
config = Config(
|
||||
dolibarr_url="https://test.dolibarr.com/api/index.php",
|
||||
api_key="test_key"
|
||||
)
|
||||
return DolibarrClient(config)
|
||||
|
||||
@patch('aiohttp.ClientSession.request')
|
||||
async def test_add_invoice_line(self, mock_request, client):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.text.return_value = '123' # Returns line ID usually
|
||||
mock_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
async with client:
|
||||
await client.add_invoice_line(
|
||||
invoice_id=1,
|
||||
desc="Test Line",
|
||||
qty=1,
|
||||
subprice=100,
|
||||
product_id=99
|
||||
)
|
||||
|
||||
# Verify call
|
||||
args, kwargs = mock_request.call_args
|
||||
assert args[0] == "POST"
|
||||
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines"
|
||||
assert kwargs['json'] == {
|
||||
"desc": "Test Line",
|
||||
"qty": 1,
|
||||
"subprice": 100,
|
||||
"fk_product": 99
|
||||
}
|
||||
|
||||
@patch('aiohttp.ClientSession.request')
|
||||
async def test_update_invoice_line(self, mock_request, client):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.text.return_value = '{"success": 1}'
|
||||
mock_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
async with client:
|
||||
await client.update_invoice_line(
|
||||
invoice_id=1,
|
||||
line_id=10,
|
||||
qty=5
|
||||
)
|
||||
|
||||
args, kwargs = mock_request.call_args
|
||||
assert args[0] == "PUT"
|
||||
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines/10"
|
||||
assert kwargs['json'] == {"qty": 5}
|
||||
|
||||
@patch('aiohttp.ClientSession.request')
|
||||
async def test_delete_invoice_line(self, mock_request, client):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.text.return_value = '{"success": 1}'
|
||||
mock_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
async with client:
|
||||
await client.delete_invoice_line(invoice_id=1, line_id=10)
|
||||
|
||||
args, kwargs = mock_request.call_args
|
||||
assert args[0] == "DELETE"
|
||||
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines/10"
|
||||
|
||||
@patch('aiohttp.ClientSession.request')
|
||||
async def test_validate_invoice(self, mock_request, client):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.text.return_value = '{"success": 1}'
|
||||
mock_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
async with client:
|
||||
await client.validate_invoice(invoice_id=1, warehouse_id=5)
|
||||
|
||||
args, kwargs = mock_request.call_args
|
||||
assert args[0] == "POST"
|
||||
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/validate"
|
||||
assert kwargs['json'] == {"idwarehouse": 5, "not_trigger": 0}
|
||||
112
tests/test_project_operations.py
Normal file
112
tests/test_project_operations.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Project Management Integration Tests for Dolibarr MCP Server.
|
||||
|
||||
These tests verify complete CRUD operations for Dolibarr projects.
|
||||
Run with: pytest tests/test_project_operations.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
|
||||
# Add src to path for imports
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from dolibarr_mcp import DolibarrClient, Config
|
||||
|
||||
|
||||
class TestProjectOperations:
|
||||
"""Test complete CRUD operations for Dolibarr projects."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
"""Create a test configuration."""
|
||||
return Config(
|
||||
dolibarr_url="https://test.dolibarr.com",
|
||||
dolibarr_api_key="test_api_key",
|
||||
log_level="INFO"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, config):
|
||||
"""Create a test client instance."""
|
||||
return DolibarrClient(config)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_crud_lifecycle(self, client):
|
||||
"""Test complete project CRUD lifecycle."""
|
||||
with patch.object(client, 'request') as mock_request:
|
||||
# Create
|
||||
mock_request.return_value = {"id": 200}
|
||||
project_id = await client.create_project({
|
||||
"title": "New Website",
|
||||
"description": "Website redesign project",
|
||||
"socid": 1,
|
||||
"status": 1
|
||||
})
|
||||
assert project_id == 200
|
||||
|
||||
# Read
|
||||
mock_request.return_value = {
|
||||
"id": 200,
|
||||
"ref": "PJ2401-001",
|
||||
"title": "New Website",
|
||||
"description": "Website redesign project"
|
||||
}
|
||||
project = await client.get_project_by_id(200)
|
||||
assert project["title"] == "New Website"
|
||||
assert project["ref"] == "PJ2401-001"
|
||||
|
||||
# Update
|
||||
mock_request.return_value = {"id": 200, "title": "Updated Website Project"}
|
||||
result = await client.update_project(200, {"title": "Updated Website Project"})
|
||||
assert result["title"] == "Updated Website Project"
|
||||
|
||||
# Delete
|
||||
mock_request.return_value = {"success": True}
|
||||
result = await client.delete_project(200)
|
||||
assert result["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects(self, client):
|
||||
"""Test searching projects."""
|
||||
with patch.object(client, 'request') as mock_request:
|
||||
mock_request.return_value = [
|
||||
{"id": 200, "ref": "PJ2401-001", "title": "Website Redesign"},
|
||||
{"id": 201, "ref": "PJ2401-002", "title": "Mobile App"}
|
||||
]
|
||||
|
||||
# Search by query
|
||||
results = await client.search_projects(sqlfilters="(t.title:like:'%Website%')", limit=10)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["title"] == "Website Redesign"
|
||||
|
||||
# Verify call arguments
|
||||
call_args = mock_request.call_args
|
||||
assert call_args is not None
|
||||
method, endpoint = call_args[0]
|
||||
kwargs = call_args[1]
|
||||
|
||||
assert method == "GET"
|
||||
assert endpoint == "projects"
|
||||
assert kwargs["params"]["sqlfilters"] == "(t.title:like:'%Website%')"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_projects_with_filters(self, client):
|
||||
"""Test getting projects with status filter."""
|
||||
with patch.object(client, 'request') as mock_request:
|
||||
mock_request.return_value = []
|
||||
|
||||
await client.get_projects(limit=50, page=2, status=1)
|
||||
|
||||
call_args = mock_request.call_args
|
||||
kwargs = call_args[1]
|
||||
params = kwargs["params"]
|
||||
|
||||
assert params["limit"] == 50
|
||||
assert params["page"] == 2
|
||||
assert params["status"] == 1
|
||||
79
tests/test_search_tools.py
Normal file
79
tests/test_search_tools.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from dolibarr_mcp.dolibarr_mcp_server import handle_call_tool
|
||||
from dolibarr_mcp.dolibarr_client import DolibarrClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_products_by_ref():
|
||||
# Mock DolibarrClient
|
||||
with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient:
|
||||
mock_instance = MockClient.return_value
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
|
||||
# Mock search_products response
|
||||
mock_instance.search_products = AsyncMock(return_value=[
|
||||
{"id": 1, "ref": "PRJ-123", "label": "Project 123"}
|
||||
])
|
||||
|
||||
# Call the tool
|
||||
result = await handle_call_tool("search_products_by_ref", {"ref_prefix": "PRJ"})
|
||||
|
||||
# Verify the call
|
||||
mock_instance.search_products.assert_called_once()
|
||||
call_args = mock_instance.search_products.call_args
|
||||
assert "sqlfilters" in call_args.kwargs
|
||||
assert call_args.kwargs["sqlfilters"] == "(t.ref:like:'PRJ%')"
|
||||
|
||||
# Verify result
|
||||
assert "PRJ-123" in result[0].text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_product_ref_exact():
|
||||
with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient:
|
||||
mock_instance = MockClient.return_value
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
|
||||
# Mock search_products response (exact match)
|
||||
mock_instance.search_products = AsyncMock(return_value=[
|
||||
{"id": 1, "ref": "PRJ-123", "label": "Project 123"}
|
||||
])
|
||||
|
||||
result = await handle_call_tool("resolve_product_ref", {"ref": "PRJ-123"})
|
||||
|
||||
mock_instance.search_products.assert_called_once()
|
||||
assert "ok" in result[0].text
|
||||
assert "PRJ-123" in result[0].text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_product_ref_ambiguous():
|
||||
with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient:
|
||||
mock_instance = MockClient.return_value
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
|
||||
# Mock search_products response (multiple matches, none exact)
|
||||
mock_instance.search_products = AsyncMock(return_value=[
|
||||
{"id": 1, "ref": "PRJ-123-A", "label": "Project 123 A"},
|
||||
{"id": 2, "ref": "PRJ-123-B", "label": "Project 123 B"}
|
||||
])
|
||||
|
||||
# Search for "PRJ-123" which matches both partially (hypothetically) but neither exactly
|
||||
result = await handle_call_tool("resolve_product_ref", {"ref": "PRJ-123"})
|
||||
|
||||
assert "ambiguous" in result[0].text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_customers():
|
||||
with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient:
|
||||
mock_instance = MockClient.return_value
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
|
||||
mock_instance.search_customers = AsyncMock(return_value=[
|
||||
{"id": 1, "nom": "Acme Corp"}
|
||||
])
|
||||
|
||||
result = await handle_call_tool("search_customers", {"query": "Acme"})
|
||||
|
||||
mock_instance.search_customers.assert_called_once()
|
||||
call_args = mock_instance.search_customers.call_args
|
||||
assert "sqlfilters" in call_args.kwargs
|
||||
assert "Acme" in call_args.kwargs["sqlfilters"]
|
||||
Reference in New Issue
Block a user