Files
gomag-vending/api/tests/test_business_rules.py
Claude Agent 5eba87976b fix(address): use SOUNDEX city matching and strip SECTORUL from city
Fixes false negatives where city spellings differ slightly (e.g.
"Sfântu Ilie" vs "SFINTU ILIE") or ROA stores "BUCURESTI SECTORUL 1"
while GoMag sends "Municipiul București". Both backend (_addr_match)
and frontend (addrMatch) now use identical SOUNDEX logic mirroring
Oracle's implementation.

Also fixes field order: etaj before apart in r_street concatenation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:31:36 +00:00

896 lines
40 KiB
Python
Raw Blame History

"""
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) ─<><E29480><EFBFBD>
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"