548 lines
20 KiB
Python
548 lines
20 KiB
Python
"""
|
|
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')
|