Files
Code_of_Conquest/api/tests/test_character_service.py
2025-11-24 23:10:55 -06:00

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')