# tests/test_stock_service.py 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.marketplace_product import MarketplaceProduct from models.database.stock import Stock @pytest.mark.unit @pytest.mark.stock class TestStockService: def setup_method(self): self.service = StockService() 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 # Mixed invalid characters that become empty after filtering 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 # Test with decimal points (should be removed) assert self.service._normalize_gtin("1234567890123.0") == "1234567890123" # Test with whitespace (should be trimmed) 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 # Test long GTINs being truncated 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" # 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 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 ) result = self.service.set_stock(db, stock_data) assert result.gtin == "1234567890123" assert result.location.upper() == f"WAREHOUSE_A_{unique_id}".upper() assert result.quantity == 100 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 quantity=200, ) result = self.service.set_stock(db, stock_data) assert result.gtin == test_stock.gtin assert result.location == test_stock.location assert result.quantity == 200 # Should replace the original quantity 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(StockValidationException) as exc_info: self.service.set_stock(db, stock_data) 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 ) result = self.service.add_stock(db, stock_data) assert result.gtin == "1234567890123" assert result.location.upper() == f"WAREHOUSE_B_{unique_id}".upper() assert result.quantity == 50 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, location=test_stock.location, # Use exact same location as test_stock quantity=25, ) result = self.service.add_stock(db, stock_data) assert result.gtin == test_stock.gtin assert result.location == test_stock.location assert result.quantity == original_quantity + 25 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(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 stock_data = StockAdd( gtin=test_stock.gtin, location=test_stock.location, # Use exact same location as test_stock quantity=remove_quantity, ) result = self.service.remove_stock(db, stock_data) assert result.gtin == test_stock.gtin assert result.location == test_stock.location assert result.quantity == original_quantity - remove_quantity 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 ) with pytest.raises(InsufficientStockException) as exc_info: self.service.remove_stock(db, stock_data) 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(StockNotFoundException) as exc_info: self.service.remove_stock(db, stock_data) 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(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_marketplace_product): """Test getting stock summary by GTIN successfully.""" result = self.service.get_stock_by_gtin(db, test_stock.gtin) assert result.gtin == test_stock.gtin assert result.total_quantity == test_stock.quantity assert len(result.locations) == 1 assert result.locations[0].location == test_stock.location assert result.locations[0].quantity == test_stock.quantity assert result.product_title == test_marketplace_product.title def test_get_stock_by_gtin_multiple_locations_success(self, db, test_marketplace_product): """Test getting stock summary with multiple locations successfully.""" unique_gtin = test_marketplace_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) db.add(stock1) db.add(stock2) db.commit() result = self.service.get_stock_by_gtin(db, unique_gtin) assert result.gtin == unique_gtin assert result.total_quantity == 80 assert len(result.locations) == 2 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") 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_marketplace_product): """Test getting total stock for a GTIN successfully.""" result = self.service.get_total_stock(db, test_stock.gtin) assert result["gtin"] == test_stock.gtin assert result["total_quantity"] == test_stock.quantity assert result["product_title"] == test_marketplace_product.title assert result["locations_count"] == 1 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") 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_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 # 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_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_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 location=f"WAREHOUSE_{unique_prefix}_{i}", quantity=10, ) db.add(stock) db.commit() 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 def test_update_stock_success(self, db, test_stock): """Test updating stock quantity successfully.""" stock_update = StockUpdate(quantity=150) result = self.service.update_stock(db, test_stock.id, stock_update) assert result.id == test_stock.id assert result.quantity == 150 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(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 successfully.""" stock_id = test_stock.id result = self.service.delete_stock(db, stock_id) assert result is True # Verify the stock is actually deleted deleted_stock = db.query(Stock).filter(Stock.id == stock_id).first() assert deleted_stock is None 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) assert exc_info.value.error_code == "STOCK_NOT_FOUND" assert "99999" in str(exc_info.value) def test_get_low_stock_items_success(self, db, test_stock, test_marketplace_product): """Test getting low stock items successfully.""" # Set stock to a low value test_stock.quantity = 5 db.commit() result = self.service.get_low_stock_items(db, threshold=10) 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_marketplace_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 def test_product_with_stock(db, test_stock): """Create a test product that corresponds to the test stock.""" product = MarketplaceProduct( marketplace_product_id="STOCK_TEST_001", title="Stock Test MarketplaceProduct", gtin=test_stock.gtin, price="29.99", brand="TestBrand", marketplace="Letzshop", ) db.add(product) db.commit() db.refresh(product) return product