We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/marioser/dolibarr-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""Tests for Dolibarr client functionality."""
import gzip
import pytest
from unittest.mock import AsyncMock, patch
from dolibarr_mcp.config import Config
from dolibarr_mcp.dolibarr_client import DolibarrClient, DolibarrAPIError, DolibarrValidationError
class TestDolibarrClient:
"""Test cases for the DolibarrClient."""
def test_config_validation(self):
"""Test configuration validation."""
# Test valid configuration
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
assert config.dolibarr_url == "https://test.dolibarr.com/api/index.php"
assert config.api_key == "test_key"
# Test validation
config.validate_config() # Should not raise
# Test invalid URL
with pytest.raises(ValueError, match="must start with http"):
invalid_config = Config(
dolibarr_url="invalid-url",
api_key="test_key"
)
invalid_config.validate_config()
@pytest.mark.asyncio
async def test_client_session_management(self):
"""Test client session management."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
client = DolibarrClient(config)
# Test session starts as None
assert client.session is None
# Test session creation
await client.start_session()
assert client.session is not None
# Test session cleanup
await client.close_session()
assert client.session is None
@pytest.mark.asyncio
async def test_context_manager(self):
"""Test async context manager functionality."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
async with DolibarrClient(config) as client:
assert client.session is not None
# Session should be closed after context exit
assert client.session is None
@pytest.mark.asyncio
@patch('aiohttp.ClientSession.request')
async def test_successful_api_request(self, mock_request):
"""Test successful API request."""
# Mock response
mock_response = AsyncMock()
mock_response.status = 200
mock_response.text.return_value = '{"success": {"code": 200, "dolibarr_version": "21.0.1"}}'
mock_request.return_value.__aenter__.return_value = mock_response
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
async with DolibarrClient(config) as client:
result = await client.get_status()
assert "success" in result
assert result["success"]["code"] == 200
assert result["success"]["dolibarr_version"] == "21.0.1"
@pytest.mark.asyncio
@patch('aiohttp.ClientSession.request')
async def test_api_error_handling(self, mock_request):
"""Test API error handling."""
# Mock error response
mock_response = AsyncMock()
mock_response.status = 404
mock_response.reason = "Not Found"
mock_response.text.return_value = '{"error": "Object not found"}'
mock_request.return_value.__aenter__.return_value = mock_response
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
async with DolibarrClient(config) as client:
with pytest.raises(DolibarrAPIError) as exc_info:
await client.get_customer_by_id(999)
assert exc_info.value.status_code == 404
assert "Object not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_validation_error_on_missing_ref(self):
"""Ensure client-side validation catches missing product ref."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key",
allow_ref_autogen=False,
)
client = DolibarrClient(config)
client.request = AsyncMock(return_value={"id": 1}) # Should not be called
with pytest.raises(DolibarrValidationError) as exc_info:
await client.create_product({"label": "No Ref", "type": "service", "price": 12.5})
assert exc_info.value.response_data["missing_fields"] == ["ref"]
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_autogen_ref_when_enabled(self):
"""Auto-generate refs when allowed by configuration."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key",
allow_ref_autogen=True,
ref_autogen_prefix="AUTOREF",
)
client = DolibarrClient(config)
client.request = AsyncMock(return_value={"id": 42, "ref": "AUTOREF_123"})
await client.create_product({"label": "Generated Ref Product", "type": "service", "price": 10})
assert client.request.await_count == 1
sent_payload = client.request.call_args.kwargs["data"]
assert "ref" in sent_payload
assert sent_payload["ref"].startswith("AUTOREF_")
@pytest.mark.asyncio
async def test_product_validation_requires_price_field(self):
"""Ensure products require at least one pricing field."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key",
)
client = DolibarrClient(config)
client.request = AsyncMock(return_value={"id": 99}) # Should not be called
with pytest.raises(DolibarrValidationError) as exc_info:
await client.create_product({"ref": "SKU-1", "label": "Widget", "type": "product"})
assert exc_info.value.response_data["missing_fields"] == ["price or price_ttc"]
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_product_validation_allows_price_ttc(self):
"""Validate products using price_ttc instead of price."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key",
)
client = DolibarrClient(config)
client.request = AsyncMock(return_value={"id": 101})
await client.create_product({"ref": "SKU-2", "label": "Widget TTC", "type": "product", "price_ttc": 12.5})
sent_payload = client.request.call_args.kwargs["data"]
assert sent_payload["price_ttc"] == 12.5
assert "price" not in sent_payload
@pytest.mark.asyncio
async def test_project_validation_missing_socid(self):
"""Ensure projects require ref and socid (name/title also required)."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key",
)
client = DolibarrClient(config)
client.request = AsyncMock(return_value={"id": 33}) # Should not be called
with pytest.raises(DolibarrValidationError) as exc_info:
await client.create_project({"ref": "PRJ-1", "title": "Missing Soc"})
assert "socid" in exc_info.value.response_data["missing_fields"]
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_project_autogen_ref_when_enabled(self):
"""Auto-generate project references when allowed and missing."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key",
allow_ref_autogen=True,
ref_autogen_prefix="PROJ",
)
client = DolibarrClient(config)
client.request = AsyncMock(return_value={"id": 77})
await client.create_project({"title": "Generated Ref Project", "socid": 5})
sent_payload = client.request.call_args.kwargs["data"]
assert "ref" in sent_payload
assert sent_payload["ref"].startswith("PROJ_")
@pytest.mark.asyncio
@patch('aiohttp.ClientSession.request')
async def test_internal_error_correlation_id(self, mock_request):
"""Include correlation IDs for unexpected server errors."""
mock_response = AsyncMock()
mock_response.status = 500
mock_response.reason = "Internal Server Error"
mock_response.text.return_value = '{"message": "Database unavailable"}'
mock_request.return_value.__aenter__.return_value = mock_response
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
async with DolibarrClient(config) as client:
with pytest.raises(DolibarrAPIError) as exc_info:
await client.get_project_by_id(1)
assert exc_info.value.status_code == 500
assert "correlation_id" in exc_info.value.response_data
@pytest.mark.asyncio
@patch('aiohttp.ClientSession.request')
async def test_request_parses_gzip_json_response(self, mock_request):
"""Ensure gzip-compressed JSON is decoded before parsing."""
mock_response = AsyncMock()
mock_response.status = 200
mock_response.headers = {"Content-Encoding": "gzip"}
mock_response.charset = "utf-8"
mock_response.url = "https://test.dolibarr.com/api/index.php/proposals"
mock_response.read.return_value = gzip.compress(b'{"id": 987}')
mock_request.return_value.__aenter__.return_value = mock_response
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
async with DolibarrClient(config) as client:
result = await client.request("POST", "proposals", data={"socid": 542})
assert result["id"] == 987
@pytest.mark.asyncio
@patch('aiohttp.ClientSession.request')
async def test_request_parses_gzip_magic_without_header(self, mock_request):
"""Handle servers that send gzip bytes without Content-Encoding header."""
mock_response = AsyncMock()
mock_response.status = 200
mock_response.headers = {}
mock_response.charset = "utf-8"
mock_response.url = "https://test.dolibarr.com/api/index.php/proposals"
mock_response.read.return_value = gzip.compress(b'{"ok": true}')
mock_request.return_value.__aenter__.return_value = mock_response
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
async with DolibarrClient(config) as client:
result = await client.request("POST", "proposals", data={"socid": 542})
assert result["ok"] is True
def test_url_building(self):
"""Test URL building functionality."""
config = Config(
dolibarr_url="https://test.dolibarr.com/api/index.php",
api_key="test_key"
)
client = DolibarrClient(config)
# Test with leading slash
url = client._build_url("/users")
assert url == "https://test.dolibarr.com/api/index.php/users"
# Test without leading slash
url = client._build_url("users")
assert url == "https://test.dolibarr.com/api/index.php/users"
# Test with trailing slash in base URL
client.base_url = "https://test.dolibarr.com/api/index.php/"
url = client._build_url("users")
assert url == "https://test.dolibarr.com/api/index.php/users"
class TestDolibarrAPIError:
"""Test cases for DolibarrAPIError."""
def test_error_creation(self):
"""Test error object creation."""
error = DolibarrAPIError(
message="Test error",
status_code=400,
response_data={"error": "Bad request"}
)
assert str(error) == "Test error"
assert error.status_code == 400
assert error.response_data == {"error": "Bad request"}
def test_error_without_optional_params(self):
"""Test error creation without optional parameters."""
error = DolibarrAPIError("Simple error")
assert str(error) == "Simple error"
assert error.status_code is None
assert error.response_data is None
# Example of how to add integration tests
@pytest.mark.integration
class TestDolibarrIntegration:
"""Integration tests (require real Dolibarr instance)."""
@pytest.mark.asyncio
async def test_real_connection(self):
"""Test connection to real Dolibarr instance."""
# Skip if no real credentials available
try:
config = Config.from_env()
config.validate_config()
except ValueError:
pytest.skip("No valid Dolibarr configuration available")
async with DolibarrClient(config) as client:
try:
result = await client.get_status()
assert "success" in result or "dolibarr_version" in str(result)
except DolibarrAPIError as e:
pytest.fail(f"API connection failed: {e}")
if __name__ == "__main__":
pytest.main([__file__])