""" Test Phase 5.1: Backend Functionality Tests (no Oracle required) ================================================================ Tests all new backend features: web_products, order_items, order detail, run orders filtered, address updates, missing SKUs toggle, and API endpoints. Run: cd api && python -m pytest tests/test_requirements.py -v """ import os import sys import pytest pytestmark = pytest.mark.unit import tempfile # --- Set env vars BEFORE any app import --- _tmpdir = tempfile.mkdtemp() _sqlite_path = os.path.join(_tmpdir, "test_import.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 # Add api/ to path so we can import app _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) import pytest import pytest_asyncio from app.database import init_sqlite from app.services import sqlite_service # Initialize SQLite once before any tests run init_sqlite() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def client(): """TestClient with lifespan (startup/shutdown) so SQLite routes work.""" from fastapi.testclient import TestClient from app.main import app with TestClient(app, raise_server_exceptions=False) as c: yield c @pytest.fixture(autouse=True, scope="module") def seed_baseline_data(): """ Seed the sync run and orders used by multiple tests so they run in any order. We use asyncio.run() because this is a synchronous fixture but needs to call async service functions. """ import asyncio async def _seed(): # Create sync run RUN001 await sqlite_service.create_sync_run("RUN001", 1) # Add the first order (IMPORTED) with items await sqlite_service.add_import_order( "RUN001", "ORD001", "2025-01-15", "Test Client", "IMPORTED", id_comanda=100, id_partener=200, items_count=2 ) items = [ { "sku": "SKU1", "product_name": "Prod 1", "quantity": 2.0, "price": 10.0, "vat": 1.9, "mapping_status": "direct", "codmat": "SKU1", "id_articol": 500, "cantitate_roa": 2.0, }, { "sku": "SKU2", "product_name": "Prod 2", "quantity": 1.0, "price": 20.0, "vat": 3.8, "mapping_status": "missing", "codmat": None, "id_articol": None, "cantitate_roa": None, }, ] await sqlite_service.add_order_items("RUN001", "ORD001", items) # Add more orders for filter tests await sqlite_service.add_import_order( "RUN001", "ORD002", "2025-01-16", "Client 2", "SKIPPED", missing_skus=["SKU99"], items_count=1 ) await sqlite_service.add_import_order( "RUN001", "ORD003", "2025-01-17", "Client 3", "ERROR", error_message="Test error", items_count=3 ) asyncio.run(_seed()) yield # --------------------------------------------------------------------------- # Section 1: web_products CRUD # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_upsert_web_product(): """First upsert creates the row; second increments order_count.""" await sqlite_service.upsert_web_product("SKU001", "Product One") name = await sqlite_service.get_web_product_name("SKU001") assert name == "Product One" # Second upsert should increment order_count (no assertion on count here, # but must not raise and batch lookup should still find it) await sqlite_service.upsert_web_product("SKU001", "Product One") batch = await sqlite_service.get_web_products_batch(["SKU001", "NONEXIST"]) assert "SKU001" in batch assert "NONEXIST" not in batch @pytest.mark.asyncio async def test_web_product_name_update(): """Empty name should NOT overwrite an existing product name.""" await sqlite_service.upsert_web_product("SKU002", "Good Name") await sqlite_service.upsert_web_product("SKU002", "") name = await sqlite_service.get_web_product_name("SKU002") assert name == "Good Name" @pytest.mark.asyncio async def test_get_web_product_name_missing(): """Lookup for an SKU that was never inserted should return empty string.""" name = await sqlite_service.get_web_product_name("DEFINITELY_NOT_THERE_XYZ") assert name == "" @pytest.mark.asyncio async def test_get_web_products_batch_empty(): """Batch lookup with empty list should return empty dict without error.""" result = await sqlite_service.get_web_products_batch([]) assert result == {} # --------------------------------------------------------------------------- # Section 2: order_items CRUD # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_add_and_get_order_items(): """Verify the items seeded in baseline data are retrievable.""" fetched = await sqlite_service.get_order_items("ORD001") assert len(fetched) == 2 assert fetched[0]["sku"] == "SKU1" assert fetched[1]["mapping_status"] == "missing" @pytest.mark.asyncio async def test_get_order_items_mapping_status(): """First item should be 'direct', second should be 'missing'.""" fetched = await sqlite_service.get_order_items("ORD001") assert fetched[0]["mapping_status"] == "direct" assert fetched[1]["codmat"] is None assert fetched[1]["id_articol"] is None @pytest.mark.asyncio async def test_get_order_items_for_nonexistent_order(): """Items query for an unknown order should return an empty list.""" fetched = await sqlite_service.get_order_items("NONEXIST_ORDER") assert fetched == [] # --------------------------------------------------------------------------- # Section 3: order detail # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_order_detail(): """Order detail returns order metadata plus its line items.""" detail = await sqlite_service.get_order_detail("ORD001") assert detail is not None assert detail["order"]["order_number"] == "ORD001" assert len(detail["items"]) == 2 @pytest.mark.asyncio async def test_get_order_detail_not_found(): """Non-existent order returns None.""" detail = await sqlite_service.get_order_detail("NONEXIST") assert detail is None @pytest.mark.asyncio async def test_get_order_detail_status(): """Seeded ORD001 should have IMPORTED status.""" detail = await sqlite_service.get_order_detail("ORD001") assert detail["order"]["status"] == "IMPORTED" # --------------------------------------------------------------------------- # Section 4: run orders filtered # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_run_orders_filtered_all(): """All orders in run should total 3 with correct status counts.""" result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 50) assert result["total"] == 3 assert result["counts"]["imported"] == 1 assert result["counts"]["skipped"] == 1 assert result["counts"]["error"] == 1 @pytest.mark.asyncio async def test_get_run_orders_filtered_imported(): """Filter IMPORTED should return only ORD001.""" result = await sqlite_service.get_run_orders_filtered("RUN001", "IMPORTED", 1, 50) assert result["total"] == 1 assert result["orders"][0]["order_number"] == "ORD001" @pytest.mark.asyncio async def test_get_run_orders_filtered_skipped(): """Filter SKIPPED should return only ORD002.""" result = await sqlite_service.get_run_orders_filtered("RUN001", "SKIPPED", 1, 50) assert result["total"] == 1 assert result["orders"][0]["order_number"] == "ORD002" @pytest.mark.asyncio async def test_get_run_orders_filtered_error(): """Filter ERROR should return only ORD003.""" result = await sqlite_service.get_run_orders_filtered("RUN001", "ERROR", 1, 50) assert result["total"] == 1 assert result["orders"][0]["order_number"] == "ORD003" @pytest.mark.asyncio async def test_get_run_orders_filtered_unknown_run(): """Unknown run_id should return zero orders without error.""" result = await sqlite_service.get_run_orders_filtered("NO_SUCH_RUN", "all", 1, 50) assert result["total"] == 0 assert result["orders"] == [] @pytest.mark.asyncio async def test_get_run_orders_filtered_pagination(): """Pagination: page 1 with per_page=1 should return 1 order.""" result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 1) assert len(result["orders"]) == 1 assert result["total"] == 3 assert result["pages"] == 3 # --------------------------------------------------------------------------- # Section 5: update_import_order_addresses # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_update_import_order_addresses(): """Address IDs should be persisted and retrievable via get_order_detail.""" await sqlite_service.update_import_order_addresses( "ORD001", "RUN001", id_adresa_facturare=300, id_adresa_livrare=400 ) detail = await sqlite_service.get_order_detail("ORD001") assert detail["order"]["id_adresa_facturare"] == 300 assert detail["order"]["id_adresa_livrare"] == 400 @pytest.mark.asyncio async def test_update_import_order_addresses_null(): """Updating with None should be accepted without error.""" await sqlite_service.update_import_order_addresses( "ORD001", "RUN001", id_adresa_facturare=None, id_adresa_livrare=None ) detail = await sqlite_service.get_order_detail("ORD001") assert detail is not None # row still exists # --------------------------------------------------------------------------- # Section 6: missing SKUs resolved toggle (R10) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_missing_skus_resolved_toggle(): """resolved=-1 returns all; resolved=0/1 returns only matching rows.""" await sqlite_service.track_missing_sku("MISS1", "Missing Product 1") await sqlite_service.track_missing_sku("MISS2", "Missing Product 2") await sqlite_service.resolve_missing_sku("MISS2") # Unresolved only (default) result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0) assert all(s["resolved"] == 0 for s in result["missing_skus"]) # Resolved only result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=1) assert all(s["resolved"] == 1 for s in result["missing_skus"]) # All (resolved=-1) result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=-1) assert result["total"] >= 2 @pytest.mark.asyncio async def test_track_missing_sku_idempotent(): """Tracking the same SKU twice should not raise (INSERT OR IGNORE).""" await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product") await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product") result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0) sku_list = [s["sku"] for s in result["missing_skus"]] assert sku_list.count("IDEMPOTENT_SKU") == 1 @pytest.mark.asyncio async def test_missing_skus_pagination(): """Pagination response includes total, page, per_page, pages fields.""" result = await sqlite_service.get_missing_skus_paginated(1, 1, resolved=-1) assert "total" in result assert "page" in result assert "per_page" in result assert "pages" in result assert len(result["missing_skus"]) <= 1 # --------------------------------------------------------------------------- # Section 7: API endpoints via TestClient # --------------------------------------------------------------------------- def test_api_sync_run_orders(client): """R1: GET /api/sync/run/{run_id}/orders returns orders and counts.""" resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=50") assert resp.status_code == 200 data = resp.json() assert "orders" in data assert "counts" in data def test_api_sync_run_orders_filtered(client): """R1: Filtering by status=IMPORTED returns only IMPORTED orders.""" resp = client.get("/api/sync/run/RUN001/orders?status=IMPORTED") assert resp.status_code == 200 data = resp.json() assert all(o["status"] == "IMPORTED" for o in data["orders"]) def test_api_sync_run_orders_pagination_fields(client): """R1: Paginated response includes total, page, per_page, pages.""" resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=10") assert resp.status_code == 200 data = resp.json() assert "total" in data assert "page" in data assert "per_page" in data assert "pages" in data def test_api_sync_run_orders_unknown_run(client): """R1: Unknown run_id returns empty orders list, not 4xx/5xx.""" resp = client.get("/api/sync/run/NO_SUCH_RUN/orders") assert resp.status_code == 200 data = resp.json() assert data["total"] == 0 def test_api_order_detail(client): """R9: GET /api/sync/order/{order_number} returns order and items.""" resp = client.get("/api/sync/order/ORD001") assert resp.status_code == 200 data = resp.json() assert "order" in data assert "items" in data def test_api_order_detail_not_found(client): """R9: Non-existent order number returns error key.""" resp = client.get("/api/sync/order/NONEXIST") assert resp.status_code == 200 data = resp.json() assert "error" in data def test_api_missing_skus_resolved_toggle(client): """R10: resolved=-1 returns all missing SKUs.""" resp = client.get("/api/validate/missing-skus?resolved=-1") assert resp.status_code == 200 data = resp.json() assert "missing_skus" in data def test_api_missing_skus_resolved_unresolved(client): """R10: resolved=0 returns only unresolved SKUs.""" resp = client.get("/api/validate/missing-skus?resolved=0") assert resp.status_code == 200 data = resp.json() assert "missing_skus" in data assert all(s["resolved"] == 0 for s in data["missing_skus"]) def test_api_missing_skus_resolved_only(client): """R10: resolved=1 returns only resolved SKUs.""" resp = client.get("/api/validate/missing-skus?resolved=1") assert resp.status_code == 200 data = resp.json() assert "missing_skus" in data assert all(s["resolved"] == 1 for s in data["missing_skus"]) def test_api_missing_skus_csv_format(client): """R8: CSV export has mapping-compatible columns.""" resp = client.get("/api/validate/missing-skus-csv") assert resp.status_code == 200 content = resp.content.decode("utf-8-sig") header_line = content.split("\n")[0].strip() assert header_line == "sku,codmat,cantitate_roa,procent_pret,product_name" def test_api_mappings_sort_params(client): """R7: Sort params accepted - no 422 validation error even without Oracle.""" resp = client.get("/api/mappings?sort_by=sku&sort_dir=desc") # 200 if Oracle available, 503 if not - but never 422 (invalid params) assert resp.status_code in [200, 503] def test_api_mappings_sort_params_asc(client): """R7: sort_dir=asc is also accepted without 422.""" resp = client.get("/api/mappings?sort_by=codmat&sort_dir=asc") assert resp.status_code in [200, 503] def test_api_batch_mappings_validation_percentage(client): """R11: Batch endpoint rejects procent_pret that does not sum to 100.""" resp = client.post("/api/mappings/batch", json={ "sku": "TESTSKU", "mappings": [ {"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60}, {"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 30}, ] }) data = resp.json() # 60 + 30 = 90, not 100 -> must fail validation assert data.get("success") is False assert "100%" in data.get("error", "") def test_api_batch_mappings_validation_exact_100(client): """R11: Batch with procent_pret summing to exactly 100 passes validation layer.""" resp = client.post("/api/mappings/batch", json={ "sku": "TESTSKU_VALID", "mappings": [ {"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60}, {"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 40}, ] }) data = resp.json() # Validation passes; may fail with 503/error if Oracle is unavailable, # but must NOT return the percentage error message assert "100%" not in data.get("error", "") def test_api_batch_mappings_no_mappings(client): """R11: Batch endpoint rejects empty mappings list.""" resp = client.post("/api/mappings/batch", json={ "sku": "TESTSKU", "mappings": [] }) data = resp.json() assert data.get("success") is False def test_api_sync_status(client): """GET /api/sync/status returns status and stats keys.""" resp = client.get("/api/sync/status") assert resp.status_code == 200 data = resp.json() assert "stats" in data def test_api_sync_history(client): """GET /api/sync/history returns paginated run history.""" resp = client.get("/api/sync/history") assert resp.status_code == 200 data = resp.json() assert "runs" in data assert "total" in data def test_api_missing_skus_pagination_params(client): """Pagination params page and per_page are respected.""" resp = client.get("/api/validate/missing-skus?page=1&per_page=2&resolved=-1") assert resp.status_code == 200 data = resp.json() assert len(data["missing_skus"]) <= 2 assert data["per_page"] == 2 def test_api_csv_template(client): """GET /api/mappings/csv-template returns a CSV file without Oracle.""" resp = client.get("/api/mappings/csv-template") assert resp.status_code == 200 # --------------------------------------------------------------------------- # Section 8: Chronological sorting (R3) # --------------------------------------------------------------------------- def test_chronological_sort(): """R3: Orders sorted oldest-first when sorted by date string.""" from app.services.order_reader import OrderData, OrderBilling orders = [ OrderData(id="3", number="003", date="2025-03-01", billing=OrderBilling()), OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling()), OrderData(id="2", number="002", date="2025-02-01", billing=OrderBilling()), ] orders.sort(key=lambda o: o.date or "") assert orders[0].number == "001" assert orders[1].number == "002" assert orders[2].number == "003" def test_chronological_sort_stable_on_equal_dates(): """R3: Two orders with the same date preserve relative order.""" from app.services.order_reader import OrderData, OrderBilling orders = [ OrderData(id="A", number="A01", date="2025-05-01", billing=OrderBilling()), OrderData(id="B", number="B01", date="2025-05-01", billing=OrderBilling()), ] orders.sort(key=lambda o: o.date or "") # Both dates equal; stable sort preserves original order assert orders[0].number == "A01" assert orders[1].number == "B01" def test_chronological_sort_empty_date_last(): """R3: Orders with missing date (empty string) sort before dated orders.""" from app.services.order_reader import OrderData, OrderBilling orders = [ OrderData(id="2", number="002", date="2025-06-01", billing=OrderBilling()), OrderData(id="1", number="001", date="", billing=OrderBilling()), ] orders.sort(key=lambda o: o.date or "") # '' sorts before '2025-...' lexicographically assert orders[0].number == "001" assert orders[1].number == "002" # --------------------------------------------------------------------------- # Section 9: OrderData dataclass integrity # --------------------------------------------------------------------------- def test_order_data_defaults(): """OrderData can be constructed with only id, number, date.""" from app.services.order_reader import OrderData, OrderBilling order = OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling()) assert order.status == "" assert order.items == [] assert order.shipping is None def test_order_billing_defaults(): """OrderBilling has sensible defaults.""" from app.services.order_reader import OrderBilling b = OrderBilling() assert b.is_company is False assert b.company_name == "" assert b.email == "" def test_get_all_skus(): """get_all_skus extracts a unique set of SKUs from all orders.""" from app.services.order_reader import OrderData, OrderBilling, OrderItem, get_all_skus orders = [ OrderData( id="1", number="001", date="2025-01-01", billing=OrderBilling(), items=[ OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9), OrderItem(sku="B", name="Prod B", price=20, quantity=2, vat=3.8), ] ), OrderData( id="2", number="002", date="2025-01-02", billing=OrderBilling(), items=[ OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9), OrderItem(sku="C", name="Prod C", price=5, quantity=3, vat=0.95), ] ), ] skus = get_all_skus(orders) assert skus == {"A", "B", "C"}