# tests/unit/services/test_inventory_service.py """Unit tests for InventoryService.""" import uuid import pytest from app.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, ProductNotFoundException, ) from app.services.inventory_service import InventoryService from models.database.inventory import Inventory from models.schema.inventory import ( InventoryAdjust, InventoryCreate, InventoryReserve, InventoryUpdate, ) @pytest.mark.unit @pytest.mark.inventory class TestInventoryService: """Test suite for InventoryService.""" def setup_method(self): """Initialize service instance before each test.""" self.service = InventoryService() # ==================== Validation Helper Tests ==================== def test_validate_quantity_positive(self): """Test that positive quantity passes validation.""" # Should not raise self.service._validate_quantity(10, allow_zero=True) self.service._validate_quantity(10, allow_zero=False) def test_validate_quantity_zero_allowed(self): """Test that zero quantity passes when allow_zero=True.""" # Should not raise self.service._validate_quantity(0, allow_zero=True) def test_validate_quantity_zero_not_allowed(self): """Test that zero quantity fails when allow_zero=False.""" with pytest.raises(InvalidQuantityException) as exc_info: self.service._validate_quantity(0, allow_zero=False) assert exc_info.value.error_code == "INVALID_QUANTITY" assert "must be positive" in str(exc_info.value) def test_validate_quantity_negative(self): """Test that negative quantity fails validation.""" with pytest.raises(InvalidQuantityException) as exc_info: self.service._validate_quantity(-5, allow_zero=True) assert exc_info.value.error_code == "INVALID_QUANTITY" assert "cannot be negative" in str(exc_info.value) def test_validate_quantity_none(self): """Test that None quantity fails validation.""" with pytest.raises(InvalidQuantityException) as exc_info: self.service._validate_quantity(None, allow_zero=True) assert exc_info.value.error_code == "INVALID_QUANTITY" assert "required" in str(exc_info.value) def test_validate_location_valid(self): """Test valid location normalization.""" result = self.service._validate_location("warehouse_a") assert result == "WAREHOUSE_A" def test_validate_location_with_whitespace(self): """Test location normalization strips whitespace.""" result = self.service._validate_location(" warehouse_a ") assert result == "WAREHOUSE_A" def test_validate_location_empty(self): """Test empty location fails validation.""" with pytest.raises(InventoryValidationException) as exc_info: self.service._validate_location("") assert "required" in str(exc_info.value) def test_validate_location_whitespace_only(self): """Test whitespace-only location fails validation.""" with pytest.raises(InventoryValidationException) as exc_info: self.service._validate_location(" ") assert "required" in str(exc_info.value) # ==================== Set Inventory Tests ==================== def test_set_inventory_new_entry_success(self, db, test_product, test_vendor): """Test setting inventory for a new product/location combination.""" unique_id = str(uuid.uuid4())[:8].upper() inventory_data = InventoryCreate( product_id=test_product.id, location=f"WAREHOUSE_NEW_{unique_id}", quantity=100, ) result = self.service.set_inventory(db, test_vendor.id, inventory_data) assert result.product_id == test_product.id assert result.vendor_id == test_vendor.id assert result.location == f"WAREHOUSE_NEW_{unique_id}" assert result.quantity == 100 def test_set_inventory_existing_entry_replaces( self, db, test_inventory, test_product, test_vendor ): """Test setting inventory replaces existing quantity.""" inventory_data = InventoryCreate( product_id=test_product.id, location=test_inventory.location, quantity=200, ) result = self.service.set_inventory(db, test_vendor.id, inventory_data) assert result.quantity == 200 # Replaced, not added def test_set_inventory_product_not_found(self, db, test_vendor): """Test setting inventory for non-existent product raises exception.""" from app.exceptions.base import ValidationException unique_id = str(uuid.uuid4())[:8].upper() inventory_data = InventoryCreate( product_id=99999, # Non-existent product location=f"WAREHOUSE_{unique_id}", quantity=100, ) # Service wraps ProductNotFoundException in ValidationException with pytest.raises((ProductNotFoundException, ValidationException)): self.service.set_inventory(db, test_vendor.id, inventory_data) def test_set_inventory_zero_quantity(self, db, test_product, test_vendor): """Test setting inventory with zero quantity succeeds.""" unique_id = str(uuid.uuid4())[:8].upper() inventory_data = InventoryCreate( product_id=test_product.id, location=f"WAREHOUSE_ZERO_{unique_id}", quantity=0, ) result = self.service.set_inventory(db, test_vendor.id, inventory_data) assert result.quantity == 0 # ==================== Adjust Inventory Tests ==================== def test_adjust_inventory_add_new_entry(self, db, test_product, test_vendor): """Test adjusting inventory creates new entry with positive quantity.""" unique_id = str(uuid.uuid4())[:8].upper() inventory_data = InventoryAdjust( product_id=test_product.id, location=f"WAREHOUSE_ADJUST_{unique_id}", quantity=50, ) result = self.service.adjust_inventory(db, test_vendor.id, inventory_data) assert result.quantity == 50 def test_adjust_inventory_add_to_existing( self, db, test_inventory, test_product, test_vendor ): """Test adjusting inventory adds to existing quantity.""" original_quantity = test_inventory.quantity inventory_data = InventoryAdjust( product_id=test_product.id, location=test_inventory.location, quantity=25, ) result = self.service.adjust_inventory(db, test_vendor.id, inventory_data) assert result.quantity == original_quantity + 25 def test_adjust_inventory_remove_from_existing( self, db, test_inventory, test_product, test_vendor ): """Test adjusting inventory removes from existing quantity.""" original_quantity = test_inventory.quantity inventory_data = InventoryAdjust( product_id=test_product.id, location=test_inventory.location, quantity=-10, ) result = self.service.adjust_inventory(db, test_vendor.id, inventory_data) assert result.quantity == original_quantity - 10 def test_adjust_inventory_remove_insufficient( self, db, test_inventory, test_product, test_vendor ): """Test removing more than available raises exception.""" from app.exceptions.base import ValidationException inventory_data = InventoryAdjust( product_id=test_product.id, location=test_inventory.location, quantity=-(test_inventory.quantity + 100), # More than available ) # Service wraps InsufficientInventoryException in ValidationException with pytest.raises((InsufficientInventoryException, ValidationException)): self.service.adjust_inventory(db, test_vendor.id, inventory_data) def test_adjust_inventory_remove_nonexistent(self, db, test_product, test_vendor): """Test removing from non-existent inventory raises InventoryNotFoundException.""" unique_id = str(uuid.uuid4())[:8].upper() inventory_data = InventoryAdjust( product_id=test_product.id, location=f"NONEXISTENT_{unique_id}", quantity=-10, ) with pytest.raises(InventoryNotFoundException): self.service.adjust_inventory(db, test_vendor.id, inventory_data) # ==================== Reserve Inventory Tests ==================== def test_reserve_inventory_success( self, db, test_inventory, test_product, test_vendor ): """Test reserving inventory succeeds.""" original_reserved = test_inventory.reserved_quantity available = test_inventory.quantity - test_inventory.reserved_quantity reserve_qty = min(10, available) reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.location, quantity=reserve_qty, ) result = self.service.reserve_inventory(db, test_vendor.id, reserve_data) assert result.reserved_quantity == original_reserved + reserve_qty def test_reserve_inventory_insufficient_available( self, db, test_inventory, test_product, test_vendor ): """Test reserving more than available raises exception.""" from app.exceptions.base import ValidationException available = test_inventory.quantity - test_inventory.reserved_quantity reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.location, quantity=available + 100, # More than available ) # Service wraps InsufficientInventoryException in ValidationException with pytest.raises((InsufficientInventoryException, ValidationException)): self.service.reserve_inventory(db, test_vendor.id, reserve_data) def test_reserve_inventory_not_found(self, db, test_product, test_vendor): """Test reserving non-existent inventory raises InventoryNotFoundException.""" unique_id = str(uuid.uuid4())[:8].upper() reserve_data = InventoryReserve( product_id=test_product.id, location=f"NONEXISTENT_{unique_id}", quantity=10, ) with pytest.raises(InventoryNotFoundException): self.service.reserve_inventory(db, test_vendor.id, reserve_data) # ==================== Release Reservation Tests ==================== def test_release_reservation_success( self, db, test_inventory, test_product, test_vendor ): """Test releasing reservation succeeds.""" original_reserved = test_inventory.reserved_quantity release_qty = min(5, original_reserved) reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.location, quantity=release_qty, ) result = self.service.release_reservation(db, test_vendor.id, reserve_data) assert result.reserved_quantity == original_reserved - release_qty def test_release_reservation_more_than_reserved( self, db, test_inventory, test_product, test_vendor ): """Test releasing more than reserved sets to zero (doesn't error).""" reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.location, quantity=test_inventory.reserved_quantity + 100, ) result = self.service.release_reservation(db, test_vendor.id, reserve_data) assert result.reserved_quantity == 0 # ==================== Fulfill Reservation Tests ==================== def test_fulfill_reservation_success( self, db, test_inventory, test_product, test_vendor ): """Test fulfilling reservation decreases both quantity and reserved.""" original_quantity = test_inventory.quantity original_reserved = test_inventory.reserved_quantity fulfill_qty = min(5, original_reserved, original_quantity) reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.location, quantity=fulfill_qty, ) result = self.service.fulfill_reservation(db, test_vendor.id, reserve_data) assert result.quantity == original_quantity - fulfill_qty assert result.reserved_quantity == max(0, original_reserved - fulfill_qty) def test_fulfill_reservation_insufficient_inventory( self, db, test_inventory, test_product, test_vendor ): """Test fulfilling more than quantity raises exception.""" from app.exceptions.base import ValidationException reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.location, quantity=test_inventory.quantity + 100, ) # Service wraps InsufficientInventoryException in ValidationException with pytest.raises((InsufficientInventoryException, ValidationException)): self.service.fulfill_reservation(db, test_vendor.id, reserve_data) # ==================== Get Product Inventory Tests ==================== def test_get_product_inventory_success( self, db, test_inventory, test_product, test_vendor ): """Test getting product inventory summary.""" result = self.service.get_product_inventory(db, test_vendor.id, test_product.id) assert result.product_id == test_product.id assert result.vendor_id == test_vendor.id assert result.total_quantity >= test_inventory.quantity assert len(result.locations) >= 1 def test_get_product_inventory_no_inventory(self, db, test_product, test_vendor): """Test getting inventory for product with no inventory entries.""" # Create a new product without inventory from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product_translation import ( MarketplaceProductTranslation, ) from models.database.product import Product unique_id = str(uuid.uuid4())[:8] mp = MarketplaceProduct( marketplace_product_id=f"MP_EMPTY_{unique_id}", price="29.99", marketplace="Letzshop", ) db.add(mp) db.flush() # Create translation for the product (title is now in translations table) translation = MarketplaceProductTranslation( marketplace_product_id=mp.id, language="en", title="Product Without Inventory", ) db.add(translation) db.commit() product = Product( vendor_id=test_vendor.id, marketplace_product_id=mp.id, is_active=True, ) db.add(product) db.commit() result = self.service.get_product_inventory(db, test_vendor.id, product.id) assert result.total_quantity == 0 assert result.total_reserved == 0 assert len(result.locations) == 0 def test_get_product_inventory_not_found(self, db, test_vendor): """Test getting inventory for non-existent product raises exception.""" from app.exceptions.base import ValidationException # Service wraps ProductNotFoundException in ValidationException with pytest.raises((ProductNotFoundException, ValidationException)): self.service.get_product_inventory(db, test_vendor.id, 99999) # ==================== Get Vendor Inventory Tests ==================== def test_get_vendor_inventory_success(self, db, test_inventory, test_vendor): """Test getting all vendor inventory.""" result = self.service.get_vendor_inventory(db, test_vendor.id) assert len(result) >= 1 assert any(inv.id == test_inventory.id for inv in result) def test_get_vendor_inventory_with_location_filter( self, db, test_inventory, test_vendor ): """Test getting vendor inventory filtered by location.""" result = self.service.get_vendor_inventory( db, test_vendor.id, location=test_inventory.location[:10] ) assert len(result) >= 1 for inv in result: assert test_inventory.location[:10].upper() in inv.location.upper() def test_get_vendor_inventory_with_low_stock_filter(self, db, test_vendor): """Test getting vendor inventory filtered by low stock threshold.""" result = self.service.get_vendor_inventory( db, test_vendor.id, low_stock_threshold=5 ) for inv in result: assert inv.quantity <= 5 def test_get_vendor_inventory_pagination(self, db, test_vendor): """Test vendor inventory pagination.""" result = self.service.get_vendor_inventory(db, test_vendor.id, skip=0, limit=10) assert len(result) <= 10 # ==================== Update Inventory Tests ==================== def test_update_inventory_quantity(self, db, test_inventory, test_vendor): """Test updating inventory quantity.""" inventory_update = InventoryUpdate(quantity=500) result = self.service.update_inventory( db, test_vendor.id, test_inventory.id, inventory_update ) assert result.quantity == 500 def test_update_inventory_reserved_quantity(self, db, test_inventory, test_vendor): """Test updating inventory reserved quantity.""" inventory_update = InventoryUpdate(reserved_quantity=20) result = self.service.update_inventory( db, test_vendor.id, test_inventory.id, inventory_update ) assert result.reserved_quantity == 20 def test_update_inventory_location(self, db, test_inventory, test_vendor): """Test updating inventory location.""" unique_id = str(uuid.uuid4())[:8].upper() new_location = f"NEW_LOCATION_{unique_id}" inventory_update = InventoryUpdate(location=new_location) result = self.service.update_inventory( db, test_vendor.id, test_inventory.id, inventory_update ) assert result.location == new_location.upper() def test_update_inventory_not_found(self, db, test_vendor): """Test updating non-existent inventory raises InventoryNotFoundException.""" inventory_update = InventoryUpdate(quantity=100) with pytest.raises(InventoryNotFoundException): self.service.update_inventory(db, test_vendor.id, 99999, inventory_update) def test_update_inventory_wrong_vendor(self, db, test_inventory, other_company): """Test updating inventory from wrong vendor raises InventoryNotFoundException.""" from models.database.vendor import Vendor unique_id = str(uuid.uuid4())[:8] other_vendor = Vendor( company_id=other_company.id, vendor_code=f"OTHER_{unique_id.upper()}", subdomain=f"other{unique_id.lower()}", name=f"Other Vendor {unique_id}", is_active=True, ) db.add(other_vendor) db.commit() inventory_update = InventoryUpdate(quantity=100) with pytest.raises(InventoryNotFoundException): self.service.update_inventory( db, other_vendor.id, test_inventory.id, inventory_update ) # ==================== Delete Inventory Tests ==================== def test_delete_inventory_success(self, db, test_inventory, test_vendor): """Test deleting inventory entry.""" inventory_id = test_inventory.id result = self.service.delete_inventory(db, test_vendor.id, inventory_id) assert result is True # Verify deleted deleted = db.query(Inventory).filter(Inventory.id == inventory_id).first() assert deleted is None def test_delete_inventory_not_found(self, db, test_vendor): """Test deleting non-existent inventory raises InventoryNotFoundException.""" with pytest.raises(InventoryNotFoundException): self.service.delete_inventory(db, test_vendor.id, 99999) def test_delete_inventory_wrong_vendor(self, db, test_inventory, other_company): """Test deleting inventory from wrong vendor raises InventoryNotFoundException.""" from models.database.vendor import Vendor unique_id = str(uuid.uuid4())[:8] other_vendor = Vendor( company_id=other_company.id, vendor_code=f"DELOTHER_{unique_id.upper()}", subdomain=f"delother{unique_id.lower()}", name=f"Delete Other Vendor {unique_id}", is_active=True, ) db.add(other_vendor) db.commit() with pytest.raises(InventoryNotFoundException): self.service.delete_inventory(db, other_vendor.id, test_inventory.id)