Files
gomag-vending/api/tests/test_business_rules.py
Claude Agent 25f73db64d feat(orders): add resync and delete order buttons
Resync soft-deletes from Oracle then re-imports from GoMag with fresh
article data. Delete soft-deletes and marks DELETED_IN_ROA. Both have
invoice safety gates (refuse if invoiced or Oracle unavailable).

UI: split modal footer (Delete left, Resync+Close right), inline
confirm pattern (no native confirm()), dashboard row hover action
icons, disabled+tooltip for invoiced orders. 8 unit tests for safety
gates and happy paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 13:10:01 +00:00

1089 lines
49 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
def test_comma_stripped_from_address(self):
"""Commas in address are replaced with spaces to prevent wrong Oracle splitting."""
from app.services.import_service import format_address_for_oracle
result = format_address_for_oracle("Str Principala, Modarzau Blocuri", "Zemes", "Bacau")
assert result == "JUD:Bacau;Zemes;Str Principala Modarzau Blocuri"
# ===========================================================================
# 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"
# ===========================================================================
# Group N: Resync / Delete — safety gates and happy paths
# ===========================================================================
from unittest.mock import AsyncMock # noqa: E402 (already imported MagicMock/patch above)
def _make_order_detail(status='IMPORTED', id_comanda=12345, factura_numar=None):
return {
"order": {
"order_number": "1001",
"status": status,
"id_comanda": id_comanda,
"order_date": "2025-01-15T10:00:00",
"customer_name": "Test Client",
"factura_numar": factura_numar,
},
"items": []
}
def _unlocked_lock():
m = MagicMock()
m.locked.return_value = False
return m
class TestResyncDeleteSafetyGates:
"""Safety gates: invoiced orders and Oracle-down must be refused."""
@pytest.mark.asyncio
@pytest.mark.unit
async def test_delete_refuses_invoiced_order(self):
"""delete_single_order → success=False when factura_numar is set."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(factura_numar='FAC-001'))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()):
result = await retry_service.delete_single_order("1001")
assert result["success"] is False
assert "facturata" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_delete_refuses_when_oracle_down(self):
"""delete_single_order → success=False when database.pool is None."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=None):
result = await retry_service.delete_single_order("1001")
assert result["success"] is False
assert "indisponibil" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_refuses_invoiced_order(self):
"""resync_single_order → success=False when factura_numar is set."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(factura_numar='FAC-001'))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is False
assert "facturata" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_refuses_wrong_status(self):
"""resync_single_order → success=False when status is ERROR (not IMPORTED)."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(status='ERROR'))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is False
assert "ERROR" in result["message"]
class TestResyncDeleteHappyPaths:
"""Happy paths and partial-failure recovery for resync / delete / retry."""
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_happy_path(self):
"""resync_single_order succeeds: IMPORTED, no invoice, soft-delete OK, reimport OK."""
from app.services import retry_service
async def _fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()), \
patch('app.services.invoice_service.check_invoices_for_orders',
new=MagicMock(return_value={})), \
patch('app.services.import_service.soft_delete_order_in_roa',
new=MagicMock(return_value={"success": True})), \
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
patch('asyncio.to_thread', new=_fake_to_thread), \
patch('app.services.retry_service._download_and_reimport',
new=AsyncMock(return_value={"success": True,
"message": "Comanda reimportata cu succes"})):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is True
@pytest.mark.asyncio
@pytest.mark.unit
async def test_delete_happy_path(self):
"""delete_single_order succeeds: IMPORTED, no invoice, soft-delete OK."""
from app.services import retry_service
async def _fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()), \
patch('app.services.invoice_service.check_invoices_for_orders',
new=MagicMock(return_value={})), \
patch('app.services.import_service.soft_delete_order_in_roa',
new=MagicMock(return_value={"success": True})), \
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
patch('asyncio.to_thread', new=_fake_to_thread):
result = await retry_service.delete_single_order("1001")
assert result["success"] is True
assert "stearsa" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_retry_accepts_deleted_in_roa(self):
"""retry_single_order proceeds (not refused) for DELETED_IN_ROA status."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(status='DELETED_IN_ROA'))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.services.retry_service._download_and_reimport',
new=AsyncMock(return_value={"success": True, "message": "ok"})):
result = await retry_service.retry_single_order("1001", {})
assert result["success"] is True
# Must NOT be refused with the "retry permis" status guard message
assert "retry permis" not in result.get("message", "").lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_partial_failure(self):
"""resync returns success=False when soft-delete OK but reimport fails."""
from app.services import retry_service
async def _fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()), \
patch('app.services.invoice_service.check_invoices_for_orders',
new=MagicMock(return_value={})), \
patch('app.services.import_service.soft_delete_order_in_roa',
new=MagicMock(return_value={"success": True})), \
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
patch('asyncio.to_thread', new=_fake_to_thread), \
patch('app.services.retry_service._download_and_reimport',
new=AsyncMock(return_value={"success": False, "message": "download failed"})):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is False
assert "reimport" in result["message"].lower()