""" Unit tests for CharacterService - character CRUD operations and tier limits. These tests verify character creation, retrieval, deletion, skill unlock, and respec functionality with proper validation and error handling. """ import pytest import json from unittest.mock import Mock, MagicMock, patch from datetime import datetime, timezone from app.services.character_service import ( CharacterService, CharacterLimitExceeded, CharacterNotFound, SkillUnlockError, InsufficientGold, CHARACTER_LIMITS ) from app.models.character import Character from app.models.stats import Stats from app.models.skills import PlayerClass, SkillTree, SkillNode from app.models.origins import Origin, StartingLocation, StartingBonus from app.services.database_service import DatabaseDocument class TestCharacterService: """Test suite for CharacterService.""" @pytest.fixture def mock_db(self): """Mock database service.""" return Mock() @pytest.fixture def mock_appwrite(self): """Mock Appwrite service.""" return Mock() @pytest.fixture def mock_class_loader(self): """Mock class loader.""" return Mock() @pytest.fixture def mock_origin_service(self): """Mock origin service.""" return Mock() @pytest.fixture def character_service(self, mock_db, mock_appwrite, mock_class_loader, mock_origin_service): """Create CharacterService with mocked dependencies.""" # Patch the singleton getters before instantiation with patch('app.services.character_service.get_database_service', return_value=mock_db), \ patch('app.services.character_service.AppwriteService', return_value=mock_appwrite), \ patch('app.services.character_service.get_class_loader', return_value=mock_class_loader), \ patch('app.services.character_service.get_origin_service', return_value=mock_origin_service): service = CharacterService() # Ensure mocks are still assigned service.db = mock_db service.appwrite = mock_appwrite service.class_loader = mock_class_loader service.origin_service = mock_origin_service return service @pytest.fixture def sample_class(self): """Create a sample player class.""" base_stats = Stats(strength=12, dexterity=10, constitution=14) skill_tree = SkillTree( tree_id="warrior_offense", name="Warrior Offense", description="Offensive skills", nodes=[ SkillNode( skill_id="power_strike", name="Power Strike", description="+5 Strength", tier=1, effects={"strength": 5}, ), SkillNode( skill_id="heavy_blow", name="Heavy Blow", description="+10 Strength", tier=2, prerequisites=["power_strike"], effects={"strength": 10}, ), ], ) return PlayerClass( class_id="warrior", name="Warrior", description="Strong fighter", base_stats=base_stats, skill_trees=[skill_tree], starting_equipment=["basic_sword"], starting_abilities=["basic_attack"], ) @pytest.fixture def sample_origin(self): """Create a sample origin.""" starting_location = StartingLocation( id="test_crypt", name="Test Crypt", region="Test Region", description="A test location" ) starting_bonus = StartingBonus( trait="Test Trait", description="Test bonus", effect="+1 to all stats" ) return Origin( id="test_origin", name="Test Origin", description="A test origin", starting_location=starting_location, narrative_hooks=["Test hook"], starting_bonus=starting_bonus ) def test_create_character_success( self, character_service, mock_appwrite, mock_class_loader, mock_origin_service, mock_db, sample_class, sample_origin ): """Test successful character creation.""" # Setup mocks mock_appwrite.get_user_tier.return_value = 'free' mock_class_loader.load_class.return_value = sample_class mock_origin_service.load_origin.return_value = sample_origin # Mock count_user_characters to return 0 character_service.count_user_characters = Mock(return_value=0) # Create character with patch('app.services.character_service.ID') as mock_id: mock_id.unique.return_value = 'char_123' character = character_service.create_character( user_id='user_001', name='Test Hero', class_id='warrior', origin_id='test_origin' ) # Assertions assert character.character_id == 'char_123' assert character.user_id == 'user_001' assert character.name == 'Test Hero' assert character.level == 1 assert character.experience == 0 assert character.gold == 0 assert character.current_location == 'test_crypt' # Verify database was called mock_db.create_document.assert_called_once() def test_create_character_exceeds_limit( self, character_service, mock_appwrite, mock_class_loader, mock_origin_service ): """Test character creation fails when tier limit exceeded.""" # Setup: user on free tier (limit 1) with 1 existing character mock_appwrite.get_user_tier.return_value = 'free' character_service.count_user_characters = Mock(return_value=1) # Attempt to create second character with pytest.raises(CharacterLimitExceeded) as exc_info: character_service.create_character( user_id='user_001', name='Second Hero', class_id='warrior', origin_id='test_origin' ) assert 'free tier' in str(exc_info.value) assert '1/1' in str(exc_info.value) def test_create_character_tier_limits(self): """Test that tier limits are correctly defined.""" assert CHARACTER_LIMITS['free'] == 1 assert CHARACTER_LIMITS['basic'] == 3 assert CHARACTER_LIMITS['premium'] == 5 assert CHARACTER_LIMITS['elite'] == 10 def test_create_character_invalid_class( self, character_service, mock_appwrite, mock_class_loader, mock_origin_service ): """Test character creation fails with invalid class.""" mock_appwrite.get_user_tier.return_value = 'free' character_service.count_user_characters = Mock(return_value=0) mock_class_loader.load_class.return_value = None with pytest.raises(ValueError) as exc_info: character_service.create_character( user_id='user_001', name='Test Hero', class_id='invalid_class', origin_id='test_origin' ) assert 'Class not found' in str(exc_info.value) def test_create_character_invalid_origin( self, character_service, mock_appwrite, mock_class_loader, mock_origin_service, sample_class ): """Test character creation fails with invalid origin.""" mock_appwrite.get_user_tier.return_value = 'free' character_service.count_user_characters = Mock(return_value=0) mock_class_loader.load_class.return_value = sample_class mock_origin_service.load_origin.return_value = None with pytest.raises(ValueError) as exc_info: character_service.create_character( user_id='user_001', name='Test Hero', class_id='warrior', origin_id='invalid_origin' ) assert 'Origin not found' in str(exc_info.value) def test_get_character_success(self, character_service, mock_db, sample_class, sample_origin): """Test successfully retrieving a character.""" # Create test character data character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=5, gold=500 ) # Mock database response mock_doc = Mock(spec=DatabaseDocument) mock_doc.id = 'char_123' mock_doc.data = { 'userId': 'user_001', 'characterData': json.dumps(character.to_dict()), 'is_active': True } mock_db.get_document.return_value = mock_doc # Get character result = character_service.get_character('char_123', 'user_001') # Assertions assert result.character_id == 'char_123' assert result.name == 'Test Hero' assert result.level == 5 assert result.gold == 500 def test_get_character_not_found(self, character_service, mock_db): """Test getting non-existent character raises error.""" mock_db.get_document.return_value = None with pytest.raises(CharacterNotFound): character_service.get_character('nonexistent', 'user_001') def test_get_character_wrong_owner(self, character_service, mock_db): """Test getting character owned by different user raises error.""" mock_doc = Mock(spec=DatabaseDocument) mock_doc.data = {'userId': 'user_002'} # Different user mock_db.get_document.return_value = mock_doc with pytest.raises(CharacterNotFound): character_service.get_character('char_123', 'user_001') def test_get_user_characters(self, character_service, mock_db, sample_class, sample_origin): """Test getting all characters for a user.""" # Create test character data char1_data = Character( character_id='char_1', user_id='user_001', name='Hero 1', player_class=sample_class, origin=sample_origin ).to_dict() char2_data = Character( character_id='char_2', user_id='user_001', name='Hero 2', player_class=sample_class, origin=sample_origin ).to_dict() # Mock database response mock_doc1 = Mock(spec=DatabaseDocument) mock_doc1.id = 'char_1' mock_doc1.data = {'characterData': json.dumps(char1_data)} mock_doc2 = Mock(spec=DatabaseDocument) mock_doc2.id = 'char_2' mock_doc2.data = {'characterData': json.dumps(char2_data)} mock_db.list_rows.return_value = [mock_doc1, mock_doc2] # Get characters characters = character_service.get_user_characters('user_001') # Assertions assert len(characters) == 2 assert characters[0].name == 'Hero 1' assert characters[1].name == 'Hero 2' def test_count_user_characters(self, character_service, mock_db): """Test counting user's characters.""" mock_db.count_documents.return_value = 3 count = character_service.count_user_characters('user_001') assert count == 3 mock_db.count_documents.assert_called_once() def test_delete_character_success(self, character_service, mock_db): """Test successfully deleting a character.""" # Mock get_character to return a valid character character_service.get_character = Mock(return_value=Mock(character_id='char_123')) # Delete character result = character_service.delete_character('char_123', 'user_001') # Assertions assert result is True mock_db.update_document.assert_called_once() # Verify it's a soft delete (is_active set to False) call_args = mock_db.update_document.call_args assert call_args[1]['data']['is_active'] is False def test_delete_character_not_found(self, character_service): """Test deleting non-existent character raises error.""" character_service.get_character = Mock(side_effect=CharacterNotFound("Not found")) with pytest.raises(CharacterNotFound): character_service.delete_character('nonexistent', 'user_001') def test_unlock_skill_success(self, character_service, sample_class, sample_origin): """Test successfully unlocking a skill.""" # Create character with level 2 (1 skill point available) character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=2, unlocked_skills=[] ) # Mock get_character character_service.get_character = Mock(return_value=character) # Mock _save_character character_service._save_character = Mock() # Unlock skill result = character_service.unlock_skill('char_123', 'user_001', 'power_strike') # Assertions assert 'power_strike' in result.unlocked_skills character_service._save_character.assert_called_once() def test_unlock_skill_already_unlocked(self, character_service, sample_class, sample_origin): """Test unlocking already unlocked skill raises error.""" character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=2, unlocked_skills=['power_strike'] # Already unlocked ) character_service.get_character = Mock(return_value=character) with pytest.raises(SkillUnlockError) as exc_info: character_service.unlock_skill('char_123', 'user_001', 'power_strike') assert 'already unlocked' in str(exc_info.value) def test_unlock_skill_not_in_class(self, character_service, sample_class, sample_origin): """Test unlocking skill not in class raises error.""" character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=2, unlocked_skills=[] ) character_service.get_character = Mock(return_value=character) with pytest.raises(SkillUnlockError) as exc_info: character_service.unlock_skill('char_123', 'user_001', 'invalid_skill') assert 'not found in class' in str(exc_info.value) def test_unlock_skill_missing_prerequisite(self, character_service, sample_class, sample_origin): """Test unlocking skill without prerequisite raises error.""" character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=2, unlocked_skills=[] # Missing 'power_strike' prerequisite ) character_service.get_character = Mock(return_value=character) with pytest.raises(SkillUnlockError) as exc_info: character_service.unlock_skill('char_123', 'user_001', 'heavy_blow') assert 'Prerequisite not met' in str(exc_info.value) assert 'power_strike' in str(exc_info.value) def test_unlock_skill_no_points_available(self, character_service, sample_class, sample_origin): """Test unlocking skill without available points raises error.""" # Add a tier 3 skill to test with tier3_skill = SkillNode( skill_id="master_strike", name="Master Strike", description="+15 Strength", tier=3, effects={"strength": 15}, ) sample_class.skill_trees[0].nodes.append(tier3_skill) # Level 1 character with 1 skill already unlocked = 0 points remaining character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=1, unlocked_skills=['power_strike'] # Used the 1 point from level 1 ) character_service.get_character = Mock(return_value=character) # Try to unlock another skill (master_strike exists in class) with pytest.raises(SkillUnlockError) as exc_info: character_service.unlock_skill('char_123', 'user_001', 'master_strike') assert 'No skill points available' in str(exc_info.value) def test_respec_skills_success(self, character_service, sample_class, sample_origin): """Test successfully respecing character skills.""" # Level 5 character with 3 skills and 500 gold # Respec cost = 5 * 100 = 500 gold character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=5, gold=500, unlocked_skills=['skill1', 'skill2', 'skill3'] ) character_service.get_character = Mock(return_value=character) character_service._save_character = Mock() # Respec skills result = character_service.respec_skills('char_123', 'user_001') # Assertions assert len(result.unlocked_skills) == 0 # Skills cleared assert result.gold == 0 # 500 - 500 = 0 character_service._save_character.assert_called_once() def test_respec_skills_insufficient_gold(self, character_service, sample_class, sample_origin): """Test respec fails with insufficient gold.""" # Level 5 character with only 100 gold (needs 500) character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, level=5, gold=100, # Not enough unlocked_skills=['skill1'] ) character_service.get_character = Mock(return_value=character) with pytest.raises(InsufficientGold) as exc_info: character_service.respec_skills('char_123', 'user_001') assert '500' in str(exc_info.value) # Cost assert '100' in str(exc_info.value) # Available def test_update_character_success(self, character_service, sample_class, sample_origin): """Test successfully updating a character.""" character = Character( character_id='char_123', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin, gold=1000 # Updated gold ) # Mock get_character to verify ownership character_service.get_character = Mock(return_value=character) character_service._save_character = Mock() # Update character result = character_service.update_character(character, 'user_001') # Assertions assert result.gold == 1000 character_service._save_character.assert_called_once() def test_update_character_not_found(self, character_service, sample_class, sample_origin): """Test updating non-existent character raises error.""" character = Character( character_id='nonexistent', user_id='user_001', name='Test Hero', player_class=sample_class, origin=sample_origin ) character_service.get_character = Mock(side_effect=CharacterNotFound("Not found")) with pytest.raises(CharacterNotFound): character_service.update_character(character, 'user_001')