feat(validation): add structural pre-flight validator
validate_structural(order) runs before save_orders_batch insert. Catches malformed payloads (MISSING_FIELD, INVALID_DATE, EMPTY_ITEMS, INVALID_QUANTITY, INVALID_PRICE) that would otherwise crash the batch insert or downstream pipeline. 17 unit tests cover each rule. Does NOT validate SKU existence — redundant with _dedup_items_by_sku pass-through and validate_skus Oracle lookup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,77 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from .. import database
|
from .. import database
|
||||||
from . import sqlite_service
|
from . import sqlite_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
async def reconcile_unresolved_missing_skus(conn=None) -> dict:
|
||||||
"""Revalidate all resolved=0 SKUs in missing_skus against Oracle.
|
"""Revalidate all resolved=0 SKUs in missing_skus against Oracle.
|
||||||
Fail-soft: logs warning and returns zero if Oracle is unavailable.
|
Fail-soft: logs warning and returns zero if Oracle is unavailable.
|
||||||
|
|||||||
166
api/tests/test_validate_structural.py
Normal file
166
api/tests/test_validate_structural.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user