diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 88c2242..0f97dde 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -1,11 +1,77 @@ import asyncio import logging +from datetime import datetime from .. import database from . import sqlite_service logger = logging.getLogger(__name__) +def validate_structural(order: dict) -> tuple[bool, str | None, str | None]: + """Pre-flight structural validator used by save_orders_batch. + + Returns (True, None, None) on pass, (False, error_type, error_msg) on fail. + Rules are intentionally minimal — only catches malformed payloads that + would crash downstream inserts. Semantic checks (SKU existence, price + comparison, etc.) are handled in later phases. + """ + if not isinstance(order, dict): + return False, "MISSING_FIELD", f"order is not a dict: {type(order).__name__}" + + order_number = order.get("order_number") + if order_number is None or str(order_number).strip() == "": + return False, "MISSING_FIELD", "order_number is missing or empty" + + raw_date = order.get("order_date") + if raw_date in (None, ""): + return False, "INVALID_DATE", "order_date is missing or empty" + if isinstance(raw_date, datetime): + pass + elif isinstance(raw_date, str): + parsed = None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): + try: + parsed = datetime.strptime(raw_date, fmt) + break + except ValueError: + continue + if parsed is None: + try: + parsed = datetime.fromisoformat(raw_date.replace("Z", "+00:00")) + except ValueError: + return False, "INVALID_DATE", f"order_date not parseable: {raw_date!r}" + else: + return False, "INVALID_DATE", f"order_date wrong type: {type(raw_date).__name__}" + + items = order.get("items") + if not items or not isinstance(items, list): + return False, "EMPTY_ITEMS", "items missing or not a non-empty list" + + for idx, item in enumerate(items): + if not isinstance(item, dict): + return False, "EMPTY_ITEMS", f"item[{idx}] is not a dict" + + qty_raw = item.get("quantity") + if qty_raw is None or qty_raw == "": + return False, "INVALID_QUANTITY", f"item[{idx}] quantity missing" + try: + qty = float(qty_raw) + except (TypeError, ValueError): + return False, "INVALID_QUANTITY", f"item[{idx}] quantity not numeric: {qty_raw!r}" + if qty <= 0: + return False, "INVALID_QUANTITY", f"item[{idx}] quantity not > 0: {qty}" + + price_raw = item.get("price") + if price_raw is None or price_raw == "": + return False, "INVALID_PRICE", f"item[{idx}] price missing" + try: + float(price_raw) + except (TypeError, ValueError): + return False, "INVALID_PRICE", f"item[{idx}] price not numeric: {price_raw!r}" + + return True, None, None + + async def reconcile_unresolved_missing_skus(conn=None) -> dict: """Revalidate all resolved=0 SKUs in missing_skus against Oracle. Fail-soft: logs warning and returns zero if Oracle is unavailable. diff --git a/api/tests/test_validate_structural.py b/api/tests/test_validate_structural.py new file mode 100644 index 0000000..0956b5f --- /dev/null +++ b/api/tests/test_validate_structural.py @@ -0,0 +1,166 @@ +"""Unit tests for validation_service.validate_structural(). + +Structural pre-flight validator — only catches malformed payloads that +would crash downstream inserts. Does NOT check SKU existence (handled +by validate_skus) or duplicate SKUs (handled by _dedup_items_by_sku). +""" +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.services.validation_service import validate_structural + + +def _valid_order(**overrides): + base = { + "order_number": "123456", + "order_date": "2026-04-22 10:00:00", + "items": [{"sku": "ABC", "quantity": 2, "price": 15.50}], + } + base.update(overrides) + return base + + +@pytest.mark.unit +def test_valid_order_passes(): + ok, err_type, err_msg = validate_structural(_valid_order()) + assert ok is True + assert err_type is None + assert err_msg is None + + +@pytest.mark.unit +def test_missing_order_number(): + ok, err_type, _ = validate_structural(_valid_order(order_number="")) + assert ok is False + assert err_type == "MISSING_FIELD" + + ok, err_type, _ = validate_structural(_valid_order(order_number=None)) + assert ok is False + assert err_type == "MISSING_FIELD" + + +@pytest.mark.unit +def test_non_dict_order(): + ok, err_type, _ = validate_structural("not a dict") + assert ok is False + assert err_type == "MISSING_FIELD" + + +@pytest.mark.unit +def test_invalid_date_unparseable(): + ok, err_type, _ = validate_structural(_valid_order(order_date="not-a-date")) + assert ok is False + assert err_type == "INVALID_DATE" + + +@pytest.mark.unit +def test_invalid_date_missing(): + ok, err_type, _ = validate_structural(_valid_order(order_date=None)) + assert ok is False + assert err_type == "INVALID_DATE" + + +@pytest.mark.unit +def test_date_iso_format_passes(): + ok, _, _ = validate_structural(_valid_order(order_date="2026-04-22T10:00:00")) + assert ok is True + + +@pytest.mark.unit +def test_empty_items(): + ok, err_type, _ = validate_structural(_valid_order(items=[])) + assert ok is False + assert err_type == "EMPTY_ITEMS" + + ok, err_type, _ = validate_structural(_valid_order(items=None)) + assert ok is False + assert err_type == "EMPTY_ITEMS" + + +@pytest.mark.unit +def test_items_not_list(): + ok, err_type, _ = validate_structural(_valid_order(items="ABC")) + assert ok is False + assert err_type == "EMPTY_ITEMS" + + +@pytest.mark.unit +def test_item_not_dict(): + ok, err_type, _ = validate_structural(_valid_order(items=["just-a-string"])) + assert ok is False + assert err_type == "EMPTY_ITEMS" + + +@pytest.mark.unit +def test_invalid_quantity_zero(): + ok, err_type, _ = validate_structural( + _valid_order(items=[{"sku": "A", "quantity": 0, "price": 1}]) + ) + assert ok is False + assert err_type == "INVALID_QUANTITY" + + +@pytest.mark.unit +def test_invalid_quantity_negative(): + ok, err_type, _ = validate_structural( + _valid_order(items=[{"sku": "A", "quantity": -3, "price": 1}]) + ) + assert ok is False + assert err_type == "INVALID_QUANTITY" + + +@pytest.mark.unit +def test_invalid_quantity_non_numeric(): + ok, err_type, _ = validate_structural( + _valid_order(items=[{"sku": "A", "quantity": "abc", "price": 1}]) + ) + assert ok is False + assert err_type == "INVALID_QUANTITY" + + +@pytest.mark.unit +def test_invalid_quantity_missing(): + ok, err_type, _ = validate_structural( + _valid_order(items=[{"sku": "A", "price": 1}]) + ) + assert ok is False + assert err_type == "INVALID_QUANTITY" + + +@pytest.mark.unit +def test_invalid_price_non_numeric(): + ok, err_type, _ = validate_structural( + _valid_order(items=[{"sku": "A", "quantity": 1, "price": "NaN-text"}]) + ) + assert ok is False + assert err_type == "INVALID_PRICE" + + +@pytest.mark.unit +def test_invalid_price_missing(): + ok, err_type, _ = validate_structural( + _valid_order(items=[{"sku": "A", "quantity": 1}]) + ) + assert ok is False + assert err_type == "INVALID_PRICE" + + +@pytest.mark.unit +def test_price_zero_allowed(): + """Complex sets can legitimately have price=0 on one leg.""" + ok, _, _ = validate_structural( + _valid_order(items=[{"sku": "A", "quantity": 1, "price": 0}]) + ) + assert ok is True + + +@pytest.mark.unit +def test_sku_null_passes_structural(): + """SKU validation is handled downstream, NOT here.""" + ok, _, _ = validate_structural( + _valid_order(items=[{"sku": None, "quantity": 1, "price": 1}]) + ) + assert ok is True