first commit
This commit is contained in:
579
api/tests/test_api_characters_integration.py
Normal file
579
api/tests/test_api_characters_integration.py
Normal file
@@ -0,0 +1,579 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user