fix(billing): complete billing module — fix tier change, platform support, merchant portal

- Fix admin tier change: resolve tier_code→tier_id in update_subscription(),
  delegate to billing_service.change_tier() for Stripe-connected subs
- Add platform support to admin tiers page: platform column, filter dropdown,
  platform selector in create/edit modal, platform_name in tier API response
- Filter used platforms in create subscription modal on merchant detail page
- Enrich merchant portal API responses with tier code, tier_name, platform_name
- Add eager-load of platform relationship in get_merchant_subscription()
- Remove stale store_name/store_code references from merchant templates
- Add merchant tier change endpoint (POST /change-tier) and tier selector UI
  replacing broken requestUpgrade() button
- Fix subscription detail link to use platform_id instead of sub.id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 20:49:48 +01:00
parent 0b37274140
commit d1fe3584ff
54 changed files with 222 additions and 52 deletions

View File

@@ -0,0 +1,280 @@
# tests/unit/services/test_admin_customer_service.py
"""
Unit tests for AdminCustomerService.
"""
from decimal import Decimal
import pytest
from app.modules.customers.exceptions import CustomerNotFoundException
from app.modules.customers.services.admin_customer_service import AdminCustomerService
from app.modules.customers.models.customer import Customer
@pytest.fixture
def admin_customer_service():
"""Create AdminCustomerService instance."""
return AdminCustomerService()
@pytest.fixture
def customer_with_orders(db, test_store, test_customer):
"""Create a customer with order data."""
test_customer.total_orders = 5
test_customer.total_spent = Decimal("250.00")
db.commit()
db.refresh(test_customer)
return test_customer
@pytest.fixture
def multiple_customers(db, test_store):
"""Create multiple customers for testing."""
customers = []
for i in range(5):
customer = Customer(
store_id=test_store.id,
email=f"customer{i}@example.com",
hashed_password="hashed_password_placeholder",
first_name=f"First{i}",
last_name=f"Last{i}",
customer_number=f"CUST-00{i}",
is_active=(i % 2 == 0), # Alternate active/inactive
total_orders=i,
total_spent=Decimal(str(i * 100)),
)
db.add(customer)
customers.append(customer)
db.commit()
for c in customers:
db.refresh(c)
return customers
@pytest.mark.unit
class TestAdminCustomerServiceList:
"""Tests for list_customers method."""
def test_list_customers_empty(self, db, admin_customer_service, test_store):
"""Test listing customers when none exist."""
customers, total = admin_customer_service.list_customers(db)
assert customers == []
assert total == 0
def test_list_customers_basic(self, db, admin_customer_service, test_customer):
"""Test basic customer listing."""
customers, total = admin_customer_service.list_customers(db)
assert total == 1
assert len(customers) == 1
assert customers[0]["id"] == test_customer.id
assert customers[0]["email"] == test_customer.email
def test_list_customers_with_store_info(
self, db, admin_customer_service, test_customer, test_store
):
"""Test that store info is included."""
customers, total = admin_customer_service.list_customers(db)
assert customers[0]["store_name"] == test_store.name
assert customers[0]["store_code"] == test_store.store_code
def test_list_customers_filter_by_store(
self, db, admin_customer_service, multiple_customers, test_store
):
"""Test filtering by store ID."""
customers, total = admin_customer_service.list_customers(
db, store_id=test_store.id
)
assert total == 5
for customer in customers:
assert customer["store_id"] == test_store.id
def test_list_customers_filter_by_active_status(
self, db, admin_customer_service, multiple_customers
):
"""Test filtering by active status."""
# Get active customers (0, 2, 4 = 3 customers)
customers, total = admin_customer_service.list_customers(db, is_active=True)
assert total == 3
# Get inactive customers (1, 3 = 2 customers)
customers, total = admin_customer_service.list_customers(db, is_active=False)
assert total == 2
def test_list_customers_search_by_email(
self, db, admin_customer_service, multiple_customers
):
"""Test searching by email."""
customers, total = admin_customer_service.list_customers(
db, search="customer2@"
)
assert total == 1
assert customers[0]["email"] == "customer2@example.com"
def test_list_customers_search_by_name(
self, db, admin_customer_service, multiple_customers
):
"""Test searching by name."""
customers, total = admin_customer_service.list_customers(db, search="First3")
assert total == 1
assert customers[0]["first_name"] == "First3"
def test_list_customers_search_by_customer_number(
self, db, admin_customer_service, multiple_customers
):
"""Test searching by customer number."""
customers, total = admin_customer_service.list_customers(db, search="CUST-001")
assert total == 1
assert customers[0]["customer_number"] == "CUST-001"
def test_list_customers_pagination(
self, db, admin_customer_service, multiple_customers
):
"""Test pagination."""
# Get first page
customers, total = admin_customer_service.list_customers(db, skip=0, limit=2)
assert len(customers) == 2
assert total == 5
# Get second page
customers, total = admin_customer_service.list_customers(db, skip=2, limit=2)
assert len(customers) == 2
# Get last page
customers, total = admin_customer_service.list_customers(db, skip=4, limit=2)
assert len(customers) == 1
@pytest.mark.unit
class TestAdminCustomerServiceStats:
"""Tests for get_customer_stats method."""
def test_get_customer_stats_empty(self, db, admin_customer_service, test_store):
"""Test stats when no customers exist."""
stats = admin_customer_service.get_customer_stats(db)
assert stats["total"] == 0
assert stats["active"] == 0
assert stats["inactive"] == 0
assert stats["with_orders"] == 0
assert stats["total_spent"] == 0
assert stats["total_orders"] == 0
assert stats["avg_order_value"] == 0
def test_get_customer_stats_with_data(
self, db, admin_customer_service, multiple_customers
):
"""Test stats with customer data."""
stats = admin_customer_service.get_customer_stats(db)
assert stats["total"] == 5
assert stats["active"] == 3 # 0, 2, 4
assert stats["inactive"] == 2 # 1, 3
# with_orders = customers with total_orders > 0 (1, 2, 3, 4 = 4 customers)
assert stats["with_orders"] == 4
# total_spent = 0 + 100 + 200 + 300 + 400 = 1000
assert stats["total_spent"] == 1000.0
# total_orders = 0 + 1 + 2 + 3 + 4 = 10
assert stats["total_orders"] == 10
def test_get_customer_stats_by_store(
self, db, admin_customer_service, test_customer, test_store
):
"""Test stats filtered by store."""
stats = admin_customer_service.get_customer_stats(db, store_id=test_store.id)
assert stats["total"] == 1
def test_get_customer_stats_avg_order_value(
self, db, admin_customer_service, customer_with_orders
):
"""Test average order value calculation."""
stats = admin_customer_service.get_customer_stats(db)
# total_spent = 250, total_orders = 5
# avg = 250 / 5 = 50
assert stats["avg_order_value"] == 50.0
@pytest.mark.unit
class TestAdminCustomerServiceGetCustomer:
"""Tests for get_customer method."""
def test_get_customer_success(self, db, admin_customer_service, test_customer):
"""Test getting customer by ID."""
customer = admin_customer_service.get_customer(db, test_customer.id)
assert customer["id"] == test_customer.id
assert customer["email"] == test_customer.email
assert customer["first_name"] == test_customer.first_name
assert customer["last_name"] == test_customer.last_name
def test_get_customer_with_store_info(
self, db, admin_customer_service, test_customer, test_store
):
"""Test store info in customer detail."""
customer = admin_customer_service.get_customer(db, test_customer.id)
assert customer["store_name"] == test_store.name
assert customer["store_code"] == test_store.store_code
def test_get_customer_not_found(self, db, admin_customer_service):
"""Test error when customer not found."""
with pytest.raises(CustomerNotFoundException):
admin_customer_service.get_customer(db, 99999)
@pytest.mark.unit
class TestAdminCustomerServiceToggleStatus:
"""Tests for toggle_customer_status method."""
def test_toggle_status_activate(
self, db, admin_customer_service, test_customer, test_admin
):
"""Test activating an inactive customer."""
# Make customer inactive first
test_customer.is_active = False
db.commit()
result = admin_customer_service.toggle_customer_status(
db, test_customer.id, test_admin.email
)
db.commit()
assert result["id"] == test_customer.id
assert result["is_active"] is True
assert "activated" in result["message"]
def test_toggle_status_deactivate(
self, db, admin_customer_service, test_customer, test_admin
):
"""Test deactivating an active customer."""
test_customer.is_active = True
db.commit()
result = admin_customer_service.toggle_customer_status(
db, test_customer.id, test_admin.email
)
db.commit()
assert result["id"] == test_customer.id
assert result["is_active"] is False
assert "deactivated" in result["message"]
def test_toggle_status_not_found(
self, db, admin_customer_service, test_admin
):
"""Test error when customer not found."""
with pytest.raises(CustomerNotFoundException):
admin_customer_service.toggle_customer_status(
db, 99999, test_admin.email
)

View File

@@ -0,0 +1,453 @@
# tests/unit/services/test_customer_address_service.py
"""
Unit tests for CustomerAddressService.
"""
import pytest
from app.modules.customers.exceptions import AddressLimitExceededException, AddressNotFoundException
from app.modules.customers.services.customer_address_service import CustomerAddressService
from app.modules.customers.models.customer import CustomerAddress
from app.modules.customers.schemas import CustomerAddressCreate, CustomerAddressUpdate
@pytest.fixture
def address_service():
"""Create CustomerAddressService instance."""
return CustomerAddressService()
@pytest.fixture
def multiple_addresses(db, test_store, test_customer):
"""Create multiple addresses for testing."""
addresses = []
for i in range(3):
address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping" if i < 2 else "billing",
first_name=f"First{i}",
last_name=f"Last{i}",
address_line_1=f"{i+1} Test Street",
city="Luxembourg",
postal_code=f"L-{1000+i}",
country_name="Luxembourg",
country_iso="LU",
is_default=(i == 0), # First shipping is default
)
db.add(address)
addresses.append(address)
db.commit()
for a in addresses:
db.refresh(a)
return addresses
@pytest.mark.unit
class TestCustomerAddressServiceList:
"""Tests for list_addresses method."""
def test_list_addresses_empty(self, db, address_service, test_store, test_customer):
"""Test listing addresses when none exist."""
addresses = address_service.list_addresses(
db, store_id=test_store.id, customer_id=test_customer.id
)
assert addresses == []
def test_list_addresses_basic(
self, db, address_service, test_store, test_customer, test_customer_address
):
"""Test basic address listing."""
addresses = address_service.list_addresses(
db, store_id=test_store.id, customer_id=test_customer.id
)
assert len(addresses) == 1
assert addresses[0].id == test_customer_address.id
def test_list_addresses_ordered_by_default(
self, db, address_service, test_store, test_customer, multiple_addresses
):
"""Test addresses are ordered by default flag first."""
addresses = address_service.list_addresses(
db, store_id=test_store.id, customer_id=test_customer.id
)
# Default address should be first
assert addresses[0].is_default is True
def test_list_addresses_store_isolation(
self, db, address_service, test_store, test_customer, test_customer_address
):
"""Test addresses are isolated by store."""
# Query with different store ID
addresses = address_service.list_addresses(
db, store_id=99999, customer_id=test_customer.id
)
assert addresses == []
@pytest.mark.unit
class TestCustomerAddressServiceGet:
"""Tests for get_address method."""
def test_get_address_success(
self, db, address_service, test_store, test_customer, test_customer_address
):
"""Test getting address by ID."""
address = address_service.get_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=test_customer_address.id,
)
assert address.id == test_customer_address.id
assert address.first_name == test_customer_address.first_name
def test_get_address_not_found(
self, db, address_service, test_store, test_customer
):
"""Test error when address not found."""
with pytest.raises(AddressNotFoundException):
address_service.get_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=99999,
)
def test_get_address_wrong_customer(
self, db, address_service, test_store, test_customer, test_customer_address
):
"""Test cannot get another customer's address."""
with pytest.raises(AddressNotFoundException):
address_service.get_address(
db,
store_id=test_store.id,
customer_id=99999, # Different customer
address_id=test_customer_address.id,
)
@pytest.mark.unit
class TestCustomerAddressServiceGetDefault:
"""Tests for get_default_address method."""
def test_get_default_address_exists(
self, db, address_service, test_store, test_customer, multiple_addresses
):
"""Test getting default shipping address."""
address = address_service.get_default_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
)
assert address is not None
assert address.is_default is True
assert address.address_type == "shipping"
def test_get_default_address_not_set(
self, db, address_service, test_store, test_customer, multiple_addresses
):
"""Test getting default billing when none is set."""
# Remove default from billing (none was set as default)
address = address_service.get_default_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_type="billing",
)
# The billing address exists but is not default
assert address is None
@pytest.mark.unit
class TestCustomerAddressServiceCreate:
"""Tests for create_address method."""
def test_create_address_success(
self, db, address_service, test_store, test_customer
):
"""Test creating a new address."""
address_data = CustomerAddressCreate(
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 New Street",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
address = address_service.create_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_data=address_data,
)
db.commit()
assert address.id is not None
assert address.first_name == "John"
assert address.last_name == "Doe"
assert address.country_iso == "LU"
assert address.country_name == "Luxembourg"
def test_create_address_with_merchant(
self, db, address_service, test_store, test_customer
):
"""Test creating address with merchant name."""
address_data = CustomerAddressCreate(
address_type="billing",
first_name="Jane",
last_name="Doe",
company="Acme Corp",
address_line_1="456 Business Ave",
city="Luxembourg",
postal_code="L-5678",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
address = address_service.create_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_data=address_data,
)
db.commit()
assert address.company == "Acme Corp"
def test_create_address_default_clears_others(
self, db, address_service, test_store, test_customer, multiple_addresses
):
"""Test creating default address clears other defaults of same type."""
# First address is default shipping
assert multiple_addresses[0].is_default is True
address_data = CustomerAddressCreate(
address_type="shipping",
first_name="New",
last_name="Default",
address_line_1="789 Main St",
city="Luxembourg",
postal_code="L-9999",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
new_address = address_service.create_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_data=address_data,
)
db.commit()
# New address should be default
assert new_address.is_default is True
# Old default should be cleared
db.refresh(multiple_addresses[0])
assert multiple_addresses[0].is_default is False
def test_create_address_limit_exceeded(
self, db, address_service, test_store, test_customer
):
"""Test error when max addresses reached."""
# Create 10 addresses (max limit)
for i in range(10):
addr = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
first_name=f"Test{i}",
last_name="User",
address_line_1=f"{i} Street",
city="City",
postal_code="12345",
country_name="Luxembourg",
country_iso="LU",
)
db.add(addr)
db.commit()
# Try to create 11th address
address_data = CustomerAddressCreate(
address_type="shipping",
first_name="Eleventh",
last_name="User",
address_line_1="11 Street",
city="City",
postal_code="12345",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
with pytest.raises(AddressLimitExceededException):
address_service.create_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_data=address_data,
)
@pytest.mark.unit
class TestCustomerAddressServiceUpdate:
"""Tests for update_address method."""
def test_update_address_success(
self, db, address_service, test_store, test_customer, test_customer_address
):
"""Test updating an address."""
update_data = CustomerAddressUpdate(
first_name="Updated",
city="New City",
)
address = address_service.update_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=test_customer_address.id,
address_data=update_data,
)
db.commit()
assert address.first_name == "Updated"
assert address.city == "New City"
# Unchanged fields should remain
assert address.last_name == test_customer_address.last_name
def test_update_address_set_default(
self, db, address_service, test_store, test_customer, multiple_addresses
):
"""Test setting address as default clears others."""
# Second address is not default
assert multiple_addresses[1].is_default is False
update_data = CustomerAddressUpdate(is_default=True)
address = address_service.update_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=multiple_addresses[1].id,
address_data=update_data,
)
db.commit()
assert address.is_default is True
# Old default should be cleared
db.refresh(multiple_addresses[0])
assert multiple_addresses[0].is_default is False
def test_update_address_not_found(
self, db, address_service, test_store, test_customer
):
"""Test error when address not found."""
update_data = CustomerAddressUpdate(first_name="Test")
with pytest.raises(AddressNotFoundException):
address_service.update_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=99999,
address_data=update_data,
)
@pytest.mark.unit
class TestCustomerAddressServiceDelete:
"""Tests for delete_address method."""
def test_delete_address_success(
self, db, address_service, test_store, test_customer, test_customer_address
):
"""Test deleting an address."""
address_id = test_customer_address.id
address_service.delete_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=address_id,
)
db.commit()
# Address should be gone
with pytest.raises(AddressNotFoundException):
address_service.get_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=address_id,
)
def test_delete_address_not_found(
self, db, address_service, test_store, test_customer
):
"""Test error when deleting non-existent address."""
with pytest.raises(AddressNotFoundException):
address_service.delete_address(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=99999,
)
@pytest.mark.unit
class TestCustomerAddressServiceSetDefault:
"""Tests for set_default method."""
def test_set_default_success(
self, db, address_service, test_store, test_customer, multiple_addresses
):
"""Test setting address as default."""
# Second shipping address is not default
assert multiple_addresses[1].is_default is False
assert multiple_addresses[1].address_type == "shipping"
address = address_service.set_default(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=multiple_addresses[1].id,
)
db.commit()
assert address.is_default is True
# Old default should be cleared
db.refresh(multiple_addresses[0])
assert multiple_addresses[0].is_default is False
def test_set_default_not_found(
self, db, address_service, test_store, test_customer
):
"""Test error when address not found."""
with pytest.raises(AddressNotFoundException):
address_service.set_default(
db,
store_id=test_store.id,
customer_id=test_customer.id,
address_id=99999,
)

View File

@@ -0,0 +1,247 @@
# tests/unit/models/database/test_customer.py
"""Unit tests for Customer and CustomerAddress database models."""
import pytest
from app.modules.customers.models.customer import Customer, CustomerAddress
@pytest.mark.unit
@pytest.mark.database
class TestCustomerModel:
"""Test Customer model."""
def test_customer_creation(self, db, test_store):
"""Test Customer model with store isolation."""
customer = Customer(
store_id=test_store.id,
email="customer@example.com",
hashed_password="hashed_password",
first_name="John",
last_name="Doe",
customer_number="CUST001",
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
assert customer.id is not None
assert customer.store_id == test_store.id
assert customer.email == "customer@example.com"
assert customer.customer_number == "CUST001"
assert customer.first_name == "John"
assert customer.last_name == "Doe"
assert customer.store.store_code == test_store.store_code
def test_customer_default_values(self, db, test_store):
"""Test Customer model default values."""
customer = Customer(
store_id=test_store.id,
email="defaults@example.com",
hashed_password="hash",
customer_number="CUST_DEFAULTS",
)
db.add(customer)
db.commit()
db.refresh(customer)
assert customer.is_active is True # Default
assert customer.marketing_consent is False # Default
assert customer.total_orders == 0 # Default
assert customer.total_spent == 0 # Default
def test_customer_full_name_property(self, db, test_store):
"""Test Customer full_name computed property."""
customer = Customer(
store_id=test_store.id,
email="fullname@example.com",
hashed_password="hash",
customer_number="CUST_FULLNAME",
first_name="Jane",
last_name="Smith",
)
db.add(customer)
db.commit()
db.refresh(customer)
assert customer.full_name == "Jane Smith"
def test_customer_full_name_fallback_to_email(self, db, test_store):
"""Test Customer full_name falls back to email when names not set."""
customer = Customer(
store_id=test_store.id,
email="noname@example.com",
hashed_password="hash",
customer_number="CUST_NONAME",
)
db.add(customer)
db.commit()
db.refresh(customer)
assert customer.full_name == "noname@example.com"
def test_customer_optional_fields(self, db, test_store):
"""Test Customer with optional fields."""
customer = Customer(
store_id=test_store.id,
email="optional@example.com",
hashed_password="hash",
customer_number="CUST_OPT",
phone="+352123456789",
preferences={"language": "en", "currency": "EUR"},
marketing_consent=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
assert customer.phone == "+352123456789"
assert customer.preferences == {"language": "en", "currency": "EUR"}
assert customer.marketing_consent is True
def test_customer_store_relationship(self, db, test_store):
"""Test Customer-Store relationship."""
customer = Customer(
store_id=test_store.id,
email="relationship@example.com",
hashed_password="hash",
customer_number="CUST_REL",
)
db.add(customer)
db.commit()
db.refresh(customer)
assert customer.store is not None
assert customer.store.id == test_store.id
@pytest.mark.unit
@pytest.mark.database
class TestCustomerAddressModel:
"""Test CustomerAddress model."""
def test_customer_address_creation(self, db, test_store, test_customer):
"""Test CustomerAddress model."""
address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
db.add(address)
db.commit()
db.refresh(address)
assert address.id is not None
assert address.store_id == test_store.id
assert address.customer_id == test_customer.id
assert address.address_type == "shipping"
assert address.is_default is True
def test_customer_address_types(self, db, test_store, test_customer):
"""Test CustomerAddress with different address types."""
shipping_address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Shipping St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
db.add(shipping_address)
billing_address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="billing",
first_name="John",
last_name="Doe",
address_line_1="456 Billing Ave",
city="Luxembourg",
postal_code="L-5678",
country_name="Luxembourg",
country_iso="LU",
)
db.add(billing_address)
db.commit()
assert shipping_address.address_type == "shipping"
assert billing_address.address_type == "billing"
def test_customer_address_optional_fields(self, db, test_store, test_customer):
"""Test CustomerAddress with optional fields."""
address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
first_name="John",
last_name="Doe",
company="ACME Corp",
address_line_1="123 Main St",
address_line_2="Suite 100",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
db.add(address)
db.commit()
db.refresh(address)
assert address.company == "ACME Corp"
assert address.address_line_2 == "Suite 100"
def test_customer_address_default_values(self, db, test_store, test_customer):
"""Test CustomerAddress default values."""
address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
db.add(address)
db.commit()
db.refresh(address)
assert address.is_default is False # Default
def test_customer_address_relationships(self, db, test_store, test_customer):
"""Test CustomerAddress relationships."""
address = CustomerAddress(
store_id=test_store.id,
customer_id=test_customer.id,
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
db.add(address)
db.commit()
db.refresh(address)
assert address.customer is not None
assert address.customer.id == test_customer.id

View File

@@ -0,0 +1,358 @@
# tests/unit/models/schema/test_customer.py
"""Unit tests for customer Pydantic schemas."""
import pytest
from pydantic import ValidationError
from app.modules.customers.schemas import (
CustomerAddressCreate,
CustomerAddressResponse,
CustomerAddressUpdate,
CustomerPreferencesUpdate,
CustomerRegister,
CustomerResponse,
CustomerUpdate,
)
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerRegisterSchema:
"""Test CustomerRegister schema validation."""
def test_valid_registration(self):
"""Test valid registration data."""
customer = CustomerRegister(
email="customer@example.com",
password="Password123",
first_name="John",
last_name="Doe",
)
assert customer.email == "customer@example.com"
assert customer.first_name == "John"
assert customer.last_name == "Doe"
def test_email_normalized_to_lowercase(self):
"""Test email is normalized to lowercase."""
customer = CustomerRegister(
email="Customer@Example.COM",
password="Password123",
first_name="John",
last_name="Doe",
)
assert customer.email == "customer@example.com"
def test_invalid_email(self):
"""Test invalid email raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CustomerRegister(
email="not-an-email",
password="Password123",
first_name="John",
last_name="Doe",
)
assert "email" in str(exc_info.value).lower()
def test_password_min_length(self):
"""Test password must be at least 8 characters."""
with pytest.raises(ValidationError) as exc_info:
CustomerRegister(
email="customer@example.com",
password="Pass1",
first_name="John",
last_name="Doe",
)
assert "password" in str(exc_info.value).lower()
def test_password_requires_digit(self):
"""Test password must contain at least one digit."""
with pytest.raises(ValidationError) as exc_info:
CustomerRegister(
email="customer@example.com",
password="Password",
first_name="John",
last_name="Doe",
)
assert "digit" in str(exc_info.value).lower()
def test_password_requires_letter(self):
"""Test password must contain at least one letter."""
with pytest.raises(ValidationError) as exc_info:
CustomerRegister(
email="customer@example.com",
password="12345678",
first_name="John",
last_name="Doe",
)
assert "letter" in str(exc_info.value).lower()
def test_first_name_required(self):
"""Test first_name is required."""
with pytest.raises(ValidationError) as exc_info:
CustomerRegister(
email="customer@example.com",
password="Password123",
last_name="Doe",
)
assert "first_name" in str(exc_info.value).lower()
def test_marketing_consent_default(self):
"""Test marketing_consent defaults to False."""
customer = CustomerRegister(
email="customer@example.com",
password="Password123",
first_name="John",
last_name="Doe",
)
assert customer.marketing_consent is False
def test_optional_phone(self):
"""Test optional phone field."""
customer = CustomerRegister(
email="customer@example.com",
password="Password123",
first_name="John",
last_name="Doe",
phone="+352 123 456",
)
assert customer.phone == "+352 123 456"
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerUpdateSchema:
"""Test CustomerUpdate schema validation."""
def test_partial_update(self):
"""Test partial update with only some fields."""
update = CustomerUpdate(first_name="Jane")
assert update.first_name == "Jane"
assert update.last_name is None
assert update.email is None
def test_empty_update_is_valid(self):
"""Test empty update is valid."""
update = CustomerUpdate()
assert update.model_dump(exclude_unset=True) == {}
def test_email_normalized_to_lowercase(self):
"""Test email is normalized to lowercase."""
update = CustomerUpdate(email="NewEmail@Example.COM")
assert update.email == "newemail@example.com"
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerResponseSchema:
"""Test CustomerResponse schema."""
def test_from_dict(self):
"""Test creating response from dict."""
from datetime import datetime
from decimal import Decimal
data = {
"id": 1,
"store_id": 1,
"email": "customer@example.com",
"first_name": "John",
"last_name": "Doe",
"phone": None,
"customer_number": "CUST001",
"marketing_consent": False,
"preferred_language": "fr",
"last_order_date": None,
"total_orders": 5,
"total_spent": Decimal("500.00"),
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
response = CustomerResponse(**data)
assert response.id == 1
assert response.customer_number == "CUST001"
assert response.total_orders == 5
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerAddressCreateSchema:
"""Test CustomerAddressCreate schema validation."""
def test_valid_shipping_address(self):
"""Test valid shipping address creation."""
address = CustomerAddressCreate(
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
assert address.address_type == "shipping"
assert address.city == "Luxembourg"
def test_valid_billing_address(self):
"""Test valid billing address creation."""
address = CustomerAddressCreate(
address_type="billing",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
assert address.address_type == "billing"
def test_invalid_address_type(self):
"""Test invalid address_type raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CustomerAddressCreate(
address_type="delivery",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
assert "address_type" in str(exc_info.value).lower()
def test_is_default_defaults_to_false(self):
"""Test is_default defaults to False."""
address = CustomerAddressCreate(
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
assert address.is_default is False
def test_optional_address_line_2(self):
"""Test optional address_line_2 field."""
address = CustomerAddressCreate(
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
address_line_2="Apt 4B",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
)
assert address.address_line_2 == "Apt 4B"
def test_required_fields(self):
"""Test required fields."""
with pytest.raises(ValidationError):
CustomerAddressCreate(
address_type="shipping",
first_name="John",
# missing last_name, address_line_1, city, postal_code, country
)
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerAddressUpdateSchema:
"""Test CustomerAddressUpdate schema validation."""
def test_partial_update(self):
"""Test partial update with only some fields."""
update = CustomerAddressUpdate(city="Esch-sur-Alzette")
assert update.city == "Esch-sur-Alzette"
assert update.first_name is None
def test_empty_update_is_valid(self):
"""Test empty update is valid."""
update = CustomerAddressUpdate()
assert update.model_dump(exclude_unset=True) == {}
def test_address_type_validation(self):
"""Test address_type validation in update."""
with pytest.raises(ValidationError):
CustomerAddressUpdate(address_type="invalid")
def test_valid_address_type_update(self):
"""Test valid address_type values in update."""
shipping = CustomerAddressUpdate(address_type="shipping")
billing = CustomerAddressUpdate(address_type="billing")
assert shipping.address_type == "shipping"
assert billing.address_type == "billing"
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerAddressResponseSchema:
"""Test CustomerAddressResponse schema."""
def test_from_dict(self):
"""Test creating response from dict."""
from datetime import datetime
data = {
"id": 1,
"store_id": 1,
"customer_id": 1,
"address_type": "shipping",
"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_name": "Luxembourg",
"country_iso": "LU",
"is_default": True,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
response = CustomerAddressResponse(**data)
assert response.id == 1
assert response.is_default is True
assert response.address_type == "shipping"
@pytest.mark.unit
@pytest.mark.schema
class TestCustomerPreferencesUpdateSchema:
"""Test CustomerPreferencesUpdate schema validation."""
def test_partial_update(self):
"""Test partial update with only some fields."""
update = CustomerPreferencesUpdate(marketing_consent=True)
assert update.marketing_consent is True
assert update.preferred_language is None
def test_language_update(self):
"""Test language preference update."""
update = CustomerPreferencesUpdate(preferred_language="fr")
assert update.preferred_language == "fr"
def test_currency_update(self):
"""Test currency preference update."""
update = CustomerPreferencesUpdate(currency="EUR")
assert update.currency == "EUR"
def test_notification_preferences(self):
"""Test notification preferences dict."""
update = CustomerPreferencesUpdate(
notification_preferences={
"email": True,
"sms": False,
"push": True,
}
)
assert update.notification_preferences["email"] is True
assert update.notification_preferences["sms"] is False