diff --git a/api/app/services/anaf_service.py b/api/app/services/anaf_service.py index 02f296a..d584b5d 100644 --- a/api/app/services/anaf_service.py +++ b/api/app/services/anaf_service.py @@ -73,7 +73,7 @@ def sanitize_cui(raw_cf: str) -> tuple[str, str | None]: return bare, f"CUI {raw_cf!r} contine caractere invalide dupa sanitizare: {bare!r}" -async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[str, dict]: +async def check_vat_status_batch(cui_list: list[str], date: str = None, log_fn=None) -> dict[str, dict]: """POST to ANAF API to check VAT status for a batch of CUIs. Chunks in batches of 500 (ANAF API limit). @@ -91,35 +91,49 @@ async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[ if not body: continue - chunk_results = await _call_anaf_api(body) + chunk_results = await _call_anaf_api(body, log_fn=log_fn) results.update(chunk_results) return results -async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]: +async def _call_anaf_api(body: list[dict], retry: int = 0, log_fn=None) -> dict[str, dict]: """Internal: single ANAF API call with retry logic.""" url = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva" results = {} + def _log_error(msg: str): + logger.error(msg) + if log_fn: + log_fn(f"ANAF eroare: {msg}") + + def _log_warning(msg: str): + logger.warning(msg) + if log_fn: + log_fn(f"ANAF warn: {msg}") + try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=body) if response.status_code == 429: if retry < 1: - logger.warning("ANAF API rate limited (429), retrying in 10s...") + _log_warning("ANAF API rate limited (429), retrying in 10s...") await asyncio.sleep(10) - return await _call_anaf_api(body, retry + 1) - logger.error("ANAF API rate limited after retry") + return await _call_anaf_api(body, retry + 1, log_fn) + _log_error("ANAF API rate limited after retry") return {} if response.status_code >= 500: if retry < 1: - logger.warning(f"ANAF API server error ({response.status_code}), retrying in 3s...") + _log_warning(f"ANAF API server error ({response.status_code}), retrying in 3s...") await asyncio.sleep(3) - return await _call_anaf_api(body, retry + 1) - logger.error(f"ANAF API server error after retry: {response.status_code}") + return await _call_anaf_api(body, retry + 1, log_fn) + _log_error(f"ANAF API server error after retry: {response.status_code}") + return {} + + if 400 <= response.status_code < 500: + _log_error(f"ANAF API client error {response.status_code} (nu se reincearca)") return {} response.raise_for_status() @@ -138,11 +152,14 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]: "checked_at": checked_at, } - # Not found CUIs + # Not found CUIs — ANAF returns plain integers (CUI values), not dicts notfound_list = data.get("notFound", []) for item in notfound_list: - date_gen = item.get("date_generale", {}) - cui_str = str(date_gen.get("cui", item.get("cui", ""))) + if isinstance(item, int): + cui_str = str(item) + else: + date_gen = item.get("date_generale", {}) + cui_str = str(date_gen.get("cui", item.get("cui", ""))) results[cui_str] = { "scpTVA": None, "denumire_anaf": "", @@ -153,16 +170,16 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]: except httpx.TimeoutException: if retry < 1: - logger.warning("ANAF API timeout, retrying in 3s...") + _log_warning("ANAF API timeout, retrying in 3s...") await asyncio.sleep(3) - return await _call_anaf_api(body, retry + 1) - logger.error("ANAF API timeout after retry") + return await _call_anaf_api(body, retry + 1, log_fn) + _log_error("ANAF API timeout after retry") except Exception as e: if retry < 1: - logger.warning(f"ANAF API error: {e}, retrying in 3s...") + _log_warning(f"ANAF API error: {e}, retrying in 3s...") await asyncio.sleep(3) - return await _call_anaf_api(body, retry + 1) - logger.error(f"ANAF API error after retry: {e}") + return await _call_anaf_api(body, retry + 1, log_fn) + _log_error(f"ANAF API error after retry: {e}") return results diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index e5f6f65..57bc739 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -683,7 +683,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None prepop_cuis = await sqlite_service.get_expired_cuis_for_prepopulate() if prepop_cuis: _log_line(run_id, f"ANAF pre-populare: {len(prepop_cuis)} CUI-uri cu cache expirat") - prepop_results = await anaf_service.check_vat_status_batch(prepop_cuis) + prepop_results = await anaf_service.check_vat_status_batch( + prepop_cuis, log_fn=lambda msg: _log_line(run_id, msg) + ) if prepop_results: await sqlite_service.bulk_populate_anaf_cache(prepop_results) _log_line(run_id, f"ANAF pre-populare: {len(prepop_results)} rezultate stocate") @@ -716,7 +718,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None # Batch ANAF call for uncached CUIs only if uncached_cuis: _log_line(run_id, f"ANAF: verificare {len(uncached_cuis)} CUI-uri noi...") - anaf_results = await anaf_service.check_vat_status_batch(uncached_cuis) + anaf_results = await anaf_service.check_vat_status_batch( + uncached_cuis, log_fn=lambda msg: _log_line(run_id, msg) + ) if anaf_results: await sqlite_service.bulk_populate_anaf_cache(anaf_results) cached_results.update(anaf_results) diff --git a/api/tests/test_cui_validation.py b/api/tests/test_cui_validation.py index 014d61f..6f21af8 100644 --- a/api/tests/test_cui_validation.py +++ b/api/tests/test_cui_validation.py @@ -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