Merge pull request #16 from latinogino/codex/add-product-and-project-creation-validation

Add validation builders for product and project creation
This commit is contained in:
latinogino
2026-01-02 18:02:47 +01:00
committed by GitHub
2 changed files with 104 additions and 3 deletions

View File

@@ -135,6 +135,12 @@ class DolibarrClient:
"""Create a unique correlation identifier."""
return str(uuid4())
def _generate_reference(self) -> str:
"""Generate a unique reference using prefix, timestamp, and a UUID suffix."""
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
suffix = uuid4().hex[:8]
return f"{self.ref_autogen_prefix}_{timestamp}_{suffix}"
def _build_validation_error(
self,
endpoint: str,
@@ -182,11 +188,15 @@ class DolibarrClient:
aliases: Optional[Dict[str, List[str]]] = None,
numeric_positive: Optional[List[str]] = None,
enum_fields: Optional[Dict[str, List[Any]]] = None,
required_any_of: Optional[List[List[str]]] = None,
non_empty_fields: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Validate payload before sending to Dolibarr and optionally auto-generate refs."""
aliases = aliases or {}
numeric_positive = numeric_positive or []
enum_fields = enum_fields or {}
required_any_of = required_any_of or []
non_empty_fields = non_empty_fields or []
self._apply_aliases(payload, aliases)
@@ -198,6 +208,14 @@ class DolibarrClient:
invalid_fields: List[Dict[str, str]] = []
for group in required_any_of:
if all(payload.get(field) in (None, "") for field in group):
missing_fields.append(" or ".join(group))
for field in non_empty_fields:
if field in payload and payload[field] in (None, "") and field not in missing_fields:
missing_fields.append(field)
for field in numeric_positive:
if field in payload and isinstance(payload[field], (int, float)) and payload[field] < 0:
invalid_fields.append({"field": field, "message": "must be a positive number"})
@@ -207,14 +225,24 @@ class DolibarrClient:
invalid_fields.append({"field": field, "message": f"must be one of {values}"})
if "ref" in missing_fields and self.allow_ref_autogen:
payload["ref"] = f"{self.ref_autogen_prefix}_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
payload["ref"] = self._generate_reference()
missing_fields = [f for f in missing_fields if f != "ref"]
if missing_fields or invalid_fields:
details: List[str] = []
if missing_fields:
details.append(f"missing: {', '.join(missing_fields)}")
if invalid_fields:
details.append(
"invalid: "
+ ", ".join(f["field"] for f in invalid_fields)
)
message = "Validation failed" + (f" ({'; '.join(details)})" if details else "")
error_data = self._build_validation_error(
endpoint=endpoint,
missing_fields=missing_fields,
invalid_fields=invalid_fields,
message=message,
)
raise DolibarrValidationError(
message=error_data["message"],
@@ -561,10 +589,12 @@ class DolibarrClient:
payload = self._validate_payload(
endpoint="products",
payload=payload,
required_fields=["ref", "label", "type", "price"],
required_fields=["ref", "label", "type"],
aliases={"label": ["name"]},
numeric_positive=["price"],
numeric_positive=["price", "price_ttc"],
enum_fields={"type": ["product", "service", 0, 1]},
required_any_of=[["price", "price_ttc"]],
non_empty_fields=["price", "price_ttc", "tva_tx"],
)
result = await self.request("POST", "products", data=payload)
return self._extract_identifier(result)
@@ -791,6 +821,7 @@ class DolibarrClient:
payload=payload,
required_fields=["ref", "name", "socid"],
aliases={"name": ["title"]},
non_empty_fields=["socid"],
)
result = await self.request("POST", "projects", data=payload)
return self._extract_identifier(result)

View File

@@ -149,6 +149,76 @@ class TestDolibarrClient:
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):