""" CUI Validation Tests ==================== Tests for Romanian CUI sanitization, checksum validation, and OCR typo correction. Run: cd api && python -m pytest tests/test_cui_validation.py -v """ import os import sys import tempfile import pytest pytestmark = pytest.mark.unit # --- Set env vars BEFORE any app import --- _tmpdir = tempfile.mkdtemp() os.environ["FORCE_THIN_MODE"] = "true" os.environ["SQLITE_DB_PATH"] = os.path.join(_tmpdir, "test_cui.db") os.environ["ORACLE_DSN"] = "dummy" os.environ["ORACLE_USER"] = "dummy" os.environ["ORACLE_PASSWORD"] = "dummy" os.environ["JSON_OUTPUT_DIR"] = _tmpdir _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, ) # =========================================================================== # strip_ro_prefix # =========================================================================== class TestStripRoPrefix: def test_basic_ro_prefix(self): assert strip_ro_prefix("RO15134434") == "15134434" def test_ro_with_space(self): assert strip_ro_prefix("RO 15134434") == "15134434" def test_lowercase_ro(self): assert strip_ro_prefix("ro15134434") == "15134434" def test_no_prefix(self): assert strip_ro_prefix("15134434") == "15134434" def test_whitespace(self): assert strip_ro_prefix(" RO15134434 ") == "15134434" def test_empty(self): assert strip_ro_prefix("") == "" def test_none(self): assert strip_ro_prefix(None) == "" def test_ocr_fix_O_to_0(self): """Letter O in CUI should be converted to digit 0.""" assert strip_ro_prefix("49O33O51") == "49033051" def test_ocr_fix_I_to_1(self): """Letter I in CUI should be converted to digit 1.""" assert strip_ro_prefix("I5134434") == "15134434" def test_ocr_fix_L_to_1(self): """Letter L in CUI should be converted to digit 1.""" assert strip_ro_prefix("L5134434") == "15134434" def test_ocr_fix_combined_with_ro(self): """RO prefix removed first, then OCR fix on remaining.""" assert strip_ro_prefix("RO49O33O51") == "49033051" def test_ro_prefix_not_affected_by_ocr(self): """The 'RO' prefix is removed before OCR translation.""" assert strip_ro_prefix("Ro 50519951") == "50519951" # =========================================================================== # validate_cui # =========================================================================== class TestValidateCui: def test_valid_short(self): assert validate_cui("12") is True def test_valid_10_digits(self): assert validate_cui("1234567890") is True def test_too_short(self): assert validate_cui("1") is False def test_too_long(self): assert validate_cui("12345678901") is False def test_non_digits(self): assert validate_cui("49O33O51") is False def test_empty(self): assert validate_cui("") is False def test_none(self): assert validate_cui(None) is False # =========================================================================== # validate_cui_checksum # =========================================================================== class TestValidateCuiChecksum: """Test Romanian CUI check digit algorithm (key 753217532).""" @pytest.mark.parametrize("cui,name", [ ("49033051", "MATTEO&OANA CAFFE 2022 SRL"), ("15134434", "AUTOKLASS CENTER SRL"), ("44741316", "OLLY'S HOUSE IECEA MARE SRL"), ("45484539", "S OFFICE VENDING SRL"), ("8722253", "VENUS ALIMCOM SRL"), ("3738836", "AUSTRAL TRADE SRL"), ("37567030", "CONVER URBAN SRL"), ("45350367", "TURCHI GARAGE SRL"), ("3601803", "known company"), ("18189442", "known company"), ("45093662", "CARTON PREMIUM SRL"), ("50519951", "SERCO CAFFE COMPANY"), ]) def test_valid_cuis(self, cui, name): assert validate_cui_checksum(cui) is True, f"CUI {cui} ({name}) should pass checksum" @pytest.mark.parametrize("cui", [ "49033052", # last digit wrong (should be 1) "15134435", # last digit wrong "44741310", # last digit wrong ]) def test_invalid_checksum(self, cui): assert validate_cui_checksum(cui) is False def test_invalid_format_rejected(self): assert validate_cui_checksum("ABC") is False assert validate_cui_checksum("") is False assert validate_cui_checksum("1") is False def test_checksum_result_10_becomes_0(self): """When (sum*10)%11 == 10, check digit should be 0. CUI 14186770: body=1418677, padded=001418677, sum=0+0+3+8+1+42+35+21+14=124, 1240%11=10 → check=0. """ assert validate_cui_checksum("14186770") is True # Wrong check digit for same body assert validate_cui_checksum("14186771") is False # =========================================================================== # sanitize_cui # =========================================================================== class TestSanitizeCui: def test_clean_cui_no_warning(self): bare, warning = sanitize_cui("RO15134434") assert bare == "15134434" assert warning is None def test_ocr_typo_fixed_no_warning(self): """Letter O→0 fix results in valid checksum, no warning.""" bare, warning = sanitize_cui("49O33O51") assert bare == "49033051" assert warning is None def test_ocr_typo_with_ro_prefix(self): bare, warning = sanitize_cui("RO49O33O51") assert bare == "49033051" assert warning is None def test_valid_format_bad_checksum_warns(self): bare, warning = sanitize_cui("49033052") # wrong check digit assert bare == "49033052" assert warning is not None assert "nu trece verificarea" in warning def test_invalid_format_warns(self): bare, warning = sanitize_cui("ABCDEF") assert warning is not None assert "caractere invalide" in warning def test_empty_no_warning(self): bare, warning = sanitize_cui("") assert bare == "" assert warning is None def test_bare_cui_no_prefix(self): bare, warning = sanitize_cui("45484539") assert bare == "45484539" assert warning is None def test_with_spaces(self): bare, warning = sanitize_cui(" RO 8722253 ") assert bare == "8722253" assert warning is None def test_ro_space_format(self): """CUI like 'Ro 50519951' from real GoMag data.""" 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