Skip to main content
Glama
by frap129
test_creature.py25.6 kB
"""Tests for CreatureRepository implementation.""" from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client from lorekeeper_mcp.models import Creature from lorekeeper_mcp.repositories.creature import CreatureRepository @pytest.fixture def mock_cache() -> MagicMock: """Create mock cache for testing.""" cache = MagicMock() cache.get_entities = AsyncMock() cache.store_entities = AsyncMock() return cache @pytest.fixture def mock_client() -> MagicMock: """Create mock API client for testing.""" client = MagicMock() client.get_creatures = AsyncMock() return client @pytest.fixture def monster_data() -> list[dict[str, Any]]: """Provide sample creature data for testing.""" return [ { "name": "Goblin", "slug": "goblin", "size": "Small", "type": "humanoid", "alignment": "Neutral Evil", "armor_class": 15, "hit_points": 7, "hit_dice": "2d6", "challenge_rating": "1/4", }, { "name": "Orc", "slug": "orc", "size": "Medium", "type": "humanoid", "alignment": "Chaotic Evil", "armor_class": 13, "hit_points": 15, "hit_dice": "2d8 + 6", "challenge_rating": "1/2", }, { "name": "Dragon", "slug": "dragon", "size": "Huge", "type": "dragon", "alignment": "Chaotic Evil", "armor_class": 20, "hit_points": 195, "hit_dice": "17d12 + 85", "challenge_rating": "16", }, ] @pytest.fixture def creatures(monster_data: list[dict[str, Any]]) -> list[Creature]: """Convert monster data to Creature models.""" return [Creature.model_validate(data) for data in monster_data] @pytest.mark.asyncio async def test_monster_repository_get_all_from_cache( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test that get_all returns cached monsters when available.""" # Cache hit - monsters already cached mock_cache.get_entities.return_value = monster_data mock_client.get_creatures.return_value = [] repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.get_all() assert len(results) == 3 assert results[0].name == "Goblin" # API should not be called since cache hit mock_client.get_creatures.assert_not_called() # Cache should be queried with "creatures" table mock_cache.get_entities.assert_called_once_with("creatures") @pytest.mark.asyncio async def test_monster_repository_get_all_cache_miss( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test that get_all fetches from API on cache miss and stores in cache.""" # Cache miss - empty cache mock_cache.get_entities.return_value = [] # Create Creature objects from monster_data creatures = [Creature.model_validate(data) for data in monster_data] mock_client.get_creatures.return_value = creatures mock_cache.store_entities.return_value = 3 repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.get_all() assert len(results) == 3 # API should be called since cache miss mock_client.get_creatures.assert_called_once() # Results should be stored in cache with "creatures" table mock_cache.store_entities.assert_called_once() call_args = mock_cache.store_entities.call_args assert call_args[0][1] == "creatures" @pytest.mark.asyncio async def test_monster_repository_search_by_type( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test search filtering by monster type.""" # Cache hit with type filter humanoid_monsters = [monster_data[0], monster_data[1]] # Goblin and Orc mock_cache.get_entities.return_value = humanoid_monsters mock_client.get_creatures.return_value = [] repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search(type="humanoid") assert len(results) == 2 assert all(monster.type == "humanoid" for monster in results) mock_cache.get_entities.assert_called_once_with("creatures", type="humanoid") @pytest.mark.asyncio async def test_monster_repository_search_by_size( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test search filtering by monster size.""" # Cache hit with size filter huge_monsters = [monster_data[2]] # Dragon mock_cache.get_entities.return_value = huge_monsters repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search(size="Huge") assert len(results) == 1 assert results[0].name == "Dragon" assert results[0].size == "Huge" mock_cache.get_entities.assert_called_once_with("creatures", size="Huge") @pytest.mark.asyncio async def test_monster_repository_search_cache_miss_with_filters( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test search with cache miss fetches from API.""" # Cache miss mock_cache.get_entities.return_value = [] # API returns filtered results humanoid_creatures = [Creature.model_validate(monster_data[0])] mock_client.get_creatures.return_value = humanoid_creatures mock_cache.store_entities.return_value = 1 repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search(type="humanoid") assert len(results) == 1 assert results[0].type == "humanoid" mock_client.get_creatures.assert_called_once_with(limit=None, type="humanoid") mock_cache.store_entities.assert_called_once() # Verify store_entities uses "creatures" table call_args = mock_cache.store_entities.call_args assert call_args[0][1] == "creatures" @pytest.mark.asyncio async def test_monster_repository_search_by_challenge_rating( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test search filtering by challenge rating.""" # Cache miss mock_cache.get_entities.return_value = [] # Filter by CR high_cr_creatures = [Creature.model_validate(monster_data[2])] mock_client.get_creatures.return_value = high_cr_creatures mock_cache.store_entities.return_value = 1 repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search(challenge_rating="16") assert len(results) == 1 assert results[0].challenge_rating == "16" mock_client.get_creatures.assert_called_once_with(limit=None, challenge_rating="16") @pytest.mark.asyncio async def test_monster_repository_search_multiple_filters( mock_cache: MagicMock, mock_client: MagicMock ) -> None: """Test search with multiple filters.""" mock_cache.get_entities.return_value = [] mock_client.get_creatures.return_value = [] repo = CreatureRepository(client=mock_client, cache=mock_cache) await repo.search(type="humanoid", size="Medium") # Client should be called with all filters mock_client.get_creatures.assert_called_once_with(limit=None, type="humanoid", size="Medium") mock_cache.get_entities.assert_called_once_with("creatures", type="humanoid", size="Medium") @pytest.mark.asyncio async def test_monster_repository_search_no_filters_returns_all( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test search with no filters returns all monsters.""" mock_cache.get_entities.return_value = monster_data repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search() assert len(results) == 3 mock_cache.get_entities.assert_called_once_with("creatures") @pytest.mark.asyncio async def test_monster_repository_search_empty_result( mock_cache: MagicMock, mock_client: MagicMock ) -> None: """Test search that returns no results.""" mock_cache.get_entities.return_value = [] mock_client.get_creatures.return_value = [] repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search(type="nonexistent") assert results == [] mock_client.get_creatures.assert_called_once_with(limit=None, type="nonexistent") @pytest.mark.asyncio async def test_monster_repository_cache_aside_pattern( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test that cache-aside pattern is correctly implemented. Pattern should be: 1. Try to get from cache 2. On cache miss, fetch from API 3. Store fetched results in cache 4. Return results """ # First call returns empty (cache miss) mock_cache.get_entities.return_value = [] creatures = [Creature.model_validate(data) for data in monster_data] mock_client.get_creatures.return_value = creatures mock_cache.store_entities.return_value = len(monster_data) repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.get_all() # Verify cache-aside pattern: # 1. Check cache first with "creatures" table mock_cache.get_entities.assert_called_once_with("creatures") # 2. On miss, fetch from API using get_creatures mock_client.get_creatures.assert_called_once_with() # 3. Store in cache with "creatures" table mock_cache.store_entities.assert_called_once() # 4. Return results assert len(results) == 3 @pytest.mark.asyncio async def test_monster_repository_uses_creatures_table_and_get_creatures( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test that repository uses 'creatures' table and calls get_creatures method.""" # Cache miss - empty cache mock_cache.get_entities.return_value = [] creatures = [Creature.model_validate(data) for data in monster_data] mock_client.get_creatures.return_value = creatures mock_cache.store_entities.return_value = 3 repo = CreatureRepository(client=mock_client, cache=mock_cache) await repo.get_all() # Should use "creatures" table for cache operations mock_cache.get_entities.assert_called_once_with("creatures") # Should call get_creatures instead of get_monsters mock_client.get_creatures.assert_called_once_with() # Should store in "creatures" table mock_cache.store_entities.assert_called_once() # Verify the store_entities call uses "creatures" table call_args = mock_cache.store_entities.call_args assert call_args[0][1] == "creatures" @pytest.mark.asyncio async def test_monster_repository_search_uses_creatures_table_and_get_creatures( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test that search uses 'creatures' table and calls get_creatures method.""" # Cache miss mock_cache.get_entities.return_value = [] humanoid_creatures = [Creature.model_validate(monster_data[0])] mock_client.get_creatures.return_value = humanoid_creatures mock_cache.store_entities.return_value = 1 repo = CreatureRepository(client=mock_client, cache=mock_cache) await repo.search(type="humanoid") # Should use "creatures" table for cache operations mock_cache.get_entities.assert_called_once_with("creatures", type="humanoid") # Should call get_creatures instead of get_monsters mock_client.get_creatures.assert_called_once_with(limit=None, type="humanoid") # Should store in "creatures" table call_args = mock_cache.store_entities.call_args assert call_args[0][1] == "creatures" @pytest.mark.asyncio async def test_repository_maps_v2_type_to_type_key_with_lowercase(mock_cache: MagicMock) -> None: """Verify repository maps type to lowercase (type parameter, not type__key).""" mock_v2_client = AsyncMock(spec=Open5eV2Client) mock_v2_client.get_creatures = AsyncMock(return_value=[]) mock_cache.get_entities = AsyncMock(return_value=[]) repository = CreatureRepository(client=mock_v2_client, cache=mock_cache) await repository.search(type="Beast") # Verify v2 client was called with type and lowercase value call_kwargs = mock_v2_client.get_creatures.call_args.kwargs assert call_kwargs.get("type") == "beast" # Ensure type__key parameter is not passed assert "type__key" not in call_kwargs @pytest.mark.asyncio async def test_repository_maps_v2_size_to_size_key_with_lowercase(mock_cache: MagicMock) -> None: """Verify repository maps size to lowercase (size parameter, not size__key).""" mock_v2_client = AsyncMock(spec=Open5eV2Client) mock_v2_client.get_creatures = AsyncMock(return_value=[]) mock_cache.get_entities = AsyncMock(return_value=[]) repository = CreatureRepository(client=mock_v2_client, cache=mock_cache) await repository.search(size="Large") # Verify v2 client was called with size and lowercase value call_kwargs = mock_v2_client.get_creatures.call_args.kwargs assert call_kwargs.get("size") == "large" # Ensure size__key parameter is not passed assert "size__key" not in call_kwargs @pytest.mark.asyncio async def test_repository_maps_v2_cr_exact_to_challenge_rating_decimal( mock_cache: MagicMock, ) -> None: """Verify repository maps exact cr to challenge_rating_decimal as float.""" mock_v2_client = AsyncMock(spec=Open5eV2Client) mock_v2_client.get_creatures = AsyncMock(return_value=[]) mock_cache.get_entities = AsyncMock(return_value=[]) repository = CreatureRepository(client=mock_v2_client, cache=mock_cache) await repository.search(cr=1) # Verify v2 client was called with challenge_rating_decimal as float call_kwargs = mock_v2_client.get_creatures.call_args.kwargs assert call_kwargs.get("challenge_rating_decimal") == 1.0 assert isinstance(call_kwargs.get("challenge_rating_decimal"), float) @pytest.mark.asyncio async def test_repository_maps_v2_challenge_rating_to_challenge_rating_decimal( mock_cache: MagicMock, ) -> None: """Verify repository maps cache field challenge_rating to challenge_rating_decimal.""" mock_v2_client = AsyncMock(spec=Open5eV2Client) mock_v2_client.get_creatures = AsyncMock(return_value=[]) mock_cache.get_entities = AsyncMock(return_value=[]) repository = CreatureRepository(client=mock_v2_client, cache=mock_cache) await repository.search(challenge_rating=21) # Verify v2 client was called with challenge_rating_decimal as float call_kwargs = mock_v2_client.get_creatures.call_args.kwargs assert call_kwargs.get("challenge_rating_decimal") == 21.0 assert isinstance(call_kwargs.get("challenge_rating_decimal"), float) @pytest.mark.asyncio async def test_repository_maps_v2_type_to_type_without_key_suffix(mock_cache: MagicMock) -> None: """Verify repository maps type parameter WITHOUT __key suffix (API expects 'type' not 'type__key').""" mock_v2_client = AsyncMock(spec=Open5eV2Client) mock_v2_client.get_creatures = AsyncMock(return_value=[]) mock_cache.get_entities = AsyncMock(return_value=[]) repository = CreatureRepository(client=mock_v2_client, cache=mock_cache) await repository.search(type="Beast") # Verify v2 client was called with 'type' (not 'type__key') and lowercase value call_kwargs = mock_v2_client.get_creatures.call_args.kwargs assert call_kwargs.get("type") == "beast", "Should use 'type' parameter, not 'type__key'" # Ensure type__key parameter is not passed assert "type__key" not in call_kwargs, "Should not use 'type__key' parameter" @pytest.mark.asyncio async def test_repository_maps_v2_size_to_size_without_key_suffix(mock_cache: MagicMock) -> None: """Verify repository maps size parameter WITHOUT __key suffix (API expects 'size' not 'size__key').""" mock_v2_client = AsyncMock(spec=Open5eV2Client) mock_v2_client.get_creatures = AsyncMock(return_value=[]) mock_cache.get_entities = AsyncMock(return_value=[]) repository = CreatureRepository(client=mock_v2_client, cache=mock_cache) await repository.search(size="Large") # Verify v2 client was called with 'size' (not 'size__key') and lowercase value call_kwargs = mock_v2_client.get_creatures.call_args.kwargs assert call_kwargs.get("size") == "large", "Should use 'size' parameter, not 'size__key'" # Ensure size__key parameter is not passed assert "size__key" not in call_kwargs, "Should not use 'size__key' parameter" @pytest.mark.asyncio async def test_search_monsters_by_document( mock_cache: MagicMock, mock_client: MagicMock, monster_data: list[dict[str, Any]] ) -> None: """Test filtering monsters by document name.""" # Add document to test data monster_with_doc = monster_data[0].copy() monster_with_doc["document"] = "System Reference Document 5.1" mock_cache.get_entities.return_value = [monster_with_doc] repo = CreatureRepository(client=mock_client, cache=mock_cache) results = await repo.search(document="System Reference Document 5.1") # Verify cache was called with document filter mock_cache.get_entities.assert_called_once() call_kwargs = mock_cache.get_entities.call_args[1] assert call_kwargs["document"] == "System Reference Document 5.1" assert len(results) == 1 assert results[0].slug == "goblin" @pytest.mark.asyncio async def test_monster_repository_search_with_document_filter() -> None: """Test CreatureRepository.search passes document filter to cache.""" # Mock client and cache mock_client = MagicMock() mock_client.get_creatures = AsyncMock(return_value=[]) mock_cache = MagicMock() mock_cache.get_entities = AsyncMock(return_value=[]) mock_cache.store_entities = AsyncMock(return_value=0) repo = CreatureRepository(client=mock_client, cache=mock_cache) # Search with document filter await repo.search(document=["srd-5e"]) # Verify cache was called with document filter mock_cache.get_entities.assert_called_once() call_args = mock_cache.get_entities.call_args # Check that document was passed to cache assert call_args[1].get("document") == ["srd-5e"] @pytest.mark.asyncio async def test_monster_repository_search_document_not_passed_to_api() -> None: """Test that document filter is NOT passed to API (cache-only filter).""" # Mock client and cache mock_client = MagicMock() mock_client.get_creatures = AsyncMock(return_value=[]) mock_cache = MagicMock() mock_cache.get_entities = AsyncMock(return_value=[]) # Cache miss mock_cache.store_entities = AsyncMock(return_value=0) repo = CreatureRepository(client=mock_client, cache=mock_cache) # Search with document filter (cache miss) await repo.search(type="humanoid", document="srd-5e") # Verify API was called WITHOUT document parameter mock_client.get_creatures.assert_called_once() call_kwargs = mock_client.get_creatures.call_args[1] assert "document" not in call_kwargs # But type should be passed assert call_kwargs.get("type") == "humanoid" class TestCreatureRepositorySemanticSearch: """Tests for CreatureRepository semantic search support.""" @pytest.mark.asyncio async def test_creature_repository_search_with_search_param(self) -> None: """Test that search() uses semantic_search when search param provided.""" class MockClient: async def get_creatures(self, **filters: Any) -> list[Creature]: return [] class MockCache: def __init__(self) -> None: self.semantic_search_called = False self.search_query = "" async def get_entities(self, entity_type: str, **filters: Any) -> list[dict[str, Any]]: return [] async def store_entities(self, entities: list[dict[str, Any]], entity_type: str) -> int: return len(entities) async def semantic_search( self, entity_type: str, query: str, limit: int = 20, **filters: Any ) -> list[dict[str, Any]]: self.semantic_search_called = True self.search_query = query return [] cache = MockCache() repo = CreatureRepository(client=MockClient(), cache=cache) await repo.search(search="fire breathing dragons") assert cache.semantic_search_called assert cache.search_query == "fire breathing dragons" @pytest.mark.asyncio async def test_creature_repository_semantic_search_with_cr_filter(self) -> None: """Test semantic search combines with CR filter.""" class MockClient: async def get_creatures(self, **filters: Any) -> list[Creature]: return [] class MockCache: def __init__(self) -> None: self.semantic_filters: dict[str, Any] = {} async def get_entities(self, entity_type: str, **filters: Any) -> list[dict[str, Any]]: return [] async def store_entities(self, entities: list[dict[str, Any]], entity_type: str) -> int: return len(entities) async def semantic_search( self, entity_type: str, query: str, limit: int = 20, **filters: Any ) -> list[dict[str, Any]]: self.semantic_filters = filters return [] cache = MockCache() repo = CreatureRepository(client=MockClient(), cache=cache) await repo.search(search="undead monsters", challenge_rating="5") assert cache.semantic_filters.get("challenge_rating") == "5" @pytest.mark.asyncio async def test_creature_repository_search_without_search_param(self) -> None: """Test that search() uses get_entities when no search param.""" class MockClient: async def get_creatures(self, **filters: Any) -> list[Creature]: return [] class MockCache: def __init__(self) -> None: self.semantic_search_called = False self.get_entities_called = False async def get_entities(self, entity_type: str, **filters: Any) -> list[dict[str, Any]]: self.get_entities_called = True return [] async def store_entities(self, entities: list[dict[str, Any]], entity_type: str) -> int: return len(entities) async def semantic_search( self, entity_type: str, query: str, limit: int = 20, **filters: Any ) -> list[dict[str, Any]]: self.semantic_search_called = True return [] cache = MockCache() repo = CreatureRepository(client=MockClient(), cache=cache) # Search without search param await repo.search(type="humanoid") # Should call get_entities, not semantic_search assert cache.get_entities_called assert not cache.semantic_search_called @pytest.mark.asyncio async def test_creature_repository_semantic_search_fallback_on_not_implemented( self, ) -> None: """Test that semantic search falls back when cache doesn't support it.""" class MockClient: async def get_creatures(self, **filters: Any) -> list[Creature]: return [] class MockCache: def __init__(self) -> None: self.get_entities_called = False async def get_entities(self, entity_type: str, **filters: Any) -> list[dict[str, Any]]: self.get_entities_called = True return [ { "slug": "goblin", "name": "Goblin", "size": "Small", "type": "humanoid", "challenge_rating": "1/4", "alignment": "Neutral Evil", "armor_class": 15, "hit_points": 7, "hit_dice": "2d6", } ] async def store_entities(self, entities: list[dict[str, Any]], entity_type: str) -> int: return len(entities) async def semantic_search( self, entity_type: str, query: str, limit: int = 20, **filters: Any ) -> list[dict[str, Any]]: raise NotImplementedError("MockCache does not support semantic search") cache = MockCache() repo = CreatureRepository(client=MockClient(), cache=cache) # Search with search param (should fall back to get_entities) results = await repo.search(search="goblin") # Should have fallen back to get_entities assert cache.get_entities_called assert len(results) == 1 def test_creature_repository_imports_from_creature_module() -> None: """Test CreatureRepository imports from repositories.creature not monster.""" from lorekeeper_mcp.repositories.creature import CreatureRepository assert CreatureRepository is not None # monster.py should not exist with pytest.raises(ImportError): from lorekeeper_mcp.repositories.monster import MonsterRepository # noqa: F401

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/frap129/lorekeeper-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server