Files
orion/tests/unit/services/test_stock_service.py

506 lines
22 KiB
Python

# 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.product import Product
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_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_product.title
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)
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_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_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_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_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 = Product(
product_id="STOCK_TEST_001",
title="Stock Test Product",
gtin=test_stock.gtin,
price="29.99",
brand="TestBrand",
marketplace="Letzshop",
)
db.add(product)
db.commit()
db.refresh(product)
return product