test updates to take into account exception management

This commit is contained in:
2025-09-27 13:47:36 +02:00
parent 3e720212d9
commit 6b9817f179
38 changed files with 2951 additions and 871 deletions

View File

@@ -163,25 +163,25 @@ class TestAuthService:
def test_email_exists_true(self, db, test_user):
"""Test email_exists returns True when email exists"""
exists = self.service.email_exists(db, test_user.email)
exists = self.service._email_exists(db, test_user.email)
assert exists is True
def test_email_exists_false(self, db):
"""Test email_exists returns False when email doesn't exist"""
exists = self.service.email_exists(db, "nonexistent@example.com")
exists = self.service._email_exists(db, "nonexistent@example.com")
assert exists is False
def test_username_exists_true(self, db, test_user):
"""Test username_exists returns True when username exists"""
exists = self.service.username_exists(db, test_user.username)
exists = self.service._username_exists(db, test_user.username)
assert exists is True
def test_username_exists_false(self, db):
"""Test username_exists returns False when username doesn't exist"""
exists = self.service.username_exists(db, "nonexistentuser")
exists = self.service._username_exists(db, "nonexistentuser")
assert exists is False

View File

@@ -231,10 +231,13 @@ class TestProductService:
def test_get_stock_info_success(self, db, test_product_with_stock):
"""Test getting stock info for product with stock"""
stock_info = self.service.get_stock_info(db, test_product_with_stock.gtin)
# Extract the product from the dictionary
product = test_product_with_stock['product']
stock_info = self.service.get_stock_info(db, product.gtin)
assert stock_info is not None
assert stock_info.gtin == test_product_with_stock.gtin
assert stock_info.gtin == product.gtin
assert stock_info.total_quantity > 0
assert len(stock_info.locations) > 0

View File

@@ -1,15 +1,24 @@
# tests/test_shop_service.py (simplified with fixtures)
# tests/test_shop_service.py (updated to use custom exceptions)
import pytest
from fastapi import HTTPException
from app.services.shop_service import ShopService
from app.exceptions import (
ShopNotFoundException,
ShopAlreadyExistsException,
UnauthorizedShopAccessException,
InvalidShopDataException,
ProductNotFoundException,
ShopProductAlreadyExistsException,
MaxShopsReachedException,
ValidationException,
)
from models.schemas.shop import ShopCreate, ShopProductCreate
@pytest.mark.unit
@pytest.mark.shops
class TestShopService:
"""Test suite for ShopService following the application's testing patterns"""
"""Test suite for ShopService following the application's exception patterns"""
def setup_method(self):
"""Setup method following the same pattern as admin service tests"""
@@ -44,11 +53,69 @@ class TestShopService:
shop_code=test_shop.shop_code, shop_name=test_shop.shop_name
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ShopAlreadyExistsException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
assert exc_info.value.status_code == 400
assert "Shop code already exists" in str(exc_info.value.detail)
exception = exc_info.value
assert exception.status_code == 409
assert exception.error_code == "SHOP_ALREADY_EXISTS"
assert test_shop.shop_code.upper() in exception.message
assert "shop_code" in exception.details
def test_create_shop_invalid_data_empty_code(self, db, test_user):
"""Test shop creation fails with empty shop code"""
shop_data = ShopCreate(shop_code="", shop_name="Test Shop")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.status_code == 422
assert exception.error_code == "INVALID_SHOP_DATA"
assert exception.details["field"] == "shop_code"
def test_create_shop_invalid_data_empty_name(self, db, test_user):
"""Test shop creation fails with empty shop name"""
shop_data = ShopCreate(shop_code="VALIDCODE", shop_name="")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.error_code == "INVALID_SHOP_DATA"
assert exception.details["field"] == "shop_name"
def test_create_shop_invalid_code_format(self, db, test_user):
"""Test shop creation fails with invalid shop code format"""
shop_data = ShopCreate(shop_code="INVALID@CODE!", shop_name="Test Shop")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.error_code == "INVALID_SHOP_DATA"
assert exception.details["field"] == "shop_code"
assert "letters, numbers, underscores, and hyphens" in exception.message
def test_create_shop_max_shops_reached(self, db, test_user, monkeypatch):
"""Test shop creation fails when user reaches maximum shops"""
# Mock the shop count check to simulate user at limit
def mock_check_shop_limit(self, db, user):
raise MaxShopsReachedException(max_shops=5, user_id=user.id)
monkeypatch.setattr(ShopService, "_check_shop_limit", mock_check_shop_limit)
shop_data = ShopCreate(shop_code="NEWSHOP", shop_name="New Shop")
with pytest.raises(MaxShopsReachedException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.status_code == 400
assert exception.error_code == "MAX_SHOPS_REACHED"
assert exception.details["max_shops"] == 5
assert exception.details["user_id"] == test_user.id
def test_get_shops_regular_user(self, db, test_user, test_shop, inactive_shop):
"""Test regular user can only see active verified shops and own shops"""
@@ -59,7 +126,7 @@ class TestShopService:
assert inactive_shop.shop_code not in shop_codes
def test_get_shops_admin_user(
self, db, test_admin, test_shop, inactive_shop, verified_shop
self, db, test_admin, test_shop, inactive_shop, verified_shop
):
"""Test admin user can see all shops with filters"""
shops, total = self.service.get_shops(
@@ -88,18 +155,26 @@ class TestShopService:
assert shop.id == test_shop.id
def test_get_shop_by_code_not_found(self, db, test_user):
"""Test shop not found returns appropriate error"""
with pytest.raises(HTTPException) as exc_info:
"""Test shop not found raises proper exception"""
with pytest.raises(ShopNotFoundException) as exc_info:
self.service.get_shop_by_code(db, "NONEXISTENT", test_user)
assert exc_info.value.status_code == 404
exception = exc_info.value
assert exception.status_code == 404
assert exception.error_code == "SHOP_NOT_FOUND"
assert exception.details["resource_type"] == "Shop"
assert exception.details["identifier"] == "NONEXISTENT"
def test_get_shop_by_code_access_denied(self, db, test_user, inactive_shop):
"""Test regular user cannot access unverified shop they don't own"""
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.get_shop_by_code(db, inactive_shop.shop_code, test_user)
assert exc_info.value.status_code == 404
exception = exc_info.value
assert exception.status_code == 403
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
assert exception.details["shop_code"] == inactive_shop.shop_code
assert exception.details["user_id"] == test_user.id
def test_add_product_to_shop_success(self, db, test_shop, unique_product):
"""Test successfully adding product to shop"""
@@ -122,10 +197,14 @@ class TestShopService:
"""Test adding non-existent product to shop fails"""
shop_product_data = ShopProductCreate(product_id="NONEXISTENT", price="15.99")
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ProductNotFoundException) as exc_info:
self.service.add_product_to_shop(db, test_shop, shop_product_data)
assert exc_info.value.status_code == 404
exception = exc_info.value
assert exception.status_code == 404
assert exception.error_code == "PRODUCT_NOT_FOUND"
assert exception.details["resource_type"] == "Product"
assert exception.details["identifier"] == "NONEXISTENT"
def test_add_product_to_shop_already_exists(self, db, test_shop, shop_product):
"""Test adding product that's already in shop fails"""
@@ -133,13 +212,17 @@ class TestShopService:
product_id=shop_product.product.product_id, price="15.99"
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ShopProductAlreadyExistsException) as exc_info:
self.service.add_product_to_shop(db, test_shop, shop_product_data)
assert exc_info.value.status_code == 400
exception = exc_info.value
assert exception.status_code == 409
assert exception.error_code == "SHOP_PRODUCT_ALREADY_EXISTS"
assert exception.details["shop_code"] == test_shop.shop_code
assert exception.details["product_id"] == shop_product.product.product_id
def test_get_shop_products_owner_access(
self, db, test_user, test_shop, shop_product
self, db, test_user, test_shop, shop_product
):
"""Test shop owner can get shop products"""
products, total = self.service.get_shop_products(db, test_shop, test_user)
@@ -151,93 +234,132 @@ class TestShopService:
def test_get_shop_products_access_denied(self, db, test_user, inactive_shop):
"""Test non-owner cannot access unverified shop products"""
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.get_shop_products(db, inactive_shop, test_user)
assert exc_info.value.status_code == 404
exception = exc_info.value
assert exception.status_code == 403
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
assert exception.details["shop_code"] == inactive_shop.shop_code
assert exception.details["user_id"] == test_user.id
def test_get_shop_by_id(self, db, test_shop):
"""Test getting shop by ID"""
shop = self.service.get_shop_by_id(db, test_shop.id)
def test_get_shop_products_with_filters(self, db, test_user, test_shop, shop_product):
"""Test getting shop products with various filters"""
# Test active only filter
products, total = self.service.get_shop_products(
db, test_shop, test_user, active_only=True
)
assert all(p.is_active for p in products)
assert shop is not None
assert shop.id == test_shop.id
# Test featured only filter
products, total = self.service.get_shop_products(
db, test_shop, test_user, featured_only=True
)
assert all(p.is_featured for p in products)
def test_get_shop_by_id_not_found(self, db):
"""Test getting shop by ID when shop doesn't exist"""
shop = self.service.get_shop_by_id(db, 99999)
# Test exception handling for generic errors
def test_create_shop_database_error(self, db, test_user, monkeypatch):
"""Test shop creation handles database errors gracefully"""
assert shop is None
def mock_commit():
raise Exception("Database connection failed")
def test_shop_code_exists_true(self, db, test_shop):
"""Test shop_code_exists returns True when shop code exists"""
exists = self.service.shop_code_exists(db, test_shop.shop_code)
monkeypatch.setattr(db, "commit", mock_commit)
assert exists is True
shop_data = ShopCreate(shop_code="NEWSHOP", shop_name="Test Shop")
def test_shop_code_exists_false(self, db):
"""Test shop_code_exists returns False when shop code doesn't exist"""
exists = self.service.shop_code_exists(db, "NONEXISTENT")
with pytest.raises(ValidationException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
assert exists is False
exception = exc_info.value
assert exception.status_code == 422
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to create shop" in exception.message
def test_get_product_by_id(self, db, unique_product):
"""Test getting product by product_id"""
product = self.service.get_product_by_id(db, unique_product.product_id)
def test_get_shops_database_error(self, db, test_user, monkeypatch):
"""Test get shops handles database errors gracefully"""
assert product is not None
assert product.id == unique_product.id
def mock_query(*args):
raise Exception("Database query failed")
def test_get_product_by_id_not_found(self, db):
"""Test getting product by product_id when product doesn't exist"""
product = self.service.get_product_by_id(db, "NONEXISTENT")
monkeypatch.setattr(db, "query", mock_query)
assert product is None
with pytest.raises(ValidationException) as exc_info:
self.service.get_shops(db, test_user)
def test_product_in_shop_true(self, db, test_shop, shop_product):
"""Test product_in_shop returns True when product is in shop"""
exists = self.service.product_in_shop(db, test_shop.id, shop_product.product_id)
exception = exc_info.value
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to retrieve shops" in exception.message
assert exists is True
def test_add_product_database_error(self, db, test_shop, unique_product, monkeypatch):
"""Test add product handles database errors gracefully"""
def test_product_in_shop_false(self, db, test_shop, unique_product):
"""Test product_in_shop returns False when product is not in shop"""
exists = self.service.product_in_shop(db, test_shop.id, unique_product.id)
def mock_commit():
raise Exception("Database commit failed")
assert exists is False
monkeypatch.setattr(db, "commit", mock_commit)
def test_is_shop_owner_true(self, test_shop, test_user):
"""Test is_shop_owner returns True for shop owner"""
is_owner = self.service.is_shop_owner(test_shop, test_user)
shop_product_data = ShopProductCreate(
product_id=unique_product.product_id, price="15.99"
)
assert is_owner is True
with pytest.raises(ValidationException) as exc_info:
self.service.add_product_to_shop(db, test_shop, shop_product_data)
def test_is_shop_owner_false(self, inactive_shop, test_user):
"""Test is_shop_owner returns False for non-owner"""
is_owner = self.service.is_shop_owner(inactive_shop, test_user)
exception = exc_info.value
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to add product to shop" in exception.message
assert is_owner is False
def test_can_view_shop_owner(self, test_shop, test_user):
"""Test can_view_shop returns True for shop owner"""
can_view = self.service.can_view_shop(test_shop, test_user)
@pytest.mark.unit
@pytest.mark.shops
class TestShopServiceExceptionDetails:
"""Additional tests focusing specifically on exception structure and details"""
assert can_view is True
def setup_method(self):
self.service = ShopService()
def test_can_view_shop_admin(self, test_shop, test_admin):
"""Test can_view_shop returns True for admin"""
can_view = self.service.can_view_shop(test_shop, test_admin)
def test_exception_to_dict_structure(self, db, test_user, test_shop):
"""Test that exceptions can be properly serialized to dict for API responses"""
shop_data = ShopCreate(
shop_code=test_shop.shop_code, shop_name="Duplicate"
)
assert can_view is True
with pytest.raises(ShopAlreadyExistsException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
def test_can_view_shop_active_verified(self, test_user, verified_shop):
"""Test can_view_shop returns True for active verified shop"""
can_view = self.service.can_view_shop(verified_shop, test_user)
exception = exc_info.value
exception_dict = exception.to_dict()
assert can_view is True
# Verify structure matches expected API response format
assert "error_code" in exception_dict
assert "message" in exception_dict
assert "status_code" in exception_dict
assert "details" in exception_dict
def test_can_view_shop_inactive_unverified(self, test_user, inactive_shop):
"""Test can_view_shop returns False for inactive unverified shop"""
can_view = self.service.can_view_shop(inactive_shop, test_user)
# Verify values
assert exception_dict["error_code"] == "SHOP_ALREADY_EXISTS"
assert exception_dict["status_code"] == 409
assert isinstance(exception_dict["details"], dict)
assert can_view is False
def test_validation_exception_field_details(self, db, test_user):
"""Test validation exceptions include field-specific details"""
shop_data = ShopCreate(shop_code="", shop_name="Test")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.details["field"] == "shop_code"
assert exception.status_code == 422
assert "required" in exception.message.lower()
def test_authorization_exception_user_details(self, db, test_user, inactive_shop):
"""Test authorization exceptions include user context"""
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.get_shop_by_code(db, inactive_shop.shop_code, test_user)
exception = exc_info.value
assert exception.details["shop_code"] == inactive_shop.shop_code
assert exception.details["user_id"] == test_user.id
assert "Unauthorized access" in exception.message

View File

@@ -214,7 +214,7 @@ class TestStatsService:
def test_get_product_count(self, db, test_product):
"""Test getting total product count"""
count = self.service.get_product_count(db)
count = self.service._get_product_count(db)
assert count >= 1
assert isinstance(count, int)
@@ -245,7 +245,7 @@ class TestStatsService:
db.add_all(brand_products)
db.commit()
count = self.service.get_unique_brands_count(db)
count = self.service._get_unique_brands_count(db)
assert (
count >= 2
@@ -278,7 +278,7 @@ class TestStatsService:
db.add_all(category_products)
db.commit()
count = self.service.get_unique_categories_count(db)
count = self.service._get_unique_categories_count(db)
assert count >= 2 # At least Electronics and Books
assert isinstance(count, int)
@@ -307,7 +307,7 @@ class TestStatsService:
db.add_all(marketplace_products)
db.commit()
count = self.service.get_unique_marketplaces_count(db)
count = self.service._get_unique_marketplaces_count(db)
assert count >= 2 # At least Amazon and eBay, plus test_product marketplace
assert isinstance(count, int)
@@ -336,7 +336,7 @@ class TestStatsService:
db.add_all(shop_products)
db.commit()
count = self.service.get_unique_shops_count(db)
count = self.service._get_unique_shops_count(db)
assert count >= 2 # At least ShopA and ShopB, plus test_product shop
assert isinstance(count, int)
@@ -405,7 +405,7 @@ class TestStatsService:
db.add_all(marketplace_products)
db.commit()
brands = self.service.get_brands_by_marketplace(db, "SpecificMarket")
brands = self.service._get_brands_by_marketplace(db, "SpecificMarket")
assert len(brands) == 2
assert "SpecificBrand1" in brands
@@ -438,7 +438,7 @@ class TestStatsService:
db.add_all(marketplace_products)
db.commit()
shops = self.service.get_shops_by_marketplace(db, "TestMarketplace")
shops = self.service._get_shops_by_marketplace(db, "TestMarketplace")
assert len(shops) == 2
assert "TestShop1" in shops
@@ -476,13 +476,13 @@ class TestStatsService:
db.add_all(marketplace_products)
db.commit()
count = self.service.get_products_by_marketplace(db, "CountMarketplace")
count = self.service._get_products_by_marketplace_count(db, "CountMarketplace")
assert count == 3
def test_get_products_by_marketplace_not_found(self, db):
"""Test getting product count for non-existent marketplace"""
count = self.service.get_products_by_marketplace(db, "NonExistentMarketplace")
count = self.service._get_products_by_marketplace_count(db, "NonExistentMarketplace")
assert count == 0

View File

@@ -4,6 +4,15 @@ import uuid
import pytest
from app.services.stock_service import StockService
from app.exceptions import (
StockNotFoundException,
InsufficientStockException,
InvalidStockOperationException,
StockValidationException,
NegativeStockException,
InvalidQuantityException,
ValidationException,
)
from models.schemas.stock import StockAdd, StockCreate, StockUpdate
from models.database.product import Product
from models.database.stock import Stock
@@ -18,72 +27,50 @@ class TestStockService:
def test_normalize_gtin_invalid(self):
"""Test GTIN normalization with invalid GTINs."""
# Completely invalid values that should return None
assert self.service.normalize_gtin("invalid") is None
assert self.service.normalize_gtin("abcdef") is None
assert self.service.normalize_gtin("") is None
assert self.service.normalize_gtin(None) is None
assert self.service.normalize_gtin(" ") is None # Only whitespace
assert self.service.normalize_gtin("!@#$%") is None # Only special characters
assert self.service._normalize_gtin("invalid") is None
assert self.service._normalize_gtin("abcdef") is None
assert self.service._normalize_gtin("") is None
assert self.service._normalize_gtin(None) is None
assert self.service._normalize_gtin(" ") is None # Only whitespace
assert self.service._normalize_gtin("!@#$%") is None # Only special characters
# Mixed invalid characters that become empty after filtering
assert self.service.normalize_gtin("abc-def-ghi") is None # No digits
# Note: Based on your GTINProcessor implementation, short numeric values
# will be padded, not rejected. For example:
# - "123" becomes "000000000123" (padded to 12 digits)
# - "1" becomes "000000000001" (padded to 12 digits)
# If you want to test that short GTINs are padded (not rejected):
assert self.service.normalize_gtin("123") == "0000000000123"
assert self.service.normalize_gtin("1") == "0000000000001"
assert self.service.normalize_gtin("12345") == "0000000012345"
assert self.service._normalize_gtin("abc-def-ghi") is None # No digits
def test_normalize_gtin_valid(self):
"""Test GTIN normalization with valid GTINs."""
# Test various valid GTIN formats - these should remain unchanged
assert self.service.normalize_gtin("1234567890123") == "1234567890123" # EAN-13
assert self.service.normalize_gtin("123456789012") == "123456789012" # UPC-A
assert self.service.normalize_gtin("12345678") == "12345678" # EAN-8
assert (
self.service.normalize_gtin("12345678901234") == "12345678901234"
) # GTIN-14
assert self.service._normalize_gtin("1234567890123") == "1234567890123" # EAN-13
assert self.service._normalize_gtin("123456789012") == "123456789012" # UPC-A
assert self.service._normalize_gtin("12345678") == "12345678" # EAN-8
assert self.service._normalize_gtin("12345678901234") == "12345678901234" # GTIN-14
# Test with decimal points (should be removed)
assert self.service.normalize_gtin("1234567890123.0") == "1234567890123"
assert self.service._normalize_gtin("1234567890123.0") == "1234567890123"
# Test with whitespace (should be trimmed)
assert self.service.normalize_gtin(" 1234567890123 ") == "1234567890123"
assert self.service._normalize_gtin(" 1234567890123 ") == "1234567890123"
# Test short GTINs being padded
assert self.service.normalize_gtin("123") == "0000000000123" # Padded to EAN-13
assert (
self.service.normalize_gtin("12345") == "0000000012345"
) # Padded to EAN-13
assert self.service._normalize_gtin("123") == "0000000000123" # Padded to EAN-13
assert self.service._normalize_gtin("12345") == "0000000012345" # Padded to EAN-13
# Test long GTINs being truncated
assert (
self.service.normalize_gtin("123456789012345") == "3456789012345"
) # Truncated to 13
assert self.service._normalize_gtin("123456789012345") == "3456789012345" # Truncated to 13
def test_normalize_gtin_edge_cases(self):
"""Test GTIN normalization edge cases."""
# Test numeric inputs
assert self.service.normalize_gtin(1234567890123) == "1234567890123"
assert self.service.normalize_gtin(123) == "0000000000123"
assert self.service._normalize_gtin(1234567890123) == "1234567890123"
assert self.service._normalize_gtin(123) == "0000000000123"
# Test mixed valid/invalid characters
assert (
self.service.normalize_gtin("123-456-789-012") == "123456789012"
) # Dashes removed
assert (
self.service.normalize_gtin("123 456 789 012") == "123456789012"
) # Spaces removed
assert (
self.service.normalize_gtin("ABC123456789012DEF") == "123456789012"
) # Letters removed
assert self.service._normalize_gtin("123-456-789-012") == "123456789012" # Dashes removed
assert self.service._normalize_gtin("123 456 789 012") == "123456789012" # Spaces removed
assert self.service._normalize_gtin("ABC123456789012DEF") == "123456789012" # Letters removed
def test_set_stock_new_entry(self, db):
"""Test setting stock for a new GTIN/location combination."""
def test_set_stock_new_entry_success(self, db):
"""Test setting stock for a new GTIN/location combination successfully."""
unique_id = str(uuid.uuid4())[:8]
stock_data = StockCreate(
gtin="1234567890123", location=f"WAREHOUSE_A_{unique_id}", quantity=100
@@ -95,8 +82,8 @@ class TestStockService:
assert result.location.upper() == f"WAREHOUSE_A_{unique_id}".upper()
assert result.quantity == 100
def test_set_stock_existing_entry(self, db, test_stock):
"""Test setting stock for an existing GTIN/location combination."""
def test_set_stock_existing_entry_success(self, db, test_stock):
"""Test setting stock for an existing GTIN/location combination successfully."""
stock_data = StockCreate(
gtin=test_stock.gtin,
location=test_stock.location, # Use exact same location as test_stock
@@ -106,21 +93,47 @@ class TestStockService:
result = self.service.set_stock(db, stock_data)
assert result.gtin == test_stock.gtin
# Fix: Handle case sensitivity properly - compare uppercase or use exact match
assert result.location == test_stock.location
assert result.quantity == 200 # Should replace the original quantity
def test_set_stock_invalid_gtin(self, db):
"""Test setting stock with invalid GTIN."""
def test_set_stock_invalid_gtin_validation_error(self, db):
"""Test setting stock with invalid GTIN returns StockValidationException."""
stock_data = StockCreate(
gtin="invalid_gtin", location="WAREHOUSE_A", quantity=100
)
with pytest.raises(ValueError, match="Invalid GTIN format"):
with pytest.raises(StockValidationException) as exc_info:
self.service.set_stock(db, stock_data)
def test_add_stock_new_entry(self, db):
"""Test adding stock for a new GTIN/location combination."""
assert exc_info.value.error_code == "STOCK_VALIDATION_FAILED"
assert "Invalid GTIN format" in str(exc_info.value)
assert exc_info.value.details.get("field") == "gtin"
def test_set_stock_invalid_quantity_error(self, db):
"""Test setting stock with invalid quantity through service validation."""
# Test the service validation directly instead of going through Pydantic schema
# This bypasses the Pydantic validation to test service layer validation
# Create a mock stock data object that bypasses Pydantic validation
class MockStockData:
def __init__(self, gtin, location, quantity):
self.gtin = gtin
self.location = location
self.quantity = quantity
mock_stock_data = MockStockData("1234567890123", "WAREHOUSE_A", -10)
# Test the internal validation method directly
with pytest.raises(InvalidQuantityException) as exc_info:
self.service._validate_quantity(-10, allow_zero=True)
assert exc_info.value.error_code == "INVALID_QUANTITY"
assert "Quantity cannot be negative" in str(exc_info.value)
assert exc_info.value.details.get("quantity") == -10
def test_add_stock_new_entry_success(self, db):
"""Test adding stock for a new GTIN/location combination successfully."""
unique_id = str(uuid.uuid4())[:8]
stock_data = StockAdd(
gtin="1234567890123", location=f"WAREHOUSE_B_{unique_id}", quantity=50
@@ -132,8 +145,8 @@ class TestStockService:
assert result.location.upper() == f"WAREHOUSE_B_{unique_id}".upper()
assert result.quantity == 50
def test_add_stock_existing_entry(self, db, test_stock):
"""Test adding stock to an existing GTIN/location combination."""
def test_add_stock_existing_entry_success(self, db, test_stock):
"""Test adding stock to an existing GTIN/location combination successfully."""
original_quantity = test_stock.quantity
stock_data = StockAdd(
gtin=test_stock.gtin,
@@ -147,19 +160,31 @@ class TestStockService:
assert result.location == test_stock.location
assert result.quantity == original_quantity + 25
def test_add_stock_invalid_gtin(self, db):
"""Test adding stock with invalid GTIN."""
def test_add_stock_invalid_gtin_validation_error(self, db):
"""Test adding stock with invalid GTIN returns StockValidationException."""
stock_data = StockAdd(gtin="invalid_gtin", location="WAREHOUSE_A", quantity=50)
with pytest.raises(ValueError, match="Invalid GTIN format"):
with pytest.raises(StockValidationException) as exc_info:
self.service.add_stock(db, stock_data)
assert exc_info.value.error_code == "STOCK_VALIDATION_FAILED"
assert "Invalid GTIN format" in str(exc_info.value)
def test_add_stock_invalid_quantity_error(self, db):
"""Test adding stock with invalid quantity through service validation."""
# Test zero quantity which should fail for add_stock (doesn't allow zero)
# This tests the service validation: allow_zero=False for add operations
with pytest.raises(InvalidQuantityException) as exc_info:
self.service._validate_quantity(0, allow_zero=False)
assert exc_info.value.error_code == "INVALID_QUANTITY"
assert "Quantity must be positive" in str(exc_info.value)
def test_remove_stock_success(self, db, test_stock):
"""Test removing stock successfully."""
original_quantity = test_stock.quantity
remove_quantity = min(
10, original_quantity
) # Ensure we don't remove more than available
remove_quantity = min(10, original_quantity) # Ensure we don't remove more than available
stock_data = StockAdd(
gtin=test_stock.gtin,
@@ -173,39 +198,64 @@ class TestStockService:
assert result.location == test_stock.location
assert result.quantity == original_quantity - remove_quantity
def test_remove_stock_insufficient_stock(self, db, test_stock):
"""Test removing more stock than available."""
def test_remove_stock_insufficient_stock_error(self, db, test_stock):
"""Test removing more stock than available returns InsufficientStockException."""
stock_data = StockAdd(
gtin=test_stock.gtin,
location=test_stock.location, # Use exact same location as test_stock
quantity=test_stock.quantity + 10, # More than available
)
# Fix: Use more flexible regex pattern
with pytest.raises(
ValueError, match="Insufficient stock|Not enough stock|Cannot remove"
):
with pytest.raises(InsufficientStockException) as exc_info:
self.service.remove_stock(db, stock_data)
def test_remove_stock_nonexistent_entry(self, db):
"""Test removing stock from non-existent GTIN/location."""
assert exc_info.value.error_code == "INSUFFICIENT_STOCK"
assert exc_info.value.details["gtin"] == test_stock.gtin
assert exc_info.value.details["location"] == test_stock.location
assert exc_info.value.details["requested_quantity"] == test_stock.quantity + 10
assert exc_info.value.details["available_quantity"] == test_stock.quantity
def test_remove_stock_nonexistent_entry_not_found(self, db):
"""Test removing stock from non-existent GTIN/location returns StockNotFoundException."""
unique_id = str(uuid.uuid4())[:8]
stock_data = StockAdd(
gtin="9999999999999", location=f"NONEXISTENT_{unique_id}", quantity=10
)
with pytest.raises(ValueError, match="No stock found|Stock not found"):
with pytest.raises(StockNotFoundException) as exc_info:
self.service.remove_stock(db, stock_data)
def test_remove_stock_invalid_gtin(self, db):
"""Test removing stock with invalid GTIN."""
assert exc_info.value.error_code == "STOCK_NOT_FOUND"
assert "9999999999999" in str(exc_info.value)
assert exc_info.value.details["resource_type"] == "Stock"
def test_remove_stock_invalid_gtin_validation_error(self, db):
"""Test removing stock with invalid GTIN returns StockValidationException."""
stock_data = StockAdd(gtin="invalid_gtin", location="WAREHOUSE_A", quantity=10)
with pytest.raises(ValueError, match="Invalid GTIN format"):
with pytest.raises(StockValidationException) as exc_info:
self.service.remove_stock(db, stock_data)
assert exc_info.value.error_code == "STOCK_VALIDATION_FAILED"
assert "Invalid GTIN format" in str(exc_info.value)
def test_remove_stock_negative_result_error(self, db, test_stock):
"""Test removing stock that would result in negative quantity returns NegativeStockException."""
# This is handled by InsufficientStockException, but test the logic
stock_data = StockAdd(
gtin=test_stock.gtin,
location=test_stock.location,
quantity=test_stock.quantity + 1, # One more than available
)
with pytest.raises(InsufficientStockException) as exc_info:
self.service.remove_stock(db, stock_data)
# The service prevents negative stock through InsufficientStockException
assert exc_info.value.error_code == "INSUFFICIENT_STOCK"
def test_get_stock_by_gtin_success(self, db, test_stock, test_product):
"""Test getting stock summary by GTIN."""
"""Test getting stock summary by GTIN successfully."""
result = self.service.get_stock_by_gtin(db, test_stock.gtin)
assert result.gtin == test_stock.gtin
@@ -215,19 +265,14 @@ class TestStockService:
assert result.locations[0].quantity == test_stock.quantity
assert result.product_title == test_product.title
def test_get_stock_by_gtin_multiple_locations(self, db, test_product):
"""Test getting stock summary with multiple locations."""
def test_get_stock_by_gtin_multiple_locations_success(self, db, test_product):
"""Test getting stock summary with multiple locations successfully."""
unique_gtin = test_product.gtin
unique_id = str(uuid.uuid4())[:8]
# Create multiple stock entries for the same GTIN with unique locations
stock1 = Stock(
gtin=unique_gtin, location=f"WAREHOUSE_A_{unique_id}", quantity=50
)
stock2 = Stock(
gtin=unique_gtin, location=f"WAREHOUSE_B_{unique_id}", quantity=30
)
stock1 = Stock(gtin=unique_gtin, location=f"WAREHOUSE_A_{unique_id}", quantity=50)
stock2 = Stock(gtin=unique_gtin, location=f"WAREHOUSE_B_{unique_id}", quantity=30)
db.add(stock1)
db.add(stock2)
@@ -239,18 +284,25 @@ class TestStockService:
assert result.total_quantity == 80
assert len(result.locations) == 2
def test_get_stock_by_gtin_not_found(self, db):
"""Test getting stock for non-existent GTIN."""
with pytest.raises(ValueError, match="No stock found"):
def test_get_stock_by_gtin_not_found_error(self, db):
"""Test getting stock for non-existent GTIN returns StockNotFoundException."""
with pytest.raises(StockNotFoundException) as exc_info:
self.service.get_stock_by_gtin(db, "9999999999999")
def test_get_stock_by_gtin_invalid_gtin(self, db):
"""Test getting stock with invalid GTIN."""
with pytest.raises(ValueError, match="Invalid GTIN format"):
assert exc_info.value.error_code == "STOCK_NOT_FOUND"
assert "9999999999999" in str(exc_info.value)
assert exc_info.value.details["resource_type"] == "Stock"
def test_get_stock_by_gtin_invalid_gtin_validation_error(self, db):
"""Test getting stock with invalid GTIN returns StockValidationException."""
with pytest.raises(StockValidationException) as exc_info:
self.service.get_stock_by_gtin(db, "invalid_gtin")
assert exc_info.value.error_code == "STOCK_VALIDATION_FAILED"
assert "Invalid GTIN format" in str(exc_info.value)
def test_get_total_stock_success(self, db, test_stock, test_product):
"""Test getting total stock for a GTIN."""
"""Test getting total stock for a GTIN successfully."""
result = self.service.get_total_stock(db, test_stock.gtin)
assert result["gtin"] == test_stock.gtin
@@ -258,43 +310,52 @@ class TestStockService:
assert result["product_title"] == test_product.title
assert result["locations_count"] == 1
def test_get_total_stock_invalid_gtin(self, db):
"""Test getting total stock with invalid GTIN."""
with pytest.raises(ValueError, match="Invalid GTIN format"):
def test_get_total_stock_invalid_gtin_validation_error(self, db):
"""Test getting total stock with invalid GTIN returns StockValidationException."""
with pytest.raises(StockValidationException) as exc_info:
self.service.get_total_stock(db, "invalid_gtin")
def test_get_all_stock_no_filters(self, db, test_stock):
"""Test getting all stock without filters."""
assert exc_info.value.error_code == "STOCK_VALIDATION_FAILED"
assert "Invalid GTIN format" in str(exc_info.value)
def test_get_total_stock_not_found_error(self, db):
"""Test getting total stock for non-existent GTIN returns StockNotFoundException."""
with pytest.raises(StockNotFoundException) as exc_info:
self.service.get_total_stock(db, "9999999999999")
assert exc_info.value.error_code == "STOCK_NOT_FOUND"
def test_get_all_stock_no_filters_success(self, db, test_stock):
"""Test getting all stock without filters successfully."""
result = self.service.get_all_stock(db)
assert len(result) >= 1
assert any(stock.gtin == test_stock.gtin for stock in result)
def test_get_all_stock_with_location_filter(self, db, test_stock):
"""Test getting all stock with location filter."""
def test_get_all_stock_with_location_filter_success(self, db, test_stock):
"""Test getting all stock with location filter successfully."""
result = self.service.get_all_stock(db, location=test_stock.location)
assert len(result) >= 1
# Fix: Handle case sensitivity in comparison
assert all(
stock.location.upper() == test_stock.location.upper() for stock in result
)
# Check that all returned stocks match the filter (case insensitive)
for stock in result:
assert test_stock.location.upper() in stock.location.upper()
def test_get_all_stock_with_gtin_filter(self, db, test_stock):
"""Test getting all stock with GTIN filter."""
def test_get_all_stock_with_gtin_filter_success(self, db, test_stock):
"""Test getting all stock with GTIN filter successfully."""
result = self.service.get_all_stock(db, gtin=test_stock.gtin)
assert len(result) >= 1
assert all(stock.gtin == test_stock.gtin for stock in result)
def test_get_all_stock_with_pagination(self, db):
"""Test getting all stock with pagination."""
def test_get_all_stock_with_pagination_success(self, db):
"""Test getting all stock with pagination successfully."""
unique_prefix = str(uuid.uuid4())[:8]
# Create multiple stock entries with unique GTINs and locations
for i in range(5):
stock = Stock(
gtin=f"1234567890{i:03d}", # Creates valid 13-digit GTINs: 1234567890000, 1234567890001, etc.
gtin=f"1234567890{i:03d}", # Creates valid 13-digit GTINs
location=f"WAREHOUSE_{unique_prefix}_{i}",
quantity=10,
)
@@ -303,12 +364,10 @@ class TestStockService:
result = self.service.get_all_stock(db, skip=2, limit=2)
assert (
len(result) <= 2
) # Should be at most 2, might be less if other records exist
assert len(result) <= 2 # Should be at most 2, might be less if other records exist
def test_update_stock_success(self, db, test_stock):
"""Test updating stock quantity."""
"""Test updating stock quantity successfully."""
stock_update = StockUpdate(quantity=150)
result = self.service.update_stock(db, test_stock.id, stock_update)
@@ -316,15 +375,28 @@ class TestStockService:
assert result.id == test_stock.id
assert result.quantity == 150
def test_update_stock_not_found(self, db):
"""Test updating non-existent stock entry."""
def test_update_stock_not_found_error(self, db):
"""Test updating non-existent stock entry returns StockNotFoundException."""
stock_update = StockUpdate(quantity=150)
with pytest.raises(ValueError, match="Stock entry not found"):
with pytest.raises(StockNotFoundException) as exc_info:
self.service.update_stock(db, 99999, stock_update)
assert exc_info.value.error_code == "STOCK_NOT_FOUND"
assert "99999" in str(exc_info.value)
def test_update_stock_invalid_quantity_error(self, db, test_stock):
"""Test updating stock with invalid quantity returns InvalidQuantityException."""
stock_update = StockUpdate(quantity=-10)
with pytest.raises(InvalidQuantityException) as exc_info:
self.service.update_stock(db, test_stock.id, stock_update)
assert exc_info.value.error_code == "INVALID_QUANTITY"
assert "Quantity cannot be negative" in str(exc_info.value)
def test_delete_stock_success(self, db, test_stock):
"""Test deleting stock entry."""
"""Test deleting stock entry successfully."""
stock_id = test_stock.id
result = self.service.delete_stock(db, stock_id)
@@ -335,24 +407,85 @@ class TestStockService:
deleted_stock = db.query(Stock).filter(Stock.id == stock_id).first()
assert deleted_stock is None
def test_delete_stock_not_found(self, db):
"""Test deleting non-existent stock entry."""
with pytest.raises(ValueError, match="Stock entry not found"):
def test_delete_stock_not_found_error(self, db):
"""Test deleting non-existent stock entry returns StockNotFoundException."""
with pytest.raises(StockNotFoundException) as exc_info:
self.service.delete_stock(db, 99999)
def test_get_stock_by_id_success(self, db, test_stock):
"""Test getting stock entry by ID."""
result = self.service.get_stock_by_id(db, test_stock.id)
assert exc_info.value.error_code == "STOCK_NOT_FOUND"
assert "99999" in str(exc_info.value)
assert result is not None
assert result.id == test_stock.id
assert result.gtin == test_stock.gtin
def test_get_low_stock_items_success(self, db, test_stock, test_product):
"""Test getting low stock items successfully."""
# Set stock to a low value
test_stock.quantity = 5
db.commit()
def test_get_stock_by_id_not_found(self, db):
"""Test getting non-existent stock entry by ID."""
result = self.service.get_stock_by_id(db, 99999)
result = self.service.get_low_stock_items(db, threshold=10)
assert result is None
assert len(result) >= 1
low_stock_item = next((item for item in result if item["gtin"] == test_stock.gtin), None)
assert low_stock_item is not None
assert low_stock_item["current_quantity"] == 5
assert low_stock_item["location"] == test_stock.location
assert low_stock_item["product_title"] == test_product.title
def test_get_low_stock_items_invalid_threshold_error(self, db):
"""Test getting low stock items with invalid threshold returns InvalidQuantityException."""
with pytest.raises(InvalidQuantityException) as exc_info:
self.service.get_low_stock_items(db, threshold=-5)
assert exc_info.value.error_code == "INVALID_QUANTITY"
assert "Threshold must be non-negative" in str(exc_info.value)
def test_get_stock_summary_by_location_success(self, db, test_stock):
"""Test getting stock summary by location successfully."""
result = self.service.get_stock_summary_by_location(db, test_stock.location)
assert result["location"] == test_stock.location.upper() # Service normalizes to uppercase
assert result["total_items"] >= 1
assert result["total_quantity"] >= test_stock.quantity
assert result["unique_gtins"] >= 1
def test_get_stock_summary_by_location_empty_result(self, db):
"""Test getting stock summary for location with no stock."""
unique_id = str(uuid.uuid4())[:8]
result = self.service.get_stock_summary_by_location(db, f"EMPTY_LOCATION_{unique_id}")
assert result["total_items"] == 0
assert result["total_quantity"] == 0
assert result["unique_gtins"] == 0
def test_validate_quantity_edge_cases(self, db):
"""Test quantity validation with edge cases."""
# Test zero quantity with allow_zero=True (should succeed)
stock_data = StockCreate(gtin="1234567890123", location="WAREHOUSE_A", quantity=0)
result = self.service.set_stock(db, stock_data)
assert result.quantity == 0
# Test zero quantity with add_stock (should fail - doesn't allow zero)
stock_data_add = StockAdd(gtin="1234567890123", location="WAREHOUSE_B", quantity=0)
with pytest.raises(InvalidQuantityException):
self.service.add_stock(db, stock_data_add)
def test_exception_structure_consistency(self, db):
"""Test that all exceptions follow the consistent LetzShopException structure."""
# Test with a known error case
with pytest.raises(StockNotFoundException) as exc_info:
self.service.get_stock_by_gtin(db, "9999999999999")
exception = exc_info.value
# Verify exception structure matches LetzShopException.to_dict()
assert hasattr(exception, 'error_code')
assert hasattr(exception, 'message')
assert hasattr(exception, 'status_code')
assert hasattr(exception, 'details')
assert isinstance(exception.error_code, str)
assert isinstance(exception.message, str)
assert isinstance(exception.status_code, int)
assert isinstance(exception.details, dict)
@pytest.fixture