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>
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""
|
|
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
|