fix(anaf): handle notFound integers, skip 4xx retry, propagate errors to run log

ANAF notFound items are plain integers, not dicts — caused 'int has no
attribute get'. 4xx errors (like 404) no longer retry uselessly. ANAF
errors now appear in the UI sync log via log_fn callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-07 12:47:25 +00:00
parent ecde7fe440
commit 5b4b317636
3 changed files with 220 additions and 20 deletions

View File

@@ -28,11 +28,15 @@ _api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _api_dir not in sys.path:
sys.path.insert(0, _api_dir)
from unittest.mock import AsyncMock, patch, MagicMock
from app.services.anaf_service import (
strip_ro_prefix,
validate_cui,
validate_cui_checksum,
sanitize_cui,
_call_anaf_api,
check_vat_status_batch,
)
@@ -210,3 +214,178 @@ class TestSanitizeCui:
bare, warning = sanitize_cui("Ro 50519951")
assert bare == "50519951"
assert warning is None
# ===========================================================================
# _call_anaf_api — notFound parsing + error handling
# ===========================================================================
class TestCallAnafApi:
"""Tests for ANAF API response parsing and error handling."""
@pytest.mark.asyncio
async def test_notfound_as_integers(self):
"""ANAF notFound items are plain integers (CUI values), not dicts."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"found": [],
"notFound": [12345678, 87654321],
}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
results = await _call_anaf_api([{"cui": 12345678, "data": "2026-04-07"}])
assert "12345678" in results
assert "87654321" in results
assert results["12345678"]["scpTVA"] is None
assert results["87654321"]["scpTVA"] is None
@pytest.mark.asyncio
async def test_notfound_as_dicts_still_works(self):
"""Backward compat: if ANAF ever returns notFound as dicts, still parse them."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"found": [],
"notFound": [{"date_generale": {"cui": 99999999}}],
}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
results = await _call_anaf_api([{"cui": 99999999, "data": "2026-04-07"}])
assert "99999999" in results
assert results["99999999"]["scpTVA"] is None
@pytest.mark.asyncio
async def test_found_items_parsed(self):
"""Normal found items are parsed correctly."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"found": [{
"date_generale": {"cui": 15134434, "denumire": "AUTOKLASS CENTER SRL"},
"inregistrare_scop_Tva": {"scpTVA": True},
}],
"notFound": [],
}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
results = await _call_anaf_api([{"cui": 15134434, "data": "2026-04-07"}])
assert results["15134434"]["scpTVA"] is True
assert results["15134434"]["denumire_anaf"] == "AUTOKLASS CENTER SRL"
@pytest.mark.asyncio
async def test_4xx_error_no_retry(self):
"""4xx client errors (like 404) should not retry."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
log_messages = []
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
results = await _call_anaf_api(
[{"cui": 12345678, "data": "2026-04-07"}],
log_fn=lambda msg: log_messages.append(msg),
)
assert results == {}
# Should only call once (no retry for 4xx)
assert mock_client.post.call_count == 1
assert any("404" in msg for msg in log_messages)
@pytest.mark.asyncio
async def test_log_fn_receives_errors(self):
"""log_fn callback receives error messages for UI display."""
mock_response = MagicMock()
mock_response.status_code = 500
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
log_messages = []
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
with patch("asyncio.sleep", new_callable=AsyncMock):
results = await _call_anaf_api(
[{"cui": 12345678, "data": "2026-04-07"}],
log_fn=lambda msg: log_messages.append(msg),
)
assert results == {}
assert len(log_messages) >= 1
class TestCheckVatStatusBatch:
"""Tests for check_vat_status_batch with log_fn propagation."""
@pytest.mark.asyncio
async def test_log_fn_passed_through(self):
"""log_fn is forwarded from check_vat_status_batch to _call_anaf_api."""
log_messages = []
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"found": [], "notFound": [12345678]}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
results = await check_vat_status_batch(
["12345678"], log_fn=lambda msg: log_messages.append(msg),
)
assert "12345678" in results
@pytest.mark.asyncio
async def test_empty_list_returns_empty(self):
assert await check_vat_status_batch([]) == {}
@pytest.mark.asyncio
async def test_non_digit_cuis_filtered(self):
"""CUIs that aren't pure digits are filtered out before API call."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"found": [], "notFound": []}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
results = await check_vat_status_batch(["ABC", "12345678"])
# Only the digit CUI should be in the body
call_body = mock_client.post.call_args[1]["json"]
assert len(call_body) == 1
assert call_body[0]["cui"] == 12345678