""" Unit tests for the InventoryService. Tests cover: - Adding and removing items - Equipment slot validation - Level and class requirement checks - Consumable usage and effect application - Bulk operations - Error handling """ import pytest from unittest.mock import Mock, patch, MagicMock from typing import List from app.models.character import Character from app.models.items import Item from app.models.effects import Effect from app.models.stats import Stats from app.models.enums import ItemType, ItemRarity, EffectType, StatType, DamageType from app.models.skills import PlayerClass from app.models.origins import Origin from app.services.inventory_service import ( InventoryService, ItemNotFoundError, CannotEquipError, InvalidSlotError, CannotUseItemError, InventoryFullError, ConsumableResult, VALID_SLOTS, ITEM_TYPE_SLOTS, MAX_INVENTORY_SIZE, get_inventory_service, ) # ============================================================================= # Test Fixtures # ============================================================================= @pytest.fixture def mock_character_service(): """Create a mock CharacterService.""" service = Mock() service.update_character = Mock() return service @pytest.fixture def inventory_service(mock_character_service): """Create InventoryService with mocked dependencies.""" return InventoryService(character_service=mock_character_service) @pytest.fixture def mock_origin(): """Create a minimal Origin for testing.""" from app.models.origins import StartingLocation, StartingBonus starting_location = StartingLocation( id="test_location", name="Test Village", region="Test Region", description="A test location" ) return Origin( id="test_origin", name="Test Origin", description="A test origin for testing purposes", starting_location=starting_location, narrative_hooks=["test hook"], starting_bonus=None, ) @pytest.fixture def mock_player_class(): """Create a minimal PlayerClass for testing.""" return PlayerClass( class_id="warrior", name="Warrior", description="A mighty warrior", base_stats=Stats( strength=14, dexterity=10, constitution=12, intelligence=8, wisdom=10, charisma=8, luck=8, ), skill_trees=[], starting_abilities=["basic_attack"], ) @pytest.fixture def test_character(mock_player_class, mock_origin): """Create a test character.""" return Character( character_id="char_test_123", user_id="user_test_456", name="Test Hero", player_class=mock_player_class, origin=mock_origin, level=5, experience=0, base_stats=mock_player_class.base_stats.copy(), inventory=[], equipped={}, gold=100, ) @pytest.fixture def test_weapon(): """Create a test weapon item.""" return Item( item_id="iron_sword", name="Iron Sword", item_type=ItemType.WEAPON, rarity=ItemRarity.COMMON, description="A sturdy iron sword", value=50, damage=10, damage_type=DamageType.PHYSICAL, crit_chance=0.05, crit_multiplier=2.0, required_level=1, ) @pytest.fixture def test_armor(): """Create a test armor item.""" return Item( item_id="leather_chest", name="Leather Chestpiece", item_type=ItemType.ARMOR, rarity=ItemRarity.COMMON, description="Simple leather armor", value=40, defense=5, resistance=2, required_level=1, ) @pytest.fixture def test_helmet(): """Create a test helmet item.""" return Item( item_id="iron_helm", name="Iron Helmet", item_type=ItemType.ARMOR, rarity=ItemRarity.UNCOMMON, description="A protective iron helmet", value=30, defense=3, resistance=1, required_level=3, ) @pytest.fixture def test_consumable(): """Create a test consumable item (health potion).""" return Item( item_id="health_potion_small", name="Small Health Potion", item_type=ItemType.CONSUMABLE, rarity=ItemRarity.COMMON, description="Restores 25 HP", value=10, effects_on_use=[ Effect( effect_id="heal_25", name="Minor Healing", effect_type=EffectType.HOT, duration=1, power=25, stacks=1, ) ], ) @pytest.fixture def test_buff_potion(): """Create a test buff potion.""" return Item( item_id="strength_potion", name="Potion of Strength", item_type=ItemType.CONSUMABLE, rarity=ItemRarity.UNCOMMON, description="Increases strength temporarily", value=25, effects_on_use=[ Effect( effect_id="str_buff", name="Strength Boost", effect_type=EffectType.BUFF, duration=3, power=5, stat_affected=StatType.STRENGTH, stacks=1, ) ], ) @pytest.fixture def test_quest_item(): """Create a test quest item.""" return Item( item_id="ancient_key", name="Ancient Key", item_type=ItemType.QUEST_ITEM, rarity=ItemRarity.RARE, description="An ornate key to the ancient tomb", value=0, is_tradeable=False, ) @pytest.fixture def high_level_weapon(): """Create a weapon with high level requirement.""" return Item( item_id="legendary_blade", name="Blade of Ages", item_type=ItemType.WEAPON, rarity=ItemRarity.LEGENDARY, description="A blade forged in ancient times", value=5000, damage=50, damage_type=DamageType.PHYSICAL, required_level=20, # Higher than test character's level 5 ) # ============================================================================= # Read Operation Tests # ============================================================================= class TestGetInventory: """Tests for get_inventory() and related read operations.""" def test_get_empty_inventory(self, inventory_service, test_character): """Test getting inventory when it's empty.""" items = inventory_service.get_inventory(test_character) assert items == [] def test_get_inventory_with_items(self, inventory_service, test_character, test_weapon, test_armor): """Test getting inventory with items.""" test_character.inventory = [test_weapon, test_armor] items = inventory_service.get_inventory(test_character) assert len(items) == 2 assert test_weapon in items assert test_armor in items def test_get_inventory_returns_copy(self, inventory_service, test_character, test_weapon): """Test that get_inventory returns a new list (not the original).""" test_character.inventory = [test_weapon] items = inventory_service.get_inventory(test_character) items.append(test_weapon) # Modify returned list # Original inventory should be unchanged assert len(test_character.inventory) == 1 def test_get_item_by_id_found(self, inventory_service, test_character, test_weapon): """Test finding an item by ID.""" test_character.inventory = [test_weapon] item = inventory_service.get_item_by_id(test_character, "iron_sword") assert item is test_weapon def test_get_item_by_id_not_found(self, inventory_service, test_character, test_weapon): """Test item not found returns None.""" test_character.inventory = [test_weapon] item = inventory_service.get_item_by_id(test_character, "nonexistent_item") assert item is None def test_get_equipped_items_empty(self, inventory_service, test_character): """Test getting equipped items when nothing equipped.""" equipped = inventory_service.get_equipped_items(test_character) assert equipped == {} def test_get_equipped_items_with_equipment(self, inventory_service, test_character, test_weapon): """Test getting equipped items.""" test_character.equipped = {"weapon": test_weapon} equipped = inventory_service.get_equipped_items(test_character) assert "weapon" in equipped assert equipped["weapon"] is test_weapon def test_get_equipped_item_specific_slot(self, inventory_service, test_character, test_weapon): """Test getting item from a specific slot.""" test_character.equipped = {"weapon": test_weapon} item = inventory_service.get_equipped_item(test_character, "weapon") assert item is test_weapon def test_get_equipped_item_empty_slot(self, inventory_service, test_character): """Test getting item from empty slot returns None.""" item = inventory_service.get_equipped_item(test_character, "weapon") assert item is None def test_get_inventory_count(self, inventory_service, test_character, test_weapon, test_armor): """Test counting inventory items.""" test_character.inventory = [test_weapon, test_armor] count = inventory_service.get_inventory_count(test_character) assert count == 2 # ============================================================================= # Add/Remove Item Tests # ============================================================================= class TestAddItem: """Tests for add_item().""" def test_add_item_success(self, inventory_service, test_character, test_weapon, mock_character_service): """Test successfully adding an item.""" inventory_service.add_item(test_character, test_weapon, "user_test_456") assert test_weapon in test_character.inventory mock_character_service.update_character.assert_called_once() def test_add_item_without_save(self, inventory_service, test_character, test_weapon, mock_character_service): """Test adding item without persistence.""" inventory_service.add_item(test_character, test_weapon, "user_test_456", save=False) assert test_weapon in test_character.inventory mock_character_service.update_character.assert_not_called() def test_add_multiple_items(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service): """Test adding multiple items.""" inventory_service.add_item(test_character, test_weapon, "user_test_456") inventory_service.add_item(test_character, test_armor, "user_test_456") assert len(test_character.inventory) == 2 class TestRemoveItem: """Tests for remove_item().""" def test_remove_item_success(self, inventory_service, test_character, test_weapon, mock_character_service): """Test successfully removing an item.""" test_character.inventory = [test_weapon] removed = inventory_service.remove_item(test_character, "iron_sword", "user_test_456") assert removed is test_weapon assert test_weapon not in test_character.inventory mock_character_service.update_character.assert_called_once() def test_remove_item_not_found(self, inventory_service, test_character): """Test removing non-existent item raises error.""" with pytest.raises(ItemNotFoundError) as exc: inventory_service.remove_item(test_character, "nonexistent", "user_test_456") assert "nonexistent" in str(exc.value) def test_remove_item_from_multiple(self, inventory_service, test_character, test_weapon, test_armor): """Test removing one item from multiple.""" test_character.inventory = [test_weapon, test_armor] inventory_service.remove_item(test_character, "iron_sword", "user_test_456") assert test_weapon not in test_character.inventory assert test_armor in test_character.inventory def test_drop_item_alias(self, inventory_service, test_character, test_weapon, mock_character_service): """Test drop_item is an alias for remove_item.""" test_character.inventory = [test_weapon] dropped = inventory_service.drop_item(test_character, "iron_sword", "user_test_456") assert dropped is test_weapon assert test_weapon not in test_character.inventory # ============================================================================= # Equipment Tests # ============================================================================= class TestEquipItem: """Tests for equip_item().""" def test_equip_weapon_to_weapon_slot(self, inventory_service, test_character, test_weapon, mock_character_service): """Test equipping a weapon to weapon slot.""" test_character.inventory = [test_weapon] previous = inventory_service.equip_item( test_character, "iron_sword", "weapon", "user_test_456" ) assert previous is None assert test_character.equipped.get("weapon") is test_weapon assert test_weapon not in test_character.inventory mock_character_service.update_character.assert_called_once() def test_equip_armor_to_chest_slot(self, inventory_service, test_character, test_armor, mock_character_service): """Test equipping armor to chest slot.""" test_character.inventory = [test_armor] inventory_service.equip_item(test_character, "leather_chest", "chest", "user_test_456") assert test_character.equipped.get("chest") is test_armor def test_equip_returns_previous_item(self, inventory_service, test_character, test_weapon, mock_character_service): """Test that equipping returns the previously equipped item.""" old_weapon = Item( item_id="old_sword", name="Old Sword", item_type=ItemType.WEAPON, damage=5, ) test_character.inventory = [test_weapon] test_character.equipped = {"weapon": old_weapon} previous = inventory_service.equip_item( test_character, "iron_sword", "weapon", "user_test_456" ) assert previous is old_weapon assert old_weapon in test_character.inventory # Returned to inventory def test_equip_to_invalid_slot_raises_error(self, inventory_service, test_character, test_weapon): """Test equipping to invalid slot raises InvalidSlotError.""" test_character.inventory = [test_weapon] with pytest.raises(InvalidSlotError) as exc: inventory_service.equip_item( test_character, "iron_sword", "invalid_slot", "user_test_456" ) assert "invalid_slot" in str(exc.value) assert "Valid slots" in str(exc.value) def test_equip_weapon_to_armor_slot_raises_error(self, inventory_service, test_character, test_weapon): """Test equipping weapon to armor slot raises CannotEquipError.""" test_character.inventory = [test_weapon] with pytest.raises(CannotEquipError) as exc: inventory_service.equip_item( test_character, "iron_sword", "chest", "user_test_456" ) assert "weapon" in str(exc.value).lower() def test_equip_armor_to_weapon_slot_raises_error(self, inventory_service, test_character, test_armor): """Test equipping armor to weapon slot raises CannotEquipError.""" test_character.inventory = [test_armor] with pytest.raises(CannotEquipError) as exc: inventory_service.equip_item( test_character, "leather_chest", "weapon", "user_test_456" ) assert "armor" in str(exc.value).lower() def test_equip_item_not_in_inventory(self, inventory_service, test_character): """Test equipping item not in inventory raises ItemNotFoundError.""" with pytest.raises(ItemNotFoundError): inventory_service.equip_item( test_character, "nonexistent", "weapon", "user_test_456" ) def test_equip_item_level_requirement_not_met(self, inventory_service, test_character, high_level_weapon): """Test equipping item with unmet level requirement raises error.""" test_character.inventory = [high_level_weapon] test_character.level = 5 # Item requires level 20 with pytest.raises(CannotEquipError) as exc: inventory_service.equip_item( test_character, "legendary_blade", "weapon", "user_test_456" ) assert "level 20" in str(exc.value) assert "level 5" in str(exc.value) def test_equip_consumable_raises_error(self, inventory_service, test_character, test_consumable): """Test equipping consumable raises CannotEquipError.""" test_character.inventory = [test_consumable] with pytest.raises(CannotEquipError) as exc: inventory_service.equip_item( test_character, "health_potion_small", "weapon", "user_test_456" ) assert "consumable" in str(exc.value).lower() def test_equip_quest_item_raises_error(self, inventory_service, test_character, test_quest_item): """Test equipping quest item raises CannotEquipError.""" test_character.inventory = [test_quest_item] with pytest.raises(CannotEquipError) as exc: inventory_service.equip_item( test_character, "ancient_key", "weapon", "user_test_456" ) def test_equip_weapon_to_off_hand(self, inventory_service, test_character, test_weapon, mock_character_service): """Test equipping weapon to off_hand slot.""" test_character.inventory = [test_weapon] inventory_service.equip_item( test_character, "iron_sword", "off_hand", "user_test_456" ) assert test_character.equipped.get("off_hand") is test_weapon class TestUnequipItem: """Tests for unequip_item().""" def test_unequip_item_success(self, inventory_service, test_character, test_weapon, mock_character_service): """Test successfully unequipping an item.""" test_character.equipped = {"weapon": test_weapon} unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456") assert unequipped is test_weapon assert "weapon" not in test_character.equipped assert test_weapon in test_character.inventory mock_character_service.update_character.assert_called_once() def test_unequip_empty_slot_returns_none(self, inventory_service, test_character, mock_character_service): """Test unequipping from empty slot returns None.""" unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456") assert unequipped is None def test_unequip_invalid_slot_raises_error(self, inventory_service, test_character): """Test unequipping from invalid slot raises InvalidSlotError.""" with pytest.raises(InvalidSlotError): inventory_service.unequip_item(test_character, "invalid_slot", "user_test_456") # ============================================================================= # Consumable Tests # ============================================================================= class TestUseConsumable: """Tests for use_consumable().""" def test_use_health_potion(self, inventory_service, test_character, test_consumable, mock_character_service): """Test using a health potion restores HP.""" test_character.inventory = [test_consumable] result = inventory_service.use_consumable( test_character, "health_potion_small", "user_test_456", current_hp=50, max_hp=100 ) assert isinstance(result, ConsumableResult) assert result.hp_restored == 25 # Potion restores 25, capped at missing HP assert result.item_name == "Small Health Potion" assert test_consumable not in test_character.inventory mock_character_service.update_character.assert_called_once() def test_use_health_potion_capped_at_max(self, inventory_service, test_character, test_consumable): """Test HP restoration is capped at max HP.""" test_character.inventory = [test_consumable] result = inventory_service.use_consumable( test_character, "health_potion_small", "user_test_456", current_hp=90, max_hp=100 # Only missing 10 HP ) assert result.hp_restored == 10 # Only restores missing amount def test_use_consumable_at_full_hp(self, inventory_service, test_character, test_consumable): """Test using potion at full HP restores 0.""" test_character.inventory = [test_consumable] result = inventory_service.use_consumable( test_character, "health_potion_small", "user_test_456", current_hp=100, max_hp=100 ) assert result.hp_restored == 0 def test_use_buff_potion(self, inventory_service, test_character, test_buff_potion, mock_character_service): """Test using a buff potion.""" test_character.inventory = [test_buff_potion] result = inventory_service.use_consumable( test_character, "strength_potion", "user_test_456" ) assert result.item_name == "Potion of Strength" assert len(result.effects_applied) == 1 assert result.effects_applied[0]["effect_type"] == "buff" assert result.effects_applied[0]["stat_affected"] == "strength" def test_use_non_consumable_raises_error(self, inventory_service, test_character, test_weapon): """Test using non-consumable item raises CannotUseItemError.""" test_character.inventory = [test_weapon] with pytest.raises(CannotUseItemError) as exc: inventory_service.use_consumable( test_character, "iron_sword", "user_test_456" ) assert "not a consumable" in str(exc.value) def test_use_item_not_in_inventory_raises_error(self, inventory_service, test_character): """Test using item not in inventory raises ItemNotFoundError.""" with pytest.raises(ItemNotFoundError): inventory_service.use_consumable( test_character, "nonexistent", "user_test_456" ) def test_consumable_result_to_dict(self, inventory_service, test_character, test_consumable): """Test ConsumableResult serialization.""" test_character.inventory = [test_consumable] result = inventory_service.use_consumable( test_character, "health_potion_small", "user_test_456", current_hp=50, max_hp=100 ) result_dict = result.to_dict() assert "item_name" in result_dict assert "hp_restored" in result_dict assert "effects_applied" in result_dict assert "message" in result_dict class TestUseConsumableInCombat: """Tests for use_consumable_in_combat().""" def test_combat_consumable_returns_effects(self, inventory_service, test_character, test_buff_potion): """Test combat consumable returns duration effects.""" test_character.inventory = [test_buff_potion] result, effects = inventory_service.use_consumable_in_combat( test_character, "strength_potion", "user_test_456", current_hp=50, max_hp=100 ) assert isinstance(result, ConsumableResult) assert len(effects) == 1 assert effects[0].effect_type == EffectType.BUFF assert effects[0].duration == 3 def test_combat_instant_heal_potion(self, inventory_service, test_character, test_consumable): """Test instant heal in combat.""" test_character.inventory = [test_consumable] result, effects = inventory_service.use_consumable_in_combat( test_character, "health_potion_small", "user_test_456", current_hp=50, max_hp=100 ) # HOT with duration 1 should be returned as duration effect for combat tracking assert len(effects) >= 0 # Implementation may vary # ============================================================================= # Bulk Operation Tests # ============================================================================= class TestBulkOperations: """Tests for bulk inventory operations.""" def test_add_items_bulk(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service): """Test adding multiple items at once.""" items = [test_weapon, test_armor] count = inventory_service.add_items(test_character, items, "user_test_456") assert count == 2 assert len(test_character.inventory) == 2 mock_character_service.update_character.assert_called_once() def test_get_items_by_type(self, inventory_service, test_character, test_weapon, test_armor, test_consumable): """Test filtering items by type.""" test_character.inventory = [test_weapon, test_armor, test_consumable] weapons = inventory_service.get_items_by_type(test_character, ItemType.WEAPON) armor = inventory_service.get_items_by_type(test_character, ItemType.ARMOR) consumables = inventory_service.get_items_by_type(test_character, ItemType.CONSUMABLE) assert len(weapons) == 1 assert test_weapon in weapons assert len(armor) == 1 assert test_armor in armor assert len(consumables) == 1 def test_get_equippable_items(self, inventory_service, test_character, test_weapon, test_armor, test_consumable): """Test getting only equippable items.""" test_character.inventory = [test_weapon, test_armor, test_consumable] equippable = inventory_service.get_equippable_items(test_character) assert test_weapon in equippable assert test_armor in equippable assert test_consumable not in equippable def test_get_equippable_items_for_slot(self, inventory_service, test_character, test_weapon, test_armor): """Test getting equippable items for a specific slot.""" test_character.inventory = [test_weapon, test_armor] for_weapon = inventory_service.get_equippable_items(test_character, slot="weapon") for_chest = inventory_service.get_equippable_items(test_character, slot="chest") assert test_weapon in for_weapon assert test_armor not in for_weapon assert test_armor in for_chest assert test_weapon not in for_chest def test_get_equippable_items_excludes_high_level(self, inventory_service, test_character, test_weapon, high_level_weapon): """Test that items above character level are excluded.""" test_character.inventory = [test_weapon, high_level_weapon] test_character.level = 5 equippable = inventory_service.get_equippable_items(test_character) assert test_weapon in equippable assert high_level_weapon not in equippable # ============================================================================= # Edge Cases and Error Handling # ============================================================================= class TestEdgeCases: """Tests for edge cases and error handling.""" def test_valid_slots_constant(self): """Test VALID_SLOTS contains expected slots.""" expected = {"weapon", "off_hand", "helmet", "chest", "gloves", "boots", "accessory_1", "accessory_2"} assert VALID_SLOTS == expected def test_item_type_slots_mapping(self): """Test ITEM_TYPE_SLOTS mapping is correct.""" assert ItemType.WEAPON in ITEM_TYPE_SLOTS assert "weapon" in ITEM_TYPE_SLOTS[ItemType.WEAPON] assert "off_hand" in ITEM_TYPE_SLOTS[ItemType.WEAPON] assert ItemType.ARMOR in ITEM_TYPE_SLOTS assert "chest" in ITEM_TYPE_SLOTS[ItemType.ARMOR] assert "helmet" in ITEM_TYPE_SLOTS[ItemType.ARMOR] def test_generated_item_with_unique_id(self, inventory_service, test_character, mock_character_service): """Test handling of generated items with unique IDs.""" generated_item = Item( item_id="gen_abc123", # Generated item ID format name="Dagger", item_type=ItemType.WEAPON, rarity=ItemRarity.RARE, damage=15, is_generated=True, generated_name="Flaming Dagger of Strength", base_template_id="dagger", applied_affixes=["flaming", "of_strength"], ) inventory_service.add_item(test_character, generated_item, "user_test_456") assert generated_item in test_character.inventory assert generated_item.get_display_name() == "Flaming Dagger of Strength" def test_equip_generated_item(self, inventory_service, test_character, mock_character_service): """Test equipping a generated item.""" generated_item = Item( item_id="gen_xyz789", name="Sword", item_type=ItemType.WEAPON, rarity=ItemRarity.EPIC, damage=25, is_generated=True, generated_name="Blazing Sword of Power", required_level=1, ) test_character.inventory = [generated_item] inventory_service.equip_item(test_character, "gen_xyz789", "weapon", "user_test_456") assert test_character.equipped.get("weapon") is generated_item # ============================================================================= # Global Instance Tests # ============================================================================= class TestGlobalInstance: """Tests for the global singleton pattern.""" def test_get_inventory_service_returns_instance(self): """Test get_inventory_service returns InventoryService.""" with patch('app.services.inventory_service._service_instance', None): with patch('app.services.inventory_service.get_character_service'): service = get_inventory_service() assert isinstance(service, InventoryService) def test_get_inventory_service_returns_same_instance(self): """Test get_inventory_service returns singleton.""" with patch('app.services.inventory_service._service_instance', None): with patch('app.services.inventory_service.get_character_service'): service1 = get_inventory_service() service2 = get_inventory_service() assert service1 is service2