Update all test files to import from canonical module locations: - Integration tests: orders, inventory, messages, invoices - Unit tests: services and models - Fixtures: customer, vendor, message fixtures Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
612 lines
21 KiB
Python
612 lines
21 KiB
Python
# tests/unit/models/schema/test_order.py
|
|
"""Unit tests for order Pydantic schemas."""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.modules.orders.schemas import (
|
|
AddressSnapshot,
|
|
AddressSnapshotResponse,
|
|
CustomerSnapshot,
|
|
CustomerSnapshotResponse,
|
|
OrderCreate,
|
|
OrderItemCreate,
|
|
OrderItemResponse,
|
|
OrderListResponse,
|
|
OrderResponse,
|
|
OrderUpdate,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestOrderItemCreateSchema:
|
|
"""Test OrderItemCreate schema validation."""
|
|
|
|
def test_valid_order_item(self):
|
|
"""Test valid order item creation."""
|
|
item = OrderItemCreate(
|
|
product_id=1,
|
|
quantity=2,
|
|
)
|
|
assert item.product_id == 1
|
|
assert item.quantity == 2
|
|
|
|
def test_product_id_required(self):
|
|
"""Test product_id is required."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderItemCreate(quantity=2)
|
|
assert "product_id" in str(exc_info.value).lower()
|
|
|
|
def test_quantity_required(self):
|
|
"""Test quantity is required."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderItemCreate(product_id=1)
|
|
assert "quantity" in str(exc_info.value).lower()
|
|
|
|
def test_quantity_must_be_at_least_1(self):
|
|
"""Test quantity must be >= 1."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderItemCreate(
|
|
product_id=1,
|
|
quantity=0,
|
|
)
|
|
assert "quantity" in str(exc_info.value).lower()
|
|
|
|
def test_negative_quantity_invalid(self):
|
|
"""Test negative quantity is invalid."""
|
|
with pytest.raises(ValidationError):
|
|
OrderItemCreate(
|
|
product_id=1,
|
|
quantity=-1,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestAddressSnapshotSchema:
|
|
"""Test AddressSnapshot schema validation."""
|
|
|
|
def test_valid_address(self):
|
|
"""Test valid address creation."""
|
|
address = AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
)
|
|
assert address.first_name == "John"
|
|
assert address.city == "Luxembourg"
|
|
assert address.country_iso == "LU"
|
|
|
|
def test_required_fields(self):
|
|
"""Test required fields validation."""
|
|
with pytest.raises(ValidationError):
|
|
AddressSnapshot(
|
|
first_name="John",
|
|
# missing required fields
|
|
)
|
|
|
|
def test_optional_company(self):
|
|
"""Test optional company field."""
|
|
address = AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
company="Tech Corp",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
)
|
|
assert address.company == "Tech Corp"
|
|
|
|
def test_optional_address_line_2(self):
|
|
"""Test optional address_line_2 field."""
|
|
address = AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
address_line_2="Suite 500",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
)
|
|
assert address.address_line_2 == "Suite 500"
|
|
|
|
def test_first_name_min_length(self):
|
|
"""Test first_name minimum length."""
|
|
with pytest.raises(ValidationError):
|
|
AddressSnapshot(
|
|
first_name="",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
)
|
|
|
|
def test_country_iso_min_length(self):
|
|
"""Test country_iso minimum length (2)."""
|
|
with pytest.raises(ValidationError):
|
|
AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="L", # Too short
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestAddressSnapshotResponseSchema:
|
|
"""Test AddressSnapshotResponse schema."""
|
|
|
|
def test_full_name_property(self):
|
|
"""Test full_name property."""
|
|
response = AddressSnapshotResponse(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
company=None,
|
|
address_line_1="123 Main St",
|
|
address_line_2=None,
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
)
|
|
assert response.full_name == "John Doe"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestCustomerSnapshotSchema:
|
|
"""Test CustomerSnapshot schema validation."""
|
|
|
|
def test_valid_customer(self):
|
|
"""Test valid customer snapshot."""
|
|
customer = CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
phone="+352123456",
|
|
locale="en",
|
|
)
|
|
assert customer.first_name == "John"
|
|
assert customer.email == "john@example.com"
|
|
|
|
def test_optional_phone(self):
|
|
"""Test phone is optional."""
|
|
customer = CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
)
|
|
assert customer.phone is None
|
|
|
|
def test_optional_locale(self):
|
|
"""Test locale is optional."""
|
|
customer = CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
)
|
|
assert customer.locale is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestCustomerSnapshotResponseSchema:
|
|
"""Test CustomerSnapshotResponse schema."""
|
|
|
|
def test_full_name_property(self):
|
|
"""Test full_name property."""
|
|
response = CustomerSnapshotResponse(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
phone=None,
|
|
locale=None,
|
|
)
|
|
assert response.full_name == "John Doe"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestOrderCreateSchema:
|
|
"""Test OrderCreate schema validation."""
|
|
|
|
def test_valid_order(self):
|
|
"""Test valid order creation."""
|
|
order = OrderCreate(
|
|
items=[
|
|
OrderItemCreate(product_id=1, quantity=2),
|
|
OrderItemCreate(product_id=2, quantity=1),
|
|
],
|
|
customer=CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
),
|
|
shipping_address=AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
),
|
|
)
|
|
assert len(order.items) == 2
|
|
assert order.customer_id is None # Optional for guest checkout
|
|
|
|
def test_items_required(self):
|
|
"""Test items are required."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderCreate(
|
|
customer=CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
),
|
|
shipping_address=AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
),
|
|
)
|
|
assert "items" in str(exc_info.value).lower()
|
|
|
|
def test_items_must_not_be_empty(self):
|
|
"""Test items list must have at least 1 item."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderCreate(
|
|
items=[],
|
|
customer=CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
),
|
|
shipping_address=AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
),
|
|
)
|
|
assert "items" in str(exc_info.value).lower()
|
|
|
|
def test_customer_required(self):
|
|
"""Test customer is required."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderCreate(
|
|
items=[OrderItemCreate(product_id=1, quantity=1)],
|
|
shipping_address=AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
),
|
|
)
|
|
assert "customer" in str(exc_info.value).lower()
|
|
|
|
def test_shipping_address_required(self):
|
|
"""Test shipping_address is required."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderCreate(
|
|
items=[OrderItemCreate(product_id=1, quantity=1)],
|
|
customer=CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
),
|
|
)
|
|
assert "shipping_address" in str(exc_info.value).lower()
|
|
|
|
def test_optional_billing_address(self):
|
|
"""Test billing_address is optional."""
|
|
order = OrderCreate(
|
|
items=[OrderItemCreate(product_id=1, quantity=1)],
|
|
customer=CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
),
|
|
shipping_address=AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
),
|
|
billing_address=AddressSnapshot(
|
|
first_name="Jane",
|
|
last_name="Doe",
|
|
address_line_1="456 Other St",
|
|
city="Esch",
|
|
postal_code="L-4321",
|
|
country_iso="LU",
|
|
),
|
|
)
|
|
assert order.billing_address is not None
|
|
assert order.billing_address.first_name == "Jane"
|
|
|
|
def test_optional_customer_notes(self):
|
|
"""Test optional customer_notes."""
|
|
order = OrderCreate(
|
|
items=[OrderItemCreate(product_id=1, quantity=1)],
|
|
customer=CustomerSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
email="john@example.com",
|
|
),
|
|
shipping_address=AddressSnapshot(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
address_line_1="123 Main St",
|
|
city="Luxembourg",
|
|
postal_code="L-1234",
|
|
country_iso="LU",
|
|
),
|
|
customer_notes="Please leave at door",
|
|
)
|
|
assert order.customer_notes == "Please leave at door"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestOrderUpdateSchema:
|
|
"""Test OrderUpdate schema validation."""
|
|
|
|
def test_status_update(self):
|
|
"""Test valid status update."""
|
|
update = OrderUpdate(status="processing")
|
|
assert update.status == "processing"
|
|
|
|
def test_valid_status_values(self):
|
|
"""Test all valid status values."""
|
|
valid_statuses = [
|
|
"pending",
|
|
"processing",
|
|
"shipped",
|
|
"delivered",
|
|
"cancelled",
|
|
"refunded",
|
|
]
|
|
for status in valid_statuses:
|
|
update = OrderUpdate(status=status)
|
|
assert update.status == status
|
|
|
|
def test_invalid_status(self):
|
|
"""Test invalid status raises ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
OrderUpdate(status="invalid_status")
|
|
assert "status" in str(exc_info.value).lower()
|
|
|
|
def test_tracking_number_update(self):
|
|
"""Test tracking number update."""
|
|
update = OrderUpdate(tracking_number="TRACK123456")
|
|
assert update.tracking_number == "TRACK123456"
|
|
|
|
def test_internal_notes_update(self):
|
|
"""Test internal notes update."""
|
|
update = OrderUpdate(internal_notes="Customer requested expedited shipping")
|
|
assert update.internal_notes == "Customer requested expedited shipping"
|
|
|
|
def test_empty_update_is_valid(self):
|
|
"""Test empty update is valid."""
|
|
update = OrderUpdate()
|
|
assert update.model_dump(exclude_unset=True) == {}
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestOrderResponseSchema:
|
|
"""Test OrderResponse schema."""
|
|
|
|
def test_from_dict(self):
|
|
"""Test creating response from dict."""
|
|
now = datetime.now(timezone.utc)
|
|
data = {
|
|
"id": 1,
|
|
"vendor_id": 1,
|
|
"customer_id": 1,
|
|
"order_number": "ORD-001",
|
|
"channel": "direct",
|
|
"status": "pending",
|
|
"subtotal": 100.00,
|
|
"tax_amount": 20.00,
|
|
"shipping_amount": 10.00,
|
|
"discount_amount": 5.00,
|
|
"total_amount": 125.00,
|
|
"currency": "EUR",
|
|
# Customer snapshot
|
|
"customer_first_name": "John",
|
|
"customer_last_name": "Doe",
|
|
"customer_email": "john@example.com",
|
|
"customer_phone": None,
|
|
"customer_locale": "en",
|
|
# Ship address snapshot
|
|
"ship_first_name": "John",
|
|
"ship_last_name": "Doe",
|
|
"ship_company": None,
|
|
"ship_address_line_1": "123 Main St",
|
|
"ship_address_line_2": None,
|
|
"ship_city": "Luxembourg",
|
|
"ship_postal_code": "L-1234",
|
|
"ship_country_iso": "LU",
|
|
# Bill address snapshot
|
|
"bill_first_name": "John",
|
|
"bill_last_name": "Doe",
|
|
"bill_company": None,
|
|
"bill_address_line_1": "123 Main St",
|
|
"bill_address_line_2": None,
|
|
"bill_city": "Luxembourg",
|
|
"bill_postal_code": "L-1234",
|
|
"bill_country_iso": "LU",
|
|
# Tracking
|
|
"shipping_method": "standard",
|
|
"tracking_number": None,
|
|
"tracking_provider": None,
|
|
# Notes
|
|
"customer_notes": None,
|
|
"internal_notes": None,
|
|
# Timestamps
|
|
"order_date": now,
|
|
"confirmed_at": None,
|
|
"shipped_at": None,
|
|
"delivered_at": None,
|
|
"cancelled_at": None,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
response = OrderResponse(**data)
|
|
assert response.id == 1
|
|
assert response.order_number == "ORD-001"
|
|
assert response.total_amount == 125.00
|
|
assert response.channel == "direct"
|
|
assert response.customer_full_name == "John Doe"
|
|
|
|
def test_is_marketplace_order(self):
|
|
"""Test is_marketplace_order property."""
|
|
now = datetime.now(timezone.utc)
|
|
# Direct order
|
|
direct_order = OrderResponse(
|
|
id=1, vendor_id=1, customer_id=1, order_number="ORD-001",
|
|
channel="direct", status="pending",
|
|
subtotal=100.0, tax_amount=0.0, shipping_amount=0.0, discount_amount=0.0,
|
|
total_amount=100.0, currency="EUR",
|
|
customer_first_name="John", customer_last_name="Doe",
|
|
customer_email="john@example.com", customer_phone=None, customer_locale=None,
|
|
ship_first_name="John", ship_last_name="Doe", ship_company=None,
|
|
ship_address_line_1="123 Main", ship_address_line_2=None,
|
|
ship_city="Luxembourg", ship_postal_code="L-1234", ship_country_iso="LU",
|
|
bill_first_name="John", bill_last_name="Doe", bill_company=None,
|
|
bill_address_line_1="123 Main", bill_address_line_2=None,
|
|
bill_city="Luxembourg", bill_postal_code="L-1234", bill_country_iso="LU",
|
|
shipping_method=None, tracking_number=None, tracking_provider=None,
|
|
customer_notes=None, internal_notes=None,
|
|
order_date=now, confirmed_at=None, shipped_at=None,
|
|
delivered_at=None, cancelled_at=None, created_at=now, updated_at=now,
|
|
)
|
|
assert direct_order.is_marketplace_order is False
|
|
|
|
# Marketplace order
|
|
marketplace_order = OrderResponse(
|
|
id=2, vendor_id=1, customer_id=1, order_number="LS-001",
|
|
channel="letzshop", status="pending",
|
|
subtotal=100.0, tax_amount=0.0, shipping_amount=0.0, discount_amount=0.0,
|
|
total_amount=100.0, currency="EUR",
|
|
customer_first_name="John", customer_last_name="Doe",
|
|
customer_email="john@example.com", customer_phone=None, customer_locale=None,
|
|
ship_first_name="John", ship_last_name="Doe", ship_company=None,
|
|
ship_address_line_1="123 Main", ship_address_line_2=None,
|
|
ship_city="Luxembourg", ship_postal_code="L-1234", ship_country_iso="LU",
|
|
bill_first_name="John", bill_last_name="Doe", bill_company=None,
|
|
bill_address_line_1="123 Main", bill_address_line_2=None,
|
|
bill_city="Luxembourg", bill_postal_code="L-1234", bill_country_iso="LU",
|
|
shipping_method=None, tracking_number=None, tracking_provider=None,
|
|
customer_notes=None, internal_notes=None,
|
|
order_date=now, confirmed_at=None, shipped_at=None,
|
|
delivered_at=None, cancelled_at=None, created_at=now, updated_at=now,
|
|
)
|
|
assert marketplace_order.is_marketplace_order is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestOrderItemResponseSchema:
|
|
"""Test OrderItemResponse schema."""
|
|
|
|
def test_from_dict(self):
|
|
"""Test creating response from dict."""
|
|
now = datetime.now(timezone.utc)
|
|
data = {
|
|
"id": 1,
|
|
"order_id": 1,
|
|
"product_id": 1,
|
|
"product_name": "Test Product",
|
|
"product_sku": "SKU-001",
|
|
"gtin": "4006381333931",
|
|
"gtin_type": "EAN13",
|
|
"quantity": 2,
|
|
"unit_price": 50.00,
|
|
"total_price": 100.00,
|
|
"inventory_reserved": True,
|
|
"inventory_fulfilled": False,
|
|
"needs_product_match": False,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
response = OrderItemResponse(**data)
|
|
assert response.id == 1
|
|
assert response.quantity == 2
|
|
assert response.total_price == 100.00
|
|
assert response.gtin == "4006381333931"
|
|
|
|
def test_has_unresolved_exception(self):
|
|
"""Test has_unresolved_exception property."""
|
|
now = datetime.now(timezone.utc)
|
|
base_data = {
|
|
"id": 1, "order_id": 1, "product_id": 1,
|
|
"product_name": "Test", "product_sku": "SKU-001",
|
|
"gtin": None, "gtin_type": None,
|
|
"quantity": 1, "unit_price": 10.0, "total_price": 10.0,
|
|
"inventory_reserved": False, "inventory_fulfilled": False,
|
|
"created_at": now, "updated_at": now,
|
|
}
|
|
|
|
# No exception
|
|
response = OrderItemResponse(**base_data, needs_product_match=False, exception=None)
|
|
assert response.has_unresolved_exception is False
|
|
|
|
# Pending exception
|
|
from app.modules.orders.schemas import OrderItemExceptionBrief
|
|
pending_exc = OrderItemExceptionBrief(
|
|
id=1, original_gtin="123", original_product_name="Test",
|
|
exception_type="product_not_found", status="pending",
|
|
resolved_product_id=None,
|
|
)
|
|
response = OrderItemResponse(**base_data, needs_product_match=True, exception=pending_exc)
|
|
assert response.has_unresolved_exception is True
|
|
|
|
# Resolved exception
|
|
resolved_exc = OrderItemExceptionBrief(
|
|
id=1, original_gtin="123", original_product_name="Test",
|
|
exception_type="product_not_found", status="resolved",
|
|
resolved_product_id=5,
|
|
)
|
|
response = OrderItemResponse(**base_data, needs_product_match=False, exception=resolved_exc)
|
|
assert response.has_unresolved_exception is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
class TestOrderListResponseSchema:
|
|
"""Test OrderListResponse schema."""
|
|
|
|
def test_valid_list_response(self):
|
|
"""Test valid list response structure."""
|
|
response = OrderListResponse(
|
|
orders=[],
|
|
total=0,
|
|
skip=0,
|
|
limit=10,
|
|
)
|
|
assert response.orders == []
|
|
assert response.total == 0
|
|
assert response.skip == 0
|
|
assert response.limit == 10
|