# tests/unit/services/test_inventory_service.py """Unit tests for InventoryService.""" import uuid import pytest from app.modules.catalog.exceptions import ProductNotFoundException from app.modules.inventory.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, ) from app.modules.inventory.models import Inventory from app.modules.inventory.schemas import ( InventoryAdjust, InventoryCreate, InventoryReserve, InventoryUpdate, ) from app.modules.inventory.services.inventory_service import InventoryService @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_store): """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_store.id, inventory_data) assert result.product_id == test_product.id assert result.store_id == test_store.id assert result.bin_location == f"WAREHOUSE_NEW_{unique_id}" assert result.quantity == 100 def test_set_inventory_existing_entry_replaces( self, db, test_inventory, test_product, test_store ): """Test setting inventory replaces existing quantity.""" inventory_data = InventoryCreate( product_id=test_product.id, location=test_inventory.bin_location, quantity=200, ) result = self.service.set_inventory(db, test_store.id, inventory_data) assert result.quantity == 200 # Replaced, not added def test_set_inventory_product_not_found(self, db, test_store): """Test setting inventory for non-existent product raises exception.""" from app.exceptions 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_store.id, inventory_data) def test_set_inventory_zero_quantity(self, db, test_product, test_store): """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_store.id, inventory_data) assert result.quantity == 0 # ==================== Adjust Inventory Tests ==================== def test_adjust_inventory_add_new_entry(self, db, test_product, test_store): """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_store.id, inventory_data) assert result.quantity == 50 def test_adjust_inventory_add_to_existing( self, db, test_inventory, test_product, test_store ): """Test adjusting inventory adds to existing quantity.""" original_quantity = test_inventory.quantity inventory_data = InventoryAdjust( product_id=test_product.id, location=test_inventory.bin_location, quantity=25, ) result = self.service.adjust_inventory(db, test_store.id, inventory_data) assert result.quantity == original_quantity + 25 def test_adjust_inventory_remove_from_existing( self, db, test_inventory, test_product, test_store ): """Test adjusting inventory removes from existing quantity.""" original_quantity = test_inventory.quantity inventory_data = InventoryAdjust( product_id=test_product.id, location=test_inventory.bin_location, quantity=-10, ) result = self.service.adjust_inventory(db, test_store.id, inventory_data) assert result.quantity == original_quantity - 10 def test_adjust_inventory_remove_insufficient( self, db, test_inventory, test_product, test_store ): """Test removing more than available raises exception.""" from app.exceptions import ValidationException inventory_data = InventoryAdjust( product_id=test_product.id, location=test_inventory.bin_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_store.id, inventory_data) def test_adjust_inventory_remove_nonexistent(self, db, test_product, test_store): """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_store.id, inventory_data) # ==================== Reserve Inventory Tests ==================== def test_reserve_inventory_success( self, db, test_inventory, test_product, test_store ): """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.bin_location, quantity=reserve_qty, ) result = self.service.reserve_inventory(db, test_store.id, reserve_data) assert result.reserved_quantity == original_reserved + reserve_qty def test_reserve_inventory_insufficient_available( self, db, test_inventory, test_product, test_store ): """Test reserving more than available raises exception.""" from app.exceptions import ValidationException available = test_inventory.quantity - test_inventory.reserved_quantity reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.bin_location, quantity=available + 100, # More than available ) # Service wraps InsufficientInventoryException in ValidationException with pytest.raises((InsufficientInventoryException, ValidationException)): self.service.reserve_inventory(db, test_store.id, reserve_data) def test_reserve_inventory_not_found(self, db, test_product, test_store): """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_store.id, reserve_data) # ==================== Release Reservation Tests ==================== def test_release_reservation_success( self, db, test_inventory, test_product, test_store ): """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.bin_location, quantity=release_qty, ) result = self.service.release_reservation(db, test_store.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_store ): """Test releasing more than reserved sets to zero (doesn't error).""" reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.bin_location, quantity=test_inventory.reserved_quantity + 100, ) result = self.service.release_reservation(db, test_store.id, reserve_data) assert result.reserved_quantity == 0 # ==================== Fulfill Reservation Tests ==================== def test_fulfill_reservation_success( self, db, test_inventory, test_product, test_store ): """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.bin_location, quantity=fulfill_qty, ) result = self.service.fulfill_reservation(db, test_store.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_store ): """Test fulfilling more than quantity raises exception.""" from app.exceptions import ValidationException reserve_data = InventoryReserve( product_id=test_product.id, location=test_inventory.bin_location, quantity=test_inventory.quantity + 100, ) # Service wraps InsufficientInventoryException in ValidationException with pytest.raises((InsufficientInventoryException, ValidationException)): self.service.fulfill_reservation(db, test_store.id, reserve_data) # ==================== Get Product Inventory Tests ==================== def test_get_product_inventory_success( self, db, test_inventory, test_product, test_store ): """Test getting product inventory summary.""" result = self.service.get_product_inventory(db, test_store.id, test_product.id) assert result.product_id == test_product.id assert result.store_id == test_store.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_store): """Test getting inventory for product with no inventory entries.""" # Create a new product without inventory from app.modules.catalog.models import Product from app.modules.marketplace.models import ( MarketplaceProduct, MarketplaceProductTranslation, ) 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( store_id=test_store.id, marketplace_product_id=mp.id, is_active=True, ) db.add(product) db.commit() result = self.service.get_product_inventory(db, test_store.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_store): """Test getting inventory for non-existent product raises exception.""" from app.exceptions import ValidationException # Service wraps ProductNotFoundException in ValidationException with pytest.raises((ProductNotFoundException, ValidationException)): self.service.get_product_inventory(db, test_store.id, 99999) # ==================== Get Store Inventory Tests ==================== def test_get_store_inventory_success(self, db, test_inventory, test_store): """Test getting all store inventory.""" result = self.service.get_store_inventory(db, test_store.id) assert len(result) >= 1 assert any(inv.id == test_inventory.id for inv in result) def test_get_store_inventory_with_location_filter( self, db, test_inventory, test_store ): """Test getting store inventory filtered by location.""" result = self.service.get_store_inventory( db, test_store.id, location=test_inventory.bin_location[:10] ) assert len(result) >= 1 for inv in result: assert test_inventory.bin_location[:10].upper() in inv.bin_location.upper() def test_get_store_inventory_with_low_stock_filter(self, db, test_store): """Test getting store inventory filtered by low stock threshold.""" result = self.service.get_store_inventory( db, test_store.id, low_stock_threshold=5 ) for inv in result: assert inv.quantity <= 5 def test_get_store_inventory_pagination(self, db, test_store): """Test store inventory pagination.""" result = self.service.get_store_inventory(db, test_store.id, skip=0, limit=10) assert len(result) <= 10 # ==================== Update Inventory Tests ==================== def test_update_inventory_quantity(self, db, test_inventory, test_store): """Test updating inventory quantity.""" inventory_update = InventoryUpdate(quantity=500) result = self.service.update_inventory( db, test_store.id, test_inventory.id, inventory_update ) assert result.quantity == 500 def test_update_inventory_reserved_quantity(self, db, test_inventory, test_store): """Test updating inventory reserved quantity.""" inventory_update = InventoryUpdate(reserved_quantity=20) result = self.service.update_inventory( db, test_store.id, test_inventory.id, inventory_update ) assert result.reserved_quantity == 20 def test_update_inventory_location(self, db, test_inventory, test_store): """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_store.id, test_inventory.id, inventory_update ) assert result.bin_location == new_location.upper() def test_update_inventory_not_found(self, db, test_store): """Test updating non-existent inventory raises InventoryNotFoundException.""" inventory_update = InventoryUpdate(quantity=100) with pytest.raises(InventoryNotFoundException): self.service.update_inventory(db, test_store.id, 99999, inventory_update) def test_update_inventory_wrong_store(self, db, test_inventory, other_merchant): """Test updating inventory from wrong store raises InventoryNotFoundException.""" from app.modules.tenancy.models import Store unique_id = str(uuid.uuid4())[:8] other_store = Store( merchant_id=other_merchant.id, store_code=f"OTHER_{unique_id.upper()}", subdomain=f"other{unique_id.lower()}", name=f"Other Store {unique_id}", is_active=True, ) db.add(other_store) db.commit() inventory_update = InventoryUpdate(quantity=100) with pytest.raises(InventoryNotFoundException): self.service.update_inventory( db, other_store.id, test_inventory.id, inventory_update ) # ==================== Delete Inventory Tests ==================== def test_delete_inventory_success(self, db, test_inventory, test_store): """Test deleting inventory entry.""" inventory_id = test_inventory.id result = self.service.delete_inventory(db, test_store.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_store): """Test deleting non-existent inventory raises InventoryNotFoundException.""" with pytest.raises(InventoryNotFoundException): self.service.delete_inventory(db, test_store.id, 99999) def test_delete_inventory_wrong_store(self, db, test_inventory, other_merchant): """Test deleting inventory from wrong store raises InventoryNotFoundException.""" from app.modules.tenancy.models import Store unique_id = str(uuid.uuid4())[:8] other_store = Store( merchant_id=other_merchant.id, store_code=f"DELOTHER_{unique_id.upper()}", subdomain=f"delother{unique_id.lower()}", name=f"Delete Other Store {unique_id}", is_active=True, ) db.add(other_store) db.commit() with pytest.raises(InventoryNotFoundException): self.service.delete_inventory(db, other_store.id, test_inventory.id) # ==================== Admin Method Tests ==================== def test_get_all_inventory_admin_success(self, db, test_inventory): """Test get_all_inventory_admin returns all inventory.""" result = self.service.get_all_inventory_admin(db) assert result.total >= 1 assert len(result.inventories) >= 1 assert any(inv.id == test_inventory.id for inv in result.inventories) def test_get_all_inventory_admin_with_store_filter( self, db, test_inventory, test_store ): """Test get_all_inventory_admin filters by store.""" result = self.service.get_all_inventory_admin( db, store_id=test_store.id ) for inv in result.inventories: assert inv.store_id == test_store.id def test_get_all_inventory_admin_with_location_filter( self, db, test_inventory ): """Test get_all_inventory_admin filters by location.""" location_prefix = test_inventory.bin_location[:5] result = self.service.get_all_inventory_admin( db, location=location_prefix ) for inv in result.inventories: assert location_prefix.upper() in inv.location.upper() def test_get_all_inventory_admin_with_low_stock_filter(self, db): """Test get_all_inventory_admin filters by low stock.""" result = self.service.get_all_inventory_admin(db, low_stock=5) for inv in result.inventories: assert inv.quantity <= 5 def test_get_all_inventory_admin_pagination(self, db): """Test get_all_inventory_admin pagination.""" result = self.service.get_all_inventory_admin(db, skip=0, limit=5) assert len(result.inventories) <= 5 assert result.skip == 0 assert result.limit == 5 def test_get_inventory_stats_admin(self, db, test_inventory): """Test get_inventory_stats_admin returns stats.""" result = self.service.get_inventory_stats_admin(db) assert result.total_entries >= 1 assert result.total_quantity >= test_inventory.quantity assert result.total_reserved >= 0 assert result.total_available >= 0 assert result.low_stock_count >= 0 assert result.stores_with_inventory >= 1 assert result.unique_locations >= 1 def test_get_low_stock_items_admin(self, db, test_product, test_store): """Test get_low_stock_items_admin returns low stock items.""" # Create low stock inventory unique_id = str(uuid.uuid4())[:8].upper() low_stock_inv = Inventory( product_id=test_product.id, store_id=test_store.id, warehouse="strassen", bin_location=f"LOW_{unique_id}", quantity=3, reserved_quantity=0, ) db.add(low_stock_inv) db.commit() result = self.service.get_low_stock_items_admin(db, threshold=10) assert len(result) >= 1 for item in result: assert item.quantity <= 10 def test_get_low_stock_items_admin_with_store_filter( self, db, test_inventory, test_store ): """Test get_low_stock_items_admin filters by store.""" result = self.service.get_low_stock_items_admin( db, threshold=1000, store_id=test_store.id ) for item in result: assert item.store_id == test_store.id def test_get_stores_with_inventory_admin(self, db, test_inventory, test_store): """Test get_stores_with_inventory_admin returns stores list.""" result = self.service.get_stores_with_inventory_admin(db) assert len(result.stores) >= 1 assert any(v.id == test_store.id for v in result.stores) def test_get_inventory_locations_admin(self, db, test_inventory): """Test get_inventory_locations_admin returns locations.""" result = self.service.get_inventory_locations_admin(db) assert len(result.locations) >= 1 assert test_inventory.bin_location in result.locations def test_get_inventory_locations_admin_with_store_filter( self, db, test_inventory, test_store ): """Test get_inventory_locations_admin filters by store.""" result = self.service.get_inventory_locations_admin( db, store_id=test_store.id ) assert len(result.locations) >= 1 def test_get_store_inventory_admin_success( self, db, test_inventory, test_store ): """Test get_store_inventory_admin returns store inventory.""" result = self.service.get_store_inventory_admin( db, store_id=test_store.id ) assert result.total >= 1 assert result.store_filter == test_store.id for inv in result.inventories: assert inv.store_id == test_store.id def test_get_store_inventory_admin_store_not_found(self, db): """Test get_store_inventory_admin raises for non-existent store.""" from app.modules.tenancy.exceptions import StoreNotFoundException with pytest.raises(StoreNotFoundException): self.service.get_store_inventory_admin(db, store_id=99999) def test_get_product_inventory_admin(self, db, test_inventory, test_product): """Test get_product_inventory_admin returns product inventory.""" result = self.service.get_product_inventory_admin(db, test_product.id) assert result.product_id == test_product.id assert result.total_quantity >= test_inventory.quantity def test_get_product_inventory_admin_not_found(self, db): """Test get_product_inventory_admin raises for non-existent product.""" with pytest.raises(ProductNotFoundException): self.service.get_product_inventory_admin(db, 99999) def test_verify_store_exists_success(self, db, test_store): """Test verify_store_exists returns store.""" result = self.service.verify_store_exists(db, test_store.id) assert result.id == test_store.id def test_verify_store_exists_not_found(self, db): """Test verify_store_exists raises for non-existent store.""" from app.modules.tenancy.exceptions import StoreNotFoundException with pytest.raises(StoreNotFoundException): self.service.verify_store_exists(db, 99999) def test_get_inventory_by_id_admin_success(self, db, test_inventory): """Test get_inventory_by_id_admin returns inventory.""" result = self.service.get_inventory_by_id_admin(db, test_inventory.id) assert result.id == test_inventory.id def test_get_inventory_by_id_admin_not_found(self, db): """Test get_inventory_by_id_admin raises for non-existent.""" with pytest.raises(InventoryNotFoundException): self.service.get_inventory_by_id_admin(db, 99999) # ==================== Private Helper Tests ==================== def test_get_store_product_success(self, db, test_product, test_store): """Test _get_store_product returns product.""" result = self.service._get_store_product( db, test_store.id, test_product.id ) assert result.id == test_product.id def test_get_store_product_not_found(self, db, test_store): """Test _get_store_product raises for non-existent product.""" with pytest.raises(ProductNotFoundException): self.service._get_store_product(db, test_store.id, 99999) def test_get_store_product_wrong_store( self, db, test_product, other_merchant ): """Test _get_store_product raises for wrong store.""" from app.modules.tenancy.models import Store unique_id = str(uuid.uuid4())[:8] other_store = Store( merchant_id=other_merchant.id, store_code=f"HELPER_{unique_id.upper()}", subdomain=f"helper{unique_id.lower()}", name=f"Helper Test Store {unique_id}", is_active=True, ) db.add(other_store) db.commit() with pytest.raises(ProductNotFoundException): self.service._get_store_product( db, other_store.id, test_product.id ) def test_get_inventory_entry_returns_existing( self, db, test_inventory, test_product ): """Test _get_inventory_entry returns existing entry.""" result = self.service._get_inventory_entry( db, test_product.id, test_inventory.bin_location ) assert result is not None assert result.id == test_inventory.id def test_get_inventory_entry_returns_none(self, db, test_product): """Test _get_inventory_entry returns None when not found.""" result = self.service._get_inventory_entry( db, test_product.id, "NONEXISTENT_LOCATION" ) assert result is None def test_get_inventory_by_id_returns_existing(self, db, test_inventory): """Test _get_inventory_by_id returns existing entry.""" result = self.service._get_inventory_by_id(db, test_inventory.id) assert result.id == test_inventory.id def test_get_inventory_by_id_raises_not_found(self, db): """Test _get_inventory_by_id raises when not found.""" with pytest.raises(InventoryNotFoundException): self.service._get_inventory_by_id(db, 99999)