""" Business Rule Regression Tests ============================== Regression tests for historical bug fixes in kit pricing, discount calculation, duplicate CODMAT resolution, price sync, and VAT normalization. Run: cd api && python -m pytest tests/test_business_rules.py -v """ import json import os import sys import tempfile import pytest pytestmark = pytest.mark.unit # --- Set env vars BEFORE any app import --- _tmpdir = tempfile.mkdtemp() _sqlite_path = os.path.join(_tmpdir, "test_biz.db") os.environ["FORCE_THIN_MODE"] = "true" os.environ["SQLITE_DB_PATH"] = _sqlite_path 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 MagicMock, patch from app.services.import_service import build_articles_json, compute_discount_split from app.services.order_reader import OrderData, OrderItem # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_item(sku="SKU1", price=100.0, quantity=1, vat=19): return OrderItem(sku=sku, name=f"Product {sku}", price=price, quantity=quantity, vat=vat) def make_order(items, discount_total=0.0, delivery_cost=0.0, discount_vat=None): order = OrderData( id="1", number="TEST-001", date="2026-01-01", items=items, discount_total=discount_total, delivery_cost=delivery_cost, ) if discount_vat is not None: order.discount_vat = discount_vat return order def is_kit(comps): """Kit detection pattern used in validation_service and price_sync_service.""" return len(comps) > 1 or ( len(comps) == 1 and (comps[0].get("cantitate_roa") or 1) != 1 ) # =========================================================================== # Group 1: compute_discount_split() # =========================================================================== class TestDiscountSplit: """Regression: discount split by VAT rate (import_service.py:63).""" def test_single_vat_rate(self): order = make_order([make_item(vat=19), make_item("SKU2", vat=19)], discount_total=10.0) result = compute_discount_split(order, {"split_discount_vat": "1"}) assert result == {"19": 10.0} def test_multiple_vat_proportional(self): items = [make_item("A", price=100, quantity=1, vat=19), make_item("B", price=50, quantity=1, vat=9)] order = make_order(items, discount_total=15.0) result = compute_discount_split(order, {"split_discount_vat": "1"}) assert result == {"9": 5.0, "19": 10.0} def test_zero_returns_none(self): order = make_order([make_item()], discount_total=0) assert compute_discount_split(order, {"split_discount_vat": "1"}) is None def test_zero_price_items_excluded(self): items = [make_item("A", price=0, quantity=1, vat=19), make_item("B", price=100, quantity=2, vat=9)] order = make_order(items, discount_total=5.0) result = compute_discount_split(order, {"split_discount_vat": "1"}) assert result == {"9": 5.0} def test_disabled_multiple_rates(self): items = [make_item("A", vat=19), make_item("B", vat=9)] order = make_order(items, discount_total=10.0) result = compute_discount_split(order, {"split_discount_vat": "0"}) assert result is None def test_rounding_remainder(self): items = [make_item("A", price=33.33, quantity=1, vat=19), make_item("B", price=33.33, quantity=1, vat=9), make_item("C", price=33.34, quantity=1, vat=5)] order = make_order(items, discount_total=10.0) result = compute_discount_split(order, {"split_discount_vat": "1"}) assert result is not None assert abs(sum(result.values()) - 10.0) < 0.001 # =========================================================================== # Group 2: build_articles_json() # =========================================================================== class TestBuildArticlesJson: """Regression: discount lines, policy bridge, transport (import_service.py:117).""" def test_discount_line_negative_quantity(self): items = [make_item()] order = make_order(items, discount_total=5.0) settings = {"discount_codmat": "DISC01", "split_discount_vat": "0"} result = json.loads(build_articles_json(items, order, settings)) disc_lines = [a for a in result if a["sku"] == "DISC01"] assert len(disc_lines) == 1 assert disc_lines[0]["quantity"] == "-1" assert disc_lines[0]["price"] == "5.0" def test_discount_uses_actual_vat_not_21(self): items = [make_item("A", vat=9), make_item("B", vat=9)] order = make_order(items, discount_total=3.0) settings = {"discount_codmat": "DISC01", "split_discount_vat": "1"} result = json.loads(build_articles_json(items, order, settings)) disc_lines = [a for a in result if a["sku"] == "DISC01"] assert len(disc_lines) == 1 assert disc_lines[0]["vat"] == "9" def test_discount_multi_vat_creates_multiple_lines(self): items = [make_item("A", price=100, vat=19), make_item("B", price=50, vat=9)] order = make_order(items, discount_total=15.0) settings = {"discount_codmat": "DISC01", "split_discount_vat": "1"} result = json.loads(build_articles_json(items, order, settings)) disc_lines = [a for a in result if a["sku"] == "DISC01"] assert len(disc_lines) == 2 vats = {d["vat"] for d in disc_lines} assert "9" in vats assert "19" in vats def test_discount_fallback_uses_gomag_vat(self): items = [make_item("A", vat=19), make_item("B", vat=9)] order = make_order(items, discount_total=5.0, discount_vat="9") settings = {"discount_codmat": "DISC01", "split_discount_vat": "0"} result = json.loads(build_articles_json(items, order, settings)) disc_lines = [a for a in result if a["sku"] == "DISC01"] assert len(disc_lines) == 1 assert disc_lines[0]["vat"] == "9" def test_per_article_policy_bridge(self): items = [make_item("SKU1")] settings = {"_codmat_policy_map": {"SKU1": 42}, "id_pol": "1"} result = json.loads(build_articles_json(items, settings=settings)) assert result[0]["id_pol"] == "42" def test_policy_same_as_default_omitted(self): items = [make_item("SKU1")] settings = {"_codmat_policy_map": {"SKU1": 1}, "id_pol": "1"} result = json.loads(build_articles_json(items, settings=settings)) assert "id_pol" not in result[0] def test_transport_line_added(self): items = [make_item()] order = make_order(items, delivery_cost=15.0) settings = {"transport_codmat": "TR", "transport_vat": "19"} result = json.loads(build_articles_json(items, order, settings)) tr_lines = [a for a in result if a["sku"] == "TR"] assert len(tr_lines) == 1 assert tr_lines[0]["quantity"] == "1" assert tr_lines[0]["price"] == "15.0" # =========================================================================== # Group 3: Kit Detection Pattern # =========================================================================== class TestKitDetection: """Regression: kit detection for single-component repackaging (multiple code locations).""" def test_multi_component(self): comps = [{"codmat": "A", "cantitate_roa": 1}, {"codmat": "B", "cantitate_roa": 1}] assert is_kit(comps) is True def test_single_component_repackaging(self): comps = [{"codmat": "CAF01", "cantitate_roa": 10}] assert is_kit(comps) is True def test_true_1to1_not_kit(self): comps = [{"codmat": "X", "cantitate_roa": 1}] assert is_kit(comps) is False def test_none_cantitate_treated_as_1(self): comps = [{"codmat": "X", "cantitate_roa": None}] assert is_kit(comps) is False def test_empty_components(self): assert is_kit([]) is False # =========================================================================== # Group 4: sync_prices_from_order() — Kit Skip Logic # =========================================================================== class TestSyncPricesKitSkip: """Regression: kit SKUs must be skipped in order-based price sync.""" def _make_mock_order(self, sku, price=50.0): mock_order = MagicMock() mock_item = MagicMock() mock_item.sku = sku mock_item.price = price mock_order.items = [mock_item] return mock_order @patch("app.services.validation_service.compare_and_update_price") def test_skips_multi_component_kit(self, mock_compare): from app.services.validation_service import sync_prices_from_order orders = [self._make_mock_order("KIT01")] mapped = {"KIT01": [ {"codmat": "A", "id_articol": 1, "cantitate_roa": 1}, {"codmat": "B", "id_articol": 2, "cantitate_roa": 1}, ]} mock_conn = MagicMock() sync_prices_from_order(orders, mapped, {}, {}, 1, conn=mock_conn, settings={"price_sync_enabled": "1"}) mock_compare.assert_not_called() @patch("app.services.validation_service.compare_and_update_price") def test_skips_repackaging_kit(self, mock_compare): from app.services.validation_service import sync_prices_from_order orders = [self._make_mock_order("CAFE100")] mapped = {"CAFE100": [ {"codmat": "CAF01", "id_articol": 1, "cantitate_roa": 10}, ]} mock_conn = MagicMock() sync_prices_from_order(orders, mapped, {}, {}, 1, conn=mock_conn, settings={"price_sync_enabled": "1"}) mock_compare.assert_not_called() @patch("app.services.validation_service.compare_and_update_price") def test_processes_1to1_mapping(self, mock_compare): from app.services.validation_service import sync_prices_from_order mock_compare.return_value = {"updated": False, "old_price": 50.0, "new_price": 50.0, "codmat": "X"} orders = [self._make_mock_order("SKU1", price=50.0)] mapped = {"SKU1": [ {"codmat": "X", "id_articol": 100, "cantitate_roa": 1, "cont": "371"}, ]} mock_conn = MagicMock() sync_prices_from_order(orders, mapped, {}, {"SKU1": 1}, 1, conn=mock_conn, settings={"price_sync_enabled": "1"}) mock_compare.assert_called_once() call_args = mock_compare.call_args assert call_args[0][0] == 100 # id_articol assert call_args[0][2] == 50.0 # price @patch("app.services.validation_service.compare_and_update_price") def test_skips_transport_discount_codmats(self, mock_compare): from app.services.validation_service import sync_prices_from_order orders = [self._make_mock_order("TRANSP", price=15.0)] mock_conn = MagicMock() sync_prices_from_order(orders, {}, {"TRANSP": {"id_articol": 99}}, {}, 1, conn=mock_conn, settings={"price_sync_enabled": "1", "transport_codmat": "TRANSP", "discount_codmat": "DISC"}) mock_compare.assert_not_called() # =========================================================================== # Group 5: Kit Component with Own Mapping # =========================================================================== class TestKitComponentOwnMapping: """Regression: kit components that have their own ARTICOLE_TERTI mapping should be skipped.""" def test_component_with_own_mapping_skipped(self): """If comp_codmat is itself a key in mapped_data, it's skipped.""" mapped_data = { "PACK-A": [{"codmat": "COMP-X", "id_articol": 1, "cantitate_roa": 1, "cont": "371"}], "COMP-X": [{"codmat": "COMP-X", "id_articol": 1, "cantitate_roa": 1, "cont": "371"}], } # The check is: if comp_codmat in mapped_data: continue comp_codmat = "COMP-X" assert comp_codmat in mapped_data # Should be skipped def test_component_without_own_mapping_processed(self): """If comp_codmat is NOT in mapped_data, it should be processed.""" mapped_data = { "PACK-A": [{"codmat": "COMP-Y", "id_articol": 2, "cantitate_roa": 1, "cont": "371"}], } comp_codmat = "COMP-Y" assert comp_codmat not in mapped_data # Should be processed # =========================================================================== # Group 6: VAT Included Type Normalization # =========================================================================== class TestVatIncludedNormalization: """Regression: GoMag returns vat_included as int 1 or string '1'.""" def _compute_price_cu_tva(self, product): price = float(product.get("price", "0")) vat = float(product.get("vat", "19")) if str(product.get("vat_included", "1")) == "1": return price else: return price * (1 + vat / 100) def test_vat_included_int_1(self): result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": 1}) assert result == 100.0 def test_vat_included_str_1(self): result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": "1"}) assert result == 100.0 def test_vat_included_int_0(self): result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": 0}) assert result == 119.0 def test_vat_included_str_0(self): result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": "0"}) assert result == 119.0 # =========================================================================== # Group 7: validate_kit_component_prices — pret=0 allowed # =========================================================================== class TestKitComponentPriceValidation: """Regression: pret=0 in CRM is valid for kit components (validation_service.py:469).""" def _call_validate(self, fetchone_returns): from app.services.validation_service import validate_kit_component_prices mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_cursor.fetchone.return_value = fetchone_returns mapped = {"KIT-SKU": [ {"codmat": "COMP1", "id_articol": 100, "cont": "371", "cantitate_roa": 5}, ]} return validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn) def test_price_zero_not_rejected(self): result = self._call_validate((0,)) assert result == {} def test_missing_entry_rejected(self): result = self._call_validate(None) assert "KIT-SKU" in result assert "COMP1" in result["KIT-SKU"] def test_skips_true_1to1(self): from app.services.validation_service import validate_kit_component_prices mock_conn = MagicMock() mapped = {"SKU1": [ {"codmat": "X", "id_articol": 1, "cont": "371", "cantitate_roa": 1}, ]} result = validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn) assert result == {} def test_checks_repackaging(self): """Single component with cantitate_roa > 1 should be checked.""" from app.services.validation_service import validate_kit_component_prices mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_cursor.fetchone.return_value = (51.50,) mapped = {"CAFE100": [ {"codmat": "CAF01", "id_articol": 100, "cont": "371", "cantitate_roa": 10}, ]} result = validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn) assert result == {} mock_cursor.execute.assert_called_once() # =========================================================================== # Group 8: Dual Policy Assignment # =========================================================================== class TestDualPolicyAssignment: """Regression: cont 341/345 → production policy, others → sales (validation_service.py:282).""" def _call_dual(self, codmats, direct_id_map, cursor_rows): from app.services.validation_service import validate_and_ensure_prices_dual mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) # The code uses `for row in cur:` to iterate, not fetchall mock_cursor.__iter__ = MagicMock(return_value=iter(cursor_rows)) # Mock ensure_prices to do nothing with patch("app.services.validation_service.ensure_prices"): return validate_and_ensure_prices_dual( codmats, id_pol_vanzare=1, id_pol_productie=2, conn=mock_conn, direct_id_map=direct_id_map ) def test_cont_341_production(self): result = self._call_dual( {"COD1"}, {"COD1": {"id_articol": 100, "cont": "341"}}, [] # no existing prices ) assert result["COD1"] == 2 # id_pol_productie def test_cont_345_production(self): result = self._call_dual( {"COD1"}, {"COD1": {"id_articol": 100, "cont": "345"}}, [] ) assert result["COD1"] == 2 def test_other_cont_sales(self): result = self._call_dual( {"COD1"}, {"COD1": {"id_articol": 100, "cont": "371"}}, [] ) assert result["COD1"] == 1 # id_pol_vanzare def test_existing_sales_preferred(self): result = self._call_dual( {"COD1"}, {"COD1": {"id_articol": 100, "cont": "345"}}, [(100, 1), (100, 2)] # price exists in BOTH policies ) assert result["COD1"] == 1 # sales preferred when both exist # =========================================================================== # Group 9: Duplicate CODMAT — resolve_codmat_ids # =========================================================================== class TestResolveCodmatIds: """Regression: ROW_NUMBER dedup returns exactly 1 id_articol per CODMAT.""" @patch("app.services.validation_service.database") def test_returns_one_per_codmat(self, mock_db): from app.services.validation_service import resolve_codmat_ids mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) # Simulate ROW_NUMBER already deduped: 1 row per codmat mock_cursor.__iter__ = MagicMock(return_value=iter([ ("COD1", 100, "345"), ("COD2", 200, "341"), ])) result = resolve_codmat_ids({"COD1", "COD2"}, conn=mock_conn) assert len(result) == 2 assert result["COD1"]["id_articol"] == 100 assert result["COD2"]["id_articol"] == 200 @patch("app.services.validation_service.database") def test_resolve_mapped_one_per_sku_codmat(self, mock_db): from app.services.validation_service import resolve_mapped_codmats mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) # 1 row per (sku, codmat) pair mock_cursor.__iter__ = MagicMock(return_value=iter([ ("SKU1", "COD1", 100, "345", 10), ("SKU1", "COD2", 200, "341", 1), ])) result = resolve_mapped_codmats({"SKU1"}, mock_conn) assert "SKU1" in result assert len(result["SKU1"]) == 2 codmats = [c["codmat"] for c in result["SKU1"]] assert "COD1" in codmats assert "COD2" in codmats # ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ── class TestNormalizeCompanyNameExtended: """Tests for extended legal form stripping in normalize_company_name.""" def test_strip_ii_at_start(self): from app.services.anaf_service import normalize_company_name assert normalize_company_name("II CHIRITA N ION") == "CHIRITA N ION" def test_no_strip_ii_mid_name(self): from app.services.anaf_service import normalize_company_name result = normalize_company_name("TEHNICA II SRL") assert "II" in result # II should remain (Roman numeral) def test_strip_pfa(self): from app.services.anaf_service import normalize_company_name assert normalize_company_name("PFA POPESCU ION") == "POPESCU ION" def test_strip_intreprindere_individuala(self): from app.services.anaf_service import normalize_company_name result = normalize_company_name("CHIRIȚĂ N. ION ÎNTREPRINDERE INDIVIDUALĂ") assert "CHIRITA N ION" == result def test_strip_ii_with_dots(self): from app.services.anaf_service import normalize_company_name assert normalize_company_name("I.I. CHIRITA N ION") == "CHIRITA N ION" # ── _addr_match (server-side address comparison) ─��� class TestAddrMatch: """Tests for _addr_match server-side address comparison.""" def test_matching_addresses(self): from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"}) r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"}) assert _addr_match(g, r) is True def test_street_mismatch(self): from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"}) r = json.dumps({"strada": "Victoriei", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"}) assert _addr_match(g, r) is False def test_city_mismatch(self): from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"}) r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Cluj", "judet": "Brasov"}) assert _addr_match(g, r) is False def test_county_mismatch(self): from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"}) r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Brasov", "judet": "Cluj"}) assert _addr_match(g, r) is False def test_none_input_returns_true(self): from app.services.sync_service import _addr_match assert _addr_match(None, None) is True assert _addr_match(None, '{"strada":"x"}') is True assert _addr_match('{"address":"x"}', None) is True def test_strada_prefix_stripping(self): """'Strada Elisabeta' should match 'ELISABETA' after normalization.""" from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Strada Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"}) r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"}) assert _addr_match(g, r) is True def test_malformed_json_returns_true(self): from app.services.sync_service import _addr_match assert _addr_match("{bad json", '{"strada":"x"}') is True def test_addr_match_structured_fields(self): """addrMatch compares GoMag free-text vs ROA structured fields.""" from app.services.sync_service import _addr_match import json gomag = json.dumps({"address": "Str Vasile Goldis Nr 19 Bl 30 Sc D Ap 78", "city": "Alba Iulia", "region": "Alba"}) roa = json.dumps({"strada": "STRADA VASILE GOLDIS", "numar": "19", "bloc": "30", "scara": "D", "apart": "78", "localitate": "ALBA IULIA", "judet": "ALBA"}) assert _addr_match(gomag, roa) is True def test_addr_match_mismatch_structured(self): """addrMatch detects real mismatches with structured ROA fields.""" from app.services.sync_service import _addr_match import json gomag = json.dumps({"address": "Str Dacia 10", "city": "Bucuresti", "region": "Bucuresti"}) roa = json.dumps({"strada": "STRADA VASILE GOLDIS", "numar": "19", "bloc": "", "scara": "", "apart": "", "localitate": "ALBA IULIA", "judet": "ALBA"}) assert _addr_match(gomag, roa) is False def test_sectorul_in_city(self): """GoMag 'Municipiul București' matches ROA 'BUCURESTI SECTORUL 1'.""" from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Bld Decebal 24", "city": "Municipiul București", "region": "Bucuresti"}) r = json.dumps({"strada": "BLD DECEBAL", "numar": "24", "localitate": "BUCURESTI SECTORUL 1", "judet": "BUCURESTI"}) assert _addr_match(g, r) is True def test_keyword_digit_gluing(self): """Keywords glued to digits like 'sc1', 'ap94' are stripped correctly.""" from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Bld Decebal nr24 bl S2B sc1 ap94", "city": "Bucuresti", "region": "Bucuresti"}) r = json.dumps({"strada": "BLD DECEBAL", "numar": "24", "bloc": "S2B", "scara": "1", "apart": "94", "localitate": "BUCURESTI", "judet": "BUCURESTI"}) assert _addr_match(g, r) is True def test_etaj_in_street(self): """GoMag address with 'etaj 7' matches ROA with etaj field.""" from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Bld Decebal 24 Bl S2B Sc 1 Et 7 Ap 94", "city": "Bucuresti", "region": "Bucuresti"}) r = json.dumps({"strada": "BLD DECEBAL", "numar": "24", "bloc": "S2B", "scara": "1", "apart": "94", "etaj": "7", "localitate": "BUCURESTI", "judet": "BUCURESTI"}) assert _addr_match(g, r) is True def test_addr_match_diacritics(self): """Romanian diacritics (â, ș, ț, î) are normalized same as Oracle storage.""" from app.services.sync_service import _addr_match import json # â→a, î→i in city name g = json.dumps({"address": "Str. Morii 208", "city": "Sf\u00e2ntu Ilie", "region": "Suceava"}) r = json.dumps({"strada": "MORII", "numar": "208", "localitate": "SFANTU ILIE", "judet": "SUCEAVA"}) assert _addr_match(g, r) is True # ș→s, ț→t in street g2 = json.dumps({"address": "Str. \u0218oseaua \u021a\u0103rii 5", "city": "Bucure\u0219ti", "region": "Bucure\u0219ti"}) r2 = json.dumps({"strada": "SOSEAUA TARII", "numar": "5", "localitate": "BUCURESTI", "judet": "BUCURESTI"}) assert _addr_match(g2, r2) is True def test_sectorul_digit_stripping(self): """'BUCURESTI SECTORUL 1' trebuie să egaleze 'Municipiul București'.""" from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Bd. 1 Decembrie 1918 26", "city": "Municipiul București", "region": "Bucuresti"}) r = json.dumps({"strada": "BD 1 DECEMBRIE 1918", "numar": "26", "localitate": "BUCURESTI SECTORUL 1", "judet": "BUCURESTI"}) assert _addr_match(g, r) is True def test_addr_match_soundex_city(self): """SOUNDEX city matching: SFANTU ILIE ≈ SFINTU ILIE (ca in Oracle L2).""" from app.services.sync_service import _addr_match import json g = json.dumps({"address": "Str. Morii 208", "city": "Sfântu Ilie", "region": "Suceava"}) r = json.dumps({"strada": "MORII", "numar": "208", "localitate": "SFINTU ILIE", "judet": "SUCEAVA"}) assert _addr_match(g, r) is True # Negative test: city complet diferit nu trebuie sa dea match g2 = json.dumps({"address": "Str. Morii 208", "city": "Cluj", "region": "Cluj"}) r2 = json.dumps({"strada": "MORII", "numar": "208", "localitate": "TIMISOARA", "judet": "CLUJ"}) assert _addr_match(g2, r2) is False def test_billing_equals_shipping_short_circuit(self): """Short-circuit condition: billing == shipping → reuse addr_livr_id.""" from app.services.import_service import format_address_for_oracle shipping_addr = format_address_for_oracle("Bld Decebal 24", "Bucuresti", "Bucuresti") billing_addr = format_address_for_oracle("Bld Decebal 24", "Bucuresti", "Bucuresti") addr_livr_id = 123 # Simulate the short-circuit condition assert addr_livr_id and billing_addr == shipping_addr def test_billing_differs_shipping_no_short_circuit(self): """When billing != shipping, short-circuit does NOT apply.""" from app.services.import_service import format_address_for_oracle shipping_addr = format_address_for_oracle("Str. Victoriei 10", "Cluj", "Cluj") billing_addr = format_address_for_oracle("Bld Decebal 24", "Bucuresti", "Bucuresti") addr_livr_id = 123 assert not (addr_livr_id and billing_addr == shipping_addr) def test_pf_billing_address_equals_shipping(self): """PF (individual): is_pj=0 → billing address = shipping (ramburs curier).""" from app.services.import_service import determine_partner_data from app.services.order_reader import OrderBilling, OrderShipping, OrderData billing = OrderBilling( firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", address="Str Victoriei 10", city="Cluj", region="Cluj", country="RO", company_name="", company_code="", company_reg="", is_company=False ) shipping = OrderShipping( firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", address="Str Victoriei 10", city="Cluj", region="Cluj", country="RO" ) order = OrderData(id="PF001", number="PF001", date="2024-01-01T10:00:00", billing=billing, shipping=shipping) pdata = determine_partner_data(order) assert pdata["is_pj"] == 0, "PF order must have is_pj=0" def test_pj_uses_billing_from_gomag(self): """PJ (company): is_pj=1 → billing address from GoMag billing.""" from app.services.import_service import determine_partner_data from app.services.order_reader import OrderBilling, OrderShipping, OrderData billing = OrderBilling( firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", address="Bld Unirii 5", city="Bucuresti", region="Bucuresti", country="RO", company_name="FIRMA SRL", company_code="RO12345678", company_reg="J40/1234/2020", is_company=True ) shipping = OrderShipping( firstname="Mihai", lastname="Ionescu", phone="0711111111", email="mihai@test.com", address="Str Libertatii 20", city="Ploiesti", region="Prahova", country="RO" ) order = OrderData(id="PJ001", number="PJ001", date="2024-01-01T10:00:00", billing=billing, shipping=shipping) pdata = determine_partner_data(order) assert pdata["is_pj"] == 1, "PJ order must have is_pj=1" assert pdata["denumire"] == "FIRMA SRL" assert pdata["cod_fiscal"] == "RO12345678" def test_pj_different_person_still_uses_billing(self): """Regression: PJ with different billing/shipping persons → still is_pj=1 (billing addr used).""" from app.services.import_service import determine_partner_data from app.services.order_reader import OrderBilling, OrderShipping, OrderData billing = OrderBilling( firstname="Secretara", lastname="Firma", phone="0700000000", email="office@firma.ro", address="Calea Victoriei 1", city="Bucuresti", region="Bucuresti", country="RO", company_name="FIRMA SA", company_code="RO99999999", company_reg="J40/9999/2019", is_company=True ) shipping = OrderShipping( firstname="Curier", lastname="Destinatar", phone="0799999999", email="d@test.com", address="Str Livrare 5", city="Iasi", region="Iasi", country="RO" ) order = OrderData(id="PJ002", number="PJ002", date="2024-01-01T10:00:00", billing=billing, shipping=shipping) pdata = determine_partner_data(order) assert pdata["is_pj"] == 1, "PJ with different persons must still be is_pj=1" def test_pf_different_billing_still_uses_shipping(self): """Regression: PF with different billing address → still is_pj=0 (shipping addr used for billing).""" from app.services.import_service import determine_partner_data from app.services.order_reader import OrderBilling, OrderShipping, OrderData billing = OrderBilling( firstname="Ana", lastname="Gheorghe", phone="0700000000", email="ana@test.com", address="Str Alta 99", city="Timisoara", region="Timis", country="RO", company_name="", company_code="", company_reg="", is_company=False ) shipping = OrderShipping( firstname="Ana", lastname="Gheorghe", phone="0700000000", email="ana@test.com", address="Str Livrare 7", city="Cluj", region="Cluj", country="RO" ) order = OrderData(id="PF002", number="PF002", date="2024-01-01T10:00:00", billing=billing, shipping=shipping) pdata = determine_partner_data(order) assert pdata["is_pj"] == 0, "PF must remain is_pj=0 regardless of billing address" def test_is_company_cui_fallback(self): """Company with no name but CUI populated → is_company=True (order_reader parsing).""" from app.services.order_reader import _parse_order order_data = { "number": "CUI001", "date": "2024-01-01T10:00:00", "statusId": 1, "status": "new", "billing": { "firstname": "Ion", "lastname": "Popescu", "phone": "0700000000", "email": "ion@test.com", "address": "Str Test 1", "city": "Bucuresti", "region": "Bucuresti", "country": "RO", "company": {"name": "", "code": "RO12345678", "registrationNo": ""} }, "items": [], "total": 100.0, "discountTotal": 0.0, "shippingTotal": 0.0 } order = _parse_order("CUI001", order_data, "test.json") assert order.billing.is_company is True, "CUI-only company must be detected as is_company" assert order.billing.company_code == "RO12345678" def test_pj_denomination_fallback_empty_company_name(self): """PJ with CUI but no company_name → denumire falls back to billing person name.""" from app.services.import_service import determine_partner_data from app.services.order_reader import OrderBilling, OrderData billing = OrderBilling( firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com", address="Str Test 1", city="Bucuresti", region="Bucuresti", country="RO", company_name="", company_code="RO12345678", company_reg="", is_company=True ) order = OrderData(id="CUI002", number="CUI002", date="2024-01-01T10:00:00", billing=billing, shipping=None) pdata = determine_partner_data(order) assert pdata["is_pj"] == 1 assert pdata["denumire"] == "POPESCU ION", "Fallback denumire must use billing person name" assert pdata["cod_fiscal"] == "RO12345678" class TestFormatAddressForOracle: """Tests for format_address_for_oracle city stripping.""" def test_city_strip_from_address_end(self): """City name at end of address is stripped.""" from app.services.import_service import format_address_for_oracle result = format_address_for_oracle("Strada Vasile Goldis nr 19 Alba Iulia", "Alba Iulia", "Alba") assert result == "JUD:Alba;Alba Iulia;Strada Vasile Goldis nr 19" def test_city_strip_case_insensitive(self): """City strip works regardless of case.""" from app.services.import_service import format_address_for_oracle result = format_address_for_oracle("Str Dacia alba iulia", "Alba Iulia", "Alba") assert result == "JUD:Alba;Alba Iulia;Str Dacia" def test_city_no_strip_when_not_at_end(self): """Don't strip city if it's in the middle of the address.""" from app.services.import_service import format_address_for_oracle result = format_address_for_oracle("Alba Iulia Str Dacia 5", "Alba Iulia", "Alba") assert "Alba Iulia Str Dacia 5" in result def test_city_no_strip_when_empty_remains(self): """Don't strip if it would leave empty address.""" from app.services.import_service import format_address_for_oracle result = format_address_for_oracle("Alba Iulia", "Alba Iulia", "Alba") assert "Alba Iulia" in result # address preserved # =========================================================================== # Group 11: TestRefreshOrderAddress # =========================================================================== import sqlite3 @pytest.fixture(scope="module") def client(): """TestClient for refresh-address endpoint tests (Oracle mocked via FORCE_THIN_MODE).""" from fastapi.testclient import TestClient from app.main import app with TestClient(app, raise_server_exceptions=False) as c: yield c @pytest.fixture def db(): """Synchronous SQLite connection to the test DB.""" conn = sqlite3.connect(_sqlite_path) conn.row_factory = sqlite3.Row yield conn # Clean up test rows inserted during the test conn.execute("DELETE FROM orders WHERE order_number LIKE 'test-%'") conn.commit() conn.close() class TestRefreshOrderAddress: """Tests for POST /api/orders/{order_number}/refresh-address endpoint.""" def test_order_not_found_returns_404(self, client): """Non-existent order_number returns 404.""" res = client.post("/api/orders/nonexistent-99999/refresh-address") assert res.status_code == 404 def test_null_address_ids_returns_422(self, client, db): """Orders without Oracle address IDs return 422.""" db.execute("INSERT OR IGNORE INTO orders (order_number, status) VALUES ('test-no-addr', 'IMPORTED')") db.commit() res = client.post("/api/orders/test-no-addr/refresh-address") assert res.status_code == 422 def test_oracle_unavailable_returns_503(self, client, db, monkeypatch): """Oracle connection failure returns 503.""" db.execute("INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-oracle-fail', 'IMPORTED', 4116)") db.commit() import asyncio as _asyncio async def _mock_to_thread(fn, *args, **kwargs): raise Exception("Oracle down") monkeypatch.setattr(_asyncio, "to_thread", _mock_to_thread) res = client.post("/api/orders/test-oracle-fail/refresh-address") assert res.status_code == 503 def test_refresh_returns_8_fields(self, client, db, monkeypatch): """Successful refresh returns 8-field address dict.""" db.execute("INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-refresh-ok', 'IMPORTED', 4116)") db.commit() mock_result = ( {"strada": "VASILE GOLDIS", "numar": "19", "bloc": "30", "scara": "D", "apart": "78", "etaj": None, "localitate": "ALBA IULIA", "judet": "ALBA"}, None, ) import asyncio as _asyncio async def _mock_to_thread(fn, *args, **kwargs): return mock_result monkeypatch.setattr(_asyncio, "to_thread", _mock_to_thread) res = client.post("/api/orders/test-refresh-ok/refresh-address") assert res.status_code == 200 data = res.json() assert data["adresa_livrare_roa"]["strada"] == "VASILE GOLDIS" assert data["adresa_livrare_roa"]["bloc"] == "30"