# tests/test_inventory_service.py import uuid import pytest from app.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, ) from app.services.inventory_service import InventoryService from models.database.inventory import Inventory from models.database.marketplace_product import MarketplaceProduct from models.schema.inventory import InventoryAdd, InventoryCreate, InventoryUpdate @pytest.mark.unit @pytest.mark.inventory class TestInventoryService: def setup_method(self): self.service = InventoryService() 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_inventory_new_entry_success(self, db): """Test setting inventory for a new GTIN/location combination successfully.""" unique_id = str(uuid.uuid4())[:8] inventory_data = InventoryCreate( gtin="1234567890123", location=f"WAREHOUSE_A_{unique_id}", quantity=100 ) result = self.service.set_inventory(db, inventory_data) assert result.gtin == "1234567890123" assert result.location.upper() == f"WAREHOUSE_A_{unique_id}".upper() assert result.quantity == 100 def test_set_inventory_existing_entry_success(self, db, test_inventory): """Test setting inventory for an existing GTIN/location combination successfully.""" inventory_data = InventoryCreate( gtin=test_inventory.gtin, location=test_inventory.location, # Use exact same location as test_inventory quantity=200, ) result = self.service.set_inventory(db, inventory_data) assert result.gtin == test_inventory.gtin assert result.location == test_inventory.location assert result.quantity == 200 # Should replace the original quantity def test_set_inventory_invalid_gtin_validation_error(self, db): """Test setting inventory with invalid GTIN returns InventoryValidationException.""" inventory_data = InventoryCreate( gtin="invalid_gtin", location="WAREHOUSE_A", quantity=100 ) with pytest.raises(InventoryValidationException) as exc_info: self.service.set_inventory(db, inventory_data) assert exc_info.value.error_code == "INVENTORY_VALIDATION_FAILED" assert "Invalid GTIN format" in str(exc_info.value) assert exc_info.value.details.get("field") == "gtin" def test_set_inventory_invalid_quantity_error(self, db): """Test setting inventory 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 inventory data object that bypasses Pydantic validation class MockInventoryData: def __init__(self, gtin, location, quantity): self.gtin = gtin self.location = location self.quantity = quantity mock_inventory_data = MockInventoryData("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_inventory_new_entry_success(self, db): """Test adding inventory for a new GTIN/location combination successfully.""" unique_id = str(uuid.uuid4())[:8] inventory_data = InventoryAdd( gtin="1234567890123", location=f"WAREHOUSE_B_{unique_id}", quantity=50 ) result = self.service.add_inventory(db, inventory_data) assert result.gtin == "1234567890123" assert result.location.upper() == f"WAREHOUSE_B_{unique_id}".upper() assert result.quantity == 50 def test_add_inventory_existing_entry_success(self, db, test_inventory): """Test adding inventory to an existing GTIN/location combination successfully.""" original_quantity = test_inventory.quantity inventory_data = InventoryAdd( gtin=test_inventory.gtin, location=test_inventory.location, # Use exact same location as test_inventory quantity=25, ) result = self.service.add_inventory(db, inventory_data) assert result.gtin == test_inventory.gtin assert result.location == test_inventory.location assert result.quantity == original_quantity + 25 def test_add_inventory_invalid_gtin_validation_error(self, db): """Test adding inventory with invalid GTIN returns InventoryValidationException.""" inventory_data = InventoryAdd( gtin="invalid_gtin", location="WAREHOUSE_A", quantity=50 ) with pytest.raises(InventoryValidationException) as exc_info: self.service.add_inventory(db, inventory_data) assert exc_info.value.error_code == "INVENTORY_VALIDATION_FAILED" assert "Invalid GTIN format" in str(exc_info.value) def test_add_inventory_invalid_quantity_error(self, db): """Test adding inventory with invalid quantity through service validation.""" # Test zero quantity which should fail for add_inventory (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_inventory_success(self, db, test_inventory): """Test removing inventory successfully.""" original_quantity = test_inventory.quantity remove_quantity = min( 10, original_quantity ) # Ensure we don't remove more than available inventory_data = InventoryAdd( gtin=test_inventory.gtin, location=test_inventory.location, # Use exact same location as test_inventory quantity=remove_quantity, ) result = self.service.remove_inventory(db, inventory_data) assert result.gtin == test_inventory.gtin assert result.location == test_inventory.location assert result.quantity == original_quantity - remove_quantity def test_remove_inventory_insufficient_inventory_error(self, db, test_inventory): """Test removing more inventory than available returns InsufficientInventoryException.""" inventory_data = InventoryAdd( gtin=test_inventory.gtin, location=test_inventory.location, # Use exact same location as test_inventory quantity=test_inventory.quantity + 10, # More than available ) with pytest.raises(InsufficientInventoryException) as exc_info: self.service.remove_inventory(db, inventory_data) assert exc_info.value.error_code == "INSUFFICIENT_INVENTORY" assert exc_info.value.details["gtin"] == test_inventory.gtin assert exc_info.value.details["location"] == test_inventory.location assert ( exc_info.value.details["requested_quantity"] == test_inventory.quantity + 10 ) assert exc_info.value.details["available_quantity"] == test_inventory.quantity def test_remove_inventory_nonexistent_entry_not_found(self, db): """Test removing inventory from non-existent GTIN/location returns InventoryNotFoundException.""" unique_id = str(uuid.uuid4())[:8] inventory_data = InventoryAdd( gtin="9999999999999", location=f"NONEXISTENT_{unique_id}", quantity=10 ) with pytest.raises(InventoryNotFoundException) as exc_info: self.service.remove_inventory(db, inventory_data) assert exc_info.value.error_code == "INVENTORY_NOT_FOUND" assert "9999999999999" in str(exc_info.value) assert exc_info.value.details["resource_type"] == "Inventory" def test_remove_inventory_invalid_gtin_validation_error(self, db): """Test removing inventory with invalid GTIN returns InventoryValidationException.""" inventory_data = InventoryAdd( gtin="invalid_gtin", location="WAREHOUSE_A", quantity=10 ) with pytest.raises(InventoryValidationException) as exc_info: self.service.remove_inventory(db, inventory_data) assert exc_info.value.error_code == "INVENTORY_VALIDATION_FAILED" assert "Invalid GTIN format" in str(exc_info.value) def test_remove_inventory_negative_result_error(self, db, test_inventory): """Test removing inventory that would result in negative quantity returns NegativeInventoryException.""" # This is handled by InsufficientInventoryException, but test the logic inventory_data = InventoryAdd( gtin=test_inventory.gtin, location=test_inventory.location, quantity=test_inventory.quantity + 1, # One more than available ) with pytest.raises(InsufficientInventoryException) as exc_info: self.service.remove_inventory(db, inventory_data) # The service prevents negative inventory through InsufficientInventoryException assert exc_info.value.error_code == "INSUFFICIENT_INVENTORY" def test_get_inventory_by_gtin_success( self, db, test_inventory, test_marketplace_product ): """Test getting inventory summary by GTIN successfully.""" result = self.service.get_inventory_by_gtin(db, test_inventory.gtin) assert result.gtin == test_inventory.gtin assert result.total_quantity == test_inventory.quantity assert len(result.locations) == 1 assert result.locations[0].location == test_inventory.location assert result.locations[0].quantity == test_inventory.quantity assert result.product_title == test_marketplace_product.title def test_get_inventory_by_gtin_multiple_locations_success( self, db, test_marketplace_product ): """Test getting inventory summary with multiple locations successfully.""" unique_gtin = test_marketplace_product.gtin unique_id = str(uuid.uuid4())[:8] # Create multiple inventory entries for the same GTIN with unique locations inventory1 = Inventory( gtin=unique_gtin, location=f"WAREHOUSE_A_{unique_id}", quantity=50 ) inventory2 = Inventory( gtin=unique_gtin, location=f"WAREHOUSE_B_{unique_id}", quantity=30 ) db.add(inventory1) db.add(inventory2) db.commit() result = self.service.get_inventory_by_gtin(db, unique_gtin) assert result.gtin == unique_gtin assert result.total_quantity == 80 assert len(result.locations) == 2 def test_get_inventory_by_gtin_not_found_error(self, db): """Test getting inventory for non-existent GTIN returns InventoryNotFoundException.""" with pytest.raises(InventoryNotFoundException) as exc_info: self.service.get_inventory_by_gtin(db, "9999999999999") assert exc_info.value.error_code == "INVENTORY_NOT_FOUND" assert "9999999999999" in str(exc_info.value) assert exc_info.value.details["resource_type"] == "Inventory" def test_get_inventory_by_gtin_invalid_gtin_validation_error(self, db): """Test getting inventory with invalid GTIN returns InventoryValidationException.""" with pytest.raises(InventoryValidationException) as exc_info: self.service.get_inventory_by_gtin(db, "invalid_gtin") assert exc_info.value.error_code == "INVENTORY_VALIDATION_FAILED" assert "Invalid GTIN format" in str(exc_info.value) def test_get_total_inventory_success( self, db, test_inventory, test_marketplace_product ): """Test getting total inventory for a GTIN successfully.""" result = self.service.get_total_inventory(db, test_inventory.gtin) assert result["gtin"] == test_inventory.gtin assert result["total_quantity"] == test_inventory.quantity assert result["product_title"] == test_marketplace_product.title assert result["locations_count"] == 1 def test_get_total_inventory_invalid_gtin_validation_error(self, db): """Test getting total inventory with invalid GTIN returns InventoryValidationException.""" with pytest.raises(InventoryValidationException) as exc_info: self.service.get_total_inventory(db, "invalid_gtin") assert exc_info.value.error_code == "INVENTORY_VALIDATION_FAILED" assert "Invalid GTIN format" in str(exc_info.value) def test_get_total_inventory_not_found_error(self, db): """Test getting total inventory for non-existent GTIN returns InventoryNotFoundException.""" with pytest.raises(InventoryNotFoundException) as exc_info: self.service.get_total_inventory(db, "9999999999999") assert exc_info.value.error_code == "INVENTORY_NOT_FOUND" def test_get_all_inventory_no_filters_success(self, db, test_inventory): """Test getting all inventory without filters successfully.""" result = self.service.get_all_inventory(db) assert len(result) >= 1 assert any(inventory.gtin == test_inventory.gtin for inventory in result) def test_get_all_inventory_with_location_filter_success(self, db, test_inventory): """Test getting all inventory with location filter successfully.""" result = self.service.get_all_inventory(db, location=test_inventory.location) assert len(result) >= 1 # Check that all returned inventory match the filter (case insensitive) for inventory in result: assert test_inventory.location.upper() in inventory.location.upper() def test_get_all_inventory_with_gtin_filter_success(self, db, test_inventory): """Test getting all inventory with GTIN filter successfully.""" result = self.service.get_all_inventory(db, gtin=test_inventory.gtin) assert len(result) >= 1 assert all(inventory.gtin == test_inventory.gtin for inventory in result) def test_get_all_inventory_with_pagination_success(self, db): """Test getting all inventory with pagination successfully.""" unique_prefix = str(uuid.uuid4())[:8] # Create multiple inventory entries with unique GTINs and locations for i in range(5): inventory = Inventory( gtin=f"1234567890{i:03d}", # Creates valid 13-digit GTINs location=f"WAREHOUSE_{unique_prefix}_{i}", quantity=10, ) db.add(inventory) db.commit() result = self.service.get_all_inventory(db, skip=2, limit=2) assert ( len(result) <= 2 ) # Should be at most 2, might be less if other records exist def test_update_inventory_success(self, db, test_inventory): """Test updating inventory quantity successfully.""" inventory_update = InventoryUpdate(quantity=150) result = self.service.update_inventory(db, test_inventory.id, inventory_update) assert result.id == test_inventory.id assert result.quantity == 150 def test_update_inventory_not_found_error(self, db): """Test updating non-existent inventory entry returns InventoryNotFoundException.""" inventory_update = InventoryUpdate(quantity=150) with pytest.raises(InventoryNotFoundException) as exc_info: self.service.update_inventory(db, 99999, inventory_update) assert exc_info.value.error_code == "INVENTORY_NOT_FOUND" assert "99999" in str(exc_info.value) def test_update_inventory_invalid_quantity_error(self, db, test_inventory): """Test updating inventory with invalid quantity returns InvalidQuantityException.""" inventory_update = InventoryUpdate(quantity=-10) with pytest.raises(InvalidQuantityException) as exc_info: self.service.update_inventory(db, test_inventory.id, inventory_update) assert exc_info.value.error_code == "INVALID_QUANTITY" assert "Quantity cannot be negative" in str(exc_info.value) def test_delete_inventory_success(self, db, test_inventory): """Test deleting inventory entry successfully.""" inventory_id = test_inventory.id result = self.service.delete_inventory(db, inventory_id) assert result is True # Verify the inventory is actually deleted deleted_inventory = ( db.query(Inventory).filter(Inventory.id == inventory_id).first() ) assert deleted_inventory is None def test_delete_inventory_not_found_error(self, db): """Test deleting non-existent inventory entry returns InventoryNotFoundException.""" with pytest.raises(InventoryNotFoundException) as exc_info: self.service.delete_inventory(db, 99999) assert exc_info.value.error_code == "INVENTORY_NOT_FOUND" assert "99999" in str(exc_info.value) def test_get_low_inventory_items_success( self, db, test_inventory, test_marketplace_product ): """Test getting low inventory items successfully.""" # Set inventory to a low value test_inventory.quantity = 5 db.commit() result = self.service.get_low_inventory_items(db, threshold=10) assert len(result) >= 1 low_inventory_item = next( (item for item in result if item["gtin"] == test_inventory.gtin), None ) assert low_inventory_item is not None assert low_inventory_item["current_quantity"] == 5 assert low_inventory_item["location"] == test_inventory.location assert low_inventory_item["product_title"] == test_marketplace_product.title def test_get_low_inventory_items_invalid_threshold_error(self, db): """Test getting low inventory items with invalid threshold returns InvalidQuantityException.""" with pytest.raises(InvalidQuantityException) as exc_info: self.service.get_low_inventory_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_inventory_summary_by_location_success(self, db, test_inventory): """Test getting inventory summary by location successfully.""" result = self.service.get_inventory_summary_by_location( db, test_inventory.location ) assert ( result["location"] == test_inventory.location.upper() ) # Service normalizes to uppercase assert result["total_items"] >= 1 assert result["total_quantity"] >= test_inventory.quantity assert result["unique_gtins"] >= 1 def test_get_inventory_summary_by_location_empty_result(self, db): """Test getting inventory summary for location with no inventory.""" unique_id = str(uuid.uuid4())[:8] result = self.service.get_inventory_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) inventory_data = InventoryCreate( gtin="1234567890123", location="WAREHOUSE_A", quantity=0 ) result = self.service.set_inventory(db, inventory_data) assert result.quantity == 0 # Test zero quantity with add_inventory (should fail - doesn't allow zero) inventory_data_add = InventoryAdd( gtin="1234567890123", location="WAREHOUSE_B", quantity=0 ) with pytest.raises(InvalidQuantityException): self.service.add_inventory(db, inventory_data_add) def test_exception_structure_consistency(self, db): """Test that all exceptions follow the consistent WizamartException structure.""" # Test with a known error case with pytest.raises(InventoryNotFoundException) as exc_info: self.service.get_inventory_by_gtin(db, "9999999999999") exception = exc_info.value # Verify exception structure matches WizamartException.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_inventory(db, test_inventory): """Create a test product that corresponds to the test inventory.""" product = MarketplaceProduct( marketplace_product_id="MP_TEST_001", title="Inventory Test MarketplaceProduct", gtin=test_inventory.gtin, price="29.99", brand="TestBrand", marketplace="Letzshop", ) db.add(product) db.commit() db.refresh(product) return product