first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View 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