""" Integration tests for Character API endpoints. These tests verify the complete character management API flow including: - List characters - Get character details - Create character (with tier limits) - Delete character - Unlock skills - Respec skills - Get classes and origins (reference data) Tests use Flask test client with mocked authentication and database layers. """ import pytest import json from unittest.mock import Mock, patch, MagicMock from datetime import datetime 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.character_service import ( CharacterLimitExceeded, CharacterNotFound, SkillUnlockError, InsufficientGold ) from app.services.database_service import DatabaseDocument class TestCharacterAPIIntegration: """Integration tests for Character API endpoints.""" @pytest.fixture def app(self): """Create Flask application for testing.""" # Import here to avoid circular imports from app import create_app app = create_app() app.config['TESTING'] = True return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() @pytest.fixture(autouse=True) def mock_auth_decorator(self): """Mock the require_auth decorator to bypass authentication.""" def pass_through_decorator(func): """Decorator that does nothing - passes through to the function.""" return func with patch('app.api.characters.require_auth', side_effect=pass_through_decorator): yield @pytest.fixture def mock_user(self): """Create a mock authenticated user.""" user = Mock() user.id = "test_user_123" user.email = "test@example.com" user.name = "Test User" user.tier = "free" user.email_verified = True return user @pytest.fixture def sample_class(self): """Create a sample player class.""" base_stats = Stats(strength=14, dexterity=10, constitution=14, intelligence=8, wisdom=10, charisma=9) skill_nodes = [ SkillNode( skill_id="shield_wall", name="Shield Wall", description="Increase armor by 5", tier=1, prerequisites=[], effects={"armor": 5} ), SkillNode( skill_id="toughness", name="Toughness", description="Increase HP by 10", tier=2, prerequisites=["shield_wall"], effects={"hit_points": 10} ) ] skill_tree = SkillTree( tree_id="shield_bearer", name="Shield Bearer", description="Defensive techniques", nodes=skill_nodes ) return PlayerClass( class_id="vanguard", name="Vanguard", description="Armored warrior", base_stats=base_stats, skill_trees=[skill_tree], starting_equipment=["Rusty Sword", "Tattered Cloth Armor"], starting_abilities=[] ) @pytest.fixture def sample_origin(self): """Create a sample origin.""" starting_location = StartingLocation( id="forgotten_crypt", name="The Forgotten Crypt", region="The Deadlands", description="A crumbling stone tomb beneath a dead forest" ) starting_bonus = StartingBonus( type="stat", value={"constitution": 1} ) return Origin( id="soul_revenant", name="The Soul Revenant", description="You died. That much you remember...", starting_location=starting_location, narrative_hooks=["Who brought you back?"], starting_bonus=starting_bonus ) @pytest.fixture def sample_character(self, sample_class, sample_origin): """Create a sample character.""" return Character( character_id="char_123", user_id="test_user_123", name="Thorin Ironforge", player_class=sample_class, origin=sample_origin, level=5, experience=2400, base_stats=Stats(strength=16, dexterity=10, constitution=14, intelligence=8, wisdom=12, charisma=10), unlocked_skills=["shield_wall"], inventory=[], equipped={}, gold=500, active_quests=[], discovered_locations=["forgotten_crypt"], current_location="forgotten_crypt" ) # ===== LIST CHARACTERS ===== @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_list_characters_success(self, mock_get_service, mock_get_user, client, mock_user, sample_character): """Test listing user's characters.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.get_user_characters.return_value = [sample_character] mock_get_service.return_value = mock_service # Execute response = client.get('/api/v1/characters') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert len(data['result']['characters']) == 1 assert data['result']['characters'][0]['name'] == "Thorin Ironforge" assert data['result']['characters'][0]['class'] == "vanguard" assert data['result']['count'] == 1 assert data['result']['tier'] == "free" assert data['result']['limit'] == 1 @patch('app.api.characters.get_current_user') def test_list_characters_unauthorized(self, mock_get_user, client): """Test listing characters without authentication.""" # Setup - simulate no user logged in mock_get_user.return_value = None # Execute response = client.get('/api/v1/characters') # Verify assert response.status_code == 401 # ===== GET CHARACTER ===== @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_get_character_success(self, mock_get_service, mock_get_user, client, mock_user, sample_character): """Test getting a single character.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.get_character.return_value = sample_character mock_get_service.return_value = mock_service # Execute response = client.get('/api/v1/characters/char_123') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert data['result']['name'] == "Thorin Ironforge" assert data['result']['level'] == 5 assert data['result']['gold'] == 500 @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_get_character_not_found(self, mock_get_service, mock_get_user, client, mock_user): """Test getting a non-existent character.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.get_character.side_effect = CharacterNotFound("Character not found") mock_get_service.return_value = mock_service # Execute response = client.get('/api/v1/characters/char_999') # Verify assert response.status_code == 404 data = json.loads(response.data) assert 'error' in data # ===== CREATE CHARACTER ===== @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_create_character_success(self, mock_get_service, mock_get_user, client, mock_user, sample_character): """Test creating a new character.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.create_character.return_value = sample_character mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters', json={ 'name': 'Thorin Ironforge', 'class_id': 'vanguard', 'origin_id': 'soul_revenant' }) # Verify assert response.status_code == 201 data = json.loads(response.data) assert data['status'] == 201 assert data['result']['name'] == "Thorin Ironforge" assert data['result']['class'] == "vanguard" assert data['result']['origin'] == "soul_revenant" @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_create_character_limit_exceeded(self, mock_get_service, mock_get_user, client, mock_user): """Test creating a character when tier limit is reached.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.create_character.side_effect = CharacterLimitExceeded( "Character limit reached for free tier (1/1)" ) mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters', json={ 'name': 'Thorin Ironforge', 'class_id': 'vanguard', 'origin_id': 'soul_revenant' }) # Verify assert response.status_code == 400 data = json.loads(response.data) assert 'error' in data assert data['error']['code'] == 'CHARACTER_LIMIT_EXCEEDED' def test_create_character_validation_errors(self, client, mock_user): """Test character creation with invalid input.""" with patch('app.api.characters.get_current_user', return_value=mock_user): # Test missing name response = client.post('/api/v1/characters', json={ 'class_id': 'vanguard', 'origin_id': 'soul_revenant' }) assert response.status_code == 400 # Test invalid class_id response = client.post('/api/v1/characters', json={ 'name': 'Test', 'class_id': 'invalid_class', 'origin_id': 'soul_revenant' }) assert response.status_code == 400 # Test invalid origin_id response = client.post('/api/v1/characters', json={ 'name': 'Test', 'class_id': 'vanguard', 'origin_id': 'invalid_origin' }) assert response.status_code == 400 # Test name too short response = client.post('/api/v1/characters', json={ 'name': 'T', 'class_id': 'vanguard', 'origin_id': 'soul_revenant' }) assert response.status_code == 400 # ===== DELETE CHARACTER ===== @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_delete_character_success(self, mock_get_service, mock_get_user, client, mock_user): """Test deleting a character.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.delete_character.return_value = True mock_get_service.return_value = mock_service # Execute response = client.delete('/api/v1/characters/char_123') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert 'deleted successfully' in data['result']['message'] @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_delete_character_not_found(self, mock_get_service, mock_get_user, client, mock_user): """Test deleting a non-existent character.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.delete_character.side_effect = CharacterNotFound("Character not found") mock_get_service.return_value = mock_service # Execute response = client.delete('/api/v1/characters/char_999') # Verify assert response.status_code == 404 # ===== UNLOCK SKILL ===== @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_unlock_skill_success(self, mock_get_service, mock_get_user, client, mock_user, sample_character): """Test unlocking a skill.""" # Setup mock_get_user.return_value = mock_user sample_character.unlocked_skills = ["shield_wall", "toughness"] mock_service = Mock() mock_service.unlock_skill.return_value = sample_character mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters/char_123/skills/unlock', json={'skill_id': 'toughness'}) # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert data['result']['skill_id'] == 'toughness' assert 'toughness' in data['result']['unlocked_skills'] @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_unlock_skill_prerequisites_not_met(self, mock_get_service, mock_get_user, client, mock_user): """Test unlocking a skill without meeting prerequisites.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.unlock_skill.side_effect = SkillUnlockError( "Prerequisite not met: shield_wall required for toughness" ) mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters/char_123/skills/unlock', json={'skill_id': 'toughness'}) # Verify assert response.status_code == 400 data = json.loads(response.data) assert 'error' in data assert data['error']['code'] == 'SKILL_UNLOCK_ERROR' @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_unlock_skill_no_points(self, mock_get_service, mock_get_user, client, mock_user): """Test unlocking a skill without available skill points.""" # Setup mock_get_user.return_value = mock_user mock_service = Mock() mock_service.unlock_skill.side_effect = SkillUnlockError( "No skill points available (Level 1, 1 skills unlocked)" ) mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters/char_123/skills/unlock', json={'skill_id': 'shield_wall'}) # Verify assert response.status_code == 400 data = json.loads(response.data) assert 'error' in data # ===== RESPEC SKILLS ===== @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_respec_skills_success(self, mock_get_service, mock_get_user, client, mock_user, sample_character): """Test respeccing character skills.""" # Setup mock_get_user.return_value = mock_user # Get character returns current state char_before = sample_character char_before.unlocked_skills = ["shield_wall"] char_before.gold = 500 # After respec, skills cleared and gold reduced char_after = sample_character char_after.unlocked_skills = [] char_after.gold = 0 # 500 - (5 * 100) mock_service = Mock() mock_service.get_character.return_value = char_before mock_service.respec_skills.return_value = char_after mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters/char_123/skills/respec') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert data['result']['cost'] == 500 # level 5 * 100 assert data['result']['remaining_gold'] == 0 assert data['result']['available_points'] == 5 @patch('app.api.characters.get_current_user') @patch('app.api.characters.get_character_service') def test_respec_skills_insufficient_gold(self, mock_get_service, mock_get_user, client, mock_user, sample_character): """Test respeccing without enough gold.""" # Setup mock_get_user.return_value = mock_user sample_character.gold = 100 # Not enough for level 5 respec (needs 500) mock_service = Mock() mock_service.get_character.return_value = sample_character mock_service.respec_skills.side_effect = InsufficientGold( "Insufficient gold for respec. Cost: 500, Available: 100" ) mock_get_service.return_value = mock_service # Execute response = client.post('/api/v1/characters/char_123/skills/respec') # Verify assert response.status_code == 400 data = json.loads(response.data) assert 'error' in data assert data['error']['code'] == 'INSUFFICIENT_GOLD' # ===== CLASSES ENDPOINTS (REFERENCE DATA) ===== @patch('app.api.characters.get_class_loader') def test_list_classes(self, mock_get_loader, client, sample_class): """Test listing all character classes.""" # Setup mock_loader = Mock() mock_loader.get_all_class_ids.return_value = ['vanguard', 'arcanist'] mock_loader.load_class.return_value = sample_class mock_get_loader.return_value = mock_loader # Execute response = client.get('/api/v1/classes') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert len(data['result']['classes']) == 2 assert data['result']['count'] == 2 @patch('app.api.characters.get_class_loader') def test_get_class_details(self, mock_get_loader, client, sample_class): """Test getting details of a specific class.""" # Setup mock_loader = Mock() mock_loader.load_class.return_value = sample_class mock_get_loader.return_value = mock_loader # Execute response = client.get('/api/v1/classes/vanguard') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert data['result']['class_id'] == 'vanguard' assert data['result']['name'] == 'Vanguard' assert len(data['result']['skill_trees']) == 1 @patch('app.api.characters.get_class_loader') def test_get_class_not_found(self, mock_get_loader, client): """Test getting a non-existent class.""" # Setup mock_loader = Mock() mock_loader.load_class.return_value = None mock_get_loader.return_value = mock_loader # Execute response = client.get('/api/v1/classes/invalid_class') # Verify assert response.status_code == 404 # ===== ORIGINS ENDPOINTS (REFERENCE DATA) ===== @patch('app.api.characters.get_origin_service') def test_list_origins(self, mock_get_service, client, sample_origin): """Test listing all character origins.""" # Setup mock_service = Mock() mock_service.get_all_origin_ids.return_value = ['soul_revenant', 'memory_thief'] mock_service.load_origin.return_value = sample_origin mock_get_service.return_value = mock_service # Execute response = client.get('/api/v1/origins') # Verify assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 200 assert len(data['result']['origins']) == 2 assert data['result']['count'] == 2