From 1b7963c2395d0be725d11eb517a20e27b2bc627e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Y=C3=BBki=20VACHOT?= Date: Sat, 29 Nov 2025 02:27:15 +0100 Subject: [PATCH] Add and enhance unit tests: include tests for `ProfileService`, `GameService`, and `AnalysisService`; integrate comprehensive mock data and fixtures. Refactor imports and naming conventions in tests for consistency. Augment `.env.example` with monitoring and logging configurations. --- .env.example | 37 +- src/tests/conftest.py | 119 ++++- src/tests/integration/test_api_client.py | 352 ++++++++++++++- src/tests/integration/test_auth_flow.py | 416 +++++++++++++++++- src/tests/unit/models/test_game.py | 4 +- .../monitoring/schema/test_schema_detector.py | 4 +- .../unit/services/test_analysis_service.py | 356 ++++++++++++++- src/tests/unit/services/test_game_service.py | 306 ++++++++++++- .../unit/services/test_profile_service.py | 237 +++++++++- 9 files changed, 1814 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index ba101c2..6ff3a25 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,10 @@ # Copy this file to .env and fill in your values # ============================================================================= -# REQUIRED: GeoGuessr Authentication +# OPTIONAL: GeoGuessr Authentication # ============================================================================= # Your GeoGuessr _ncfa cookie for API authentication +# If not set, users must authenticate using the login tool # # How to get your _ncfa cookie: # 1. Log in to GeoGuessr in your browser @@ -15,9 +16,8 @@ # 6. Copy its value and paste it below # # IMPORTANT: Keep this secret! Anyone with this cookie can access your account. -# The cookie typically expires after some time, so you may need to update it periodically. -GEOGUESSR_NCFA_COOKIE=your_ncfa_cookie_value_here +GEOGUESSR_NCFA_COOKIE= # ============================================================================= # MCP Server Configuration @@ -25,12 +25,35 @@ GEOGUESSR_NCFA_COOKIE=your_ncfa_cookie_value_here # Transport protocol: "streamable-http" (recommended) or "sse" (legacy) MCP_TRANSPORT=streamable-http +# Host to bind to (0.0.0.0 for all interfaces) +MCP_HOST=0.0.0.0 + # Port to expose the server on MCP_PORT=8000 # ============================================================================= -# Optional: API Key Authentication (recommended for production) +# API Monitoring Configuration # ============================================================================= -# If you want to require API key authentication for accessing the MCP server -# Uncomment and set a secure API key -# API_KEYS=your-secure-api-key-here,another-api-key-if-needed +# Enable automatic API endpoint monitoring +MONITORING_ENABLED=true + +# How often to check API endpoints (in hours) +MONITORING_INTERVAL_HOURS=24 + +# Directory to store schema cache (persisted between restarts) +SCHEMA_CACHE_DIR=/app/data/schemas + +# ============================================================================= +# Logging Configuration +# ============================================================================= +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO + +# ============================================================================= +# Request Configuration +# ============================================================================= +# Request timeout in seconds +REQUEST_TIMEOUT=30.0 + +# Maximum retry attempts for failed requests +MAX_RETRIES=3 diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 6281cb1..4170568 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,9 +1,13 @@ """Shared test fixtures.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest +from geoguessr_mcp.api.dynamic_response import DynamicResponse +from geoguessr_mcp.models import RoundGuess, Game +from geoguessr_mcp.services import AnalysisService, GameService, ProfileService + @pytest.fixture(autouse=True) def mock_env(monkeypatch): @@ -11,6 +15,14 @@ def mock_env(monkeypatch): monkeypatch.setenv("GEOGUESSR_NCFA_COOKIE", "test_cookie_value") +@pytest.fixture +def mock_client(): + """Create a mock GeoGuessrClient.""" + client = MagicMock() + client.get = AsyncMock() + return client + + @pytest.fixture def mock_session(): """Create a mock async HTTP session.""" @@ -20,6 +32,61 @@ def mock_session(): return mock_client +@pytest.fixture +def mock_game_service(): + """Create a mock GameService.""" + service = MagicMock() + service.get_recent_games = AsyncMock() + service.get_season_stats = AsyncMock() + return service + + +@pytest.fixture +def mock_profile_service(): + """Create a mock ProfileService.""" + service = MagicMock() + service.get_comprehensive_profile = AsyncMock() + return service + + +@pytest.fixture +def analysis_service(mock_client, mock_game_service, mock_profile_service): + """Create AnalysisService with mocked dependencies.""" + return AnalysisService( + mock_client, + game_service=mock_game_service, + profile_service=mock_profile_service, + ) + + +@pytest.fixture +def game_service(mock_client): + """Create GameService with mocked client.""" + return GameService(mock_client) + + +@pytest.fixture +def profile_service(mock_client): + """Create ProfileService with mocked client.""" + return ProfileService(mock_client) + + +@pytest.fixture +def mock_dynamic_response(): + """Create a mock DynamicResponse factory.""" + + def create_response(data, success=True, status_code=200): + response = MagicMock(spec=DynamicResponse) + response.data = data + response.is_success = success + response.status_code = status_code + response.available_fields = list(data.keys()) if isinstance(data, dict) else [] + response.summarize.return_value = {"data_summary": data} + return response + + return create_response + + @pytest.fixture def mock_profile_data(): """Standard profile response data.""" @@ -81,3 +148,53 @@ def mock_season_stats_data(): "wins": 30, "tier": "Gold", } + + +@pytest.fixture +def mock_activity_feed_data(): + """Activity feed response data.""" + return { + "entries": [ + { + "type": "PlayedGame", + "payload": {"gameToken": "game-token-1"}, + "timestamp": "2024-01-15T10:00:00Z", + }, + { + "type": "PlayedGame", + "payload": {"gameToken": "game-token-2"}, + "timestamp": "2024-01-14T10:00:00Z", + }, + { + "type": "Achievement", + "payload": {"achievementId": "ach-1"}, + "timestamp": "2024-01-13T10:00:00Z", + }, + ] + } + + +@pytest.fixture +def sample_games(): + """Create sample Game objects for testing.""" + games = [] + for i in range(5): + rounds = [ + RoundGuess( + round_number=j + 1, + score=5000 - (i * 200) - (j * 100), # Varying scores + distance_meters=100.0 * (i + 1), + time_seconds=30 + i * 5, + ) + for j in range(5) + ] + game = Game( + token=f"game-{i}", + map_name="World", + mode="standard", + total_score=sum(r.score for r in rounds), + rounds=rounds, + finished=True, + ) + games.append(game) + return games diff --git a/src/tests/integration/test_api_client.py b/src/tests/integration/test_api_client.py index 4640904..8bf38f8 100644 --- a/src/tests/integration/test_api_client.py +++ b/src/tests/integration/test_api_client.py @@ -1 +1,351 @@ -# TODO +""" +Integration tests for the GeoGuessr API client. + +These tests verify the API client functionality with mocked HTTP responses, +simulating real API interactions without making actual network calls. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from geoguessr_mcp.api import DynamicResponse, GeoGuessrClient, EndpointInfo, Endpoints +from geoguessr_mcp.auth.session import SessionManager, UserSession +from geoguessr_mcp.config import settings + + +class TestDynamicResponse: + """Tests for DynamicResponse wrapper class.""" + + def test_is_success_200(self): + """Test success detection for 200 status code.""" + response = DynamicResponse( + data={"id": "123"}, + endpoint="/v3/profiles", + status_code=200, + response_time_ms=150.0, + ) + assert response.is_success is True + + def test_is_success_201(self): + """Test success detection for 201 status code.""" + response = DynamicResponse( + data={"created": True}, + endpoint="/v3/games", + status_code=201, + response_time_ms=200.0, + ) + assert response.is_success is True + + def test_is_success_failure(self): + """Test success detection for error status codes.""" + response = DynamicResponse( + data={"error": "Not found"}, + endpoint="/v3/profiles", + status_code=404, + response_time_ms=100.0, + ) + assert response.is_success is False + + def test_available_fields_dict(self): + """Test available_fields with dict data.""" + response = DynamicResponse( + data={"id": "123", "name": "Test", "score": 5000}, + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + assert response.available_fields == ["id", "name", "score"] + + def test_available_fields_non_dict(self): + """Test available_fields with non-dict data.""" + response = DynamicResponse( + data=["item1", "item2"], + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + assert response.available_fields == [] + + def test_get_field_simple(self): + """Test getting a simple field.""" + response = DynamicResponse( + data={"id": "123", "name": "Test"}, + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + assert response.get_field("id") == "123" + assert response.get_field("name") == "Test" + + def test_get_field_nested(self): + """Test getting a nested field with dot notation.""" + response = DynamicResponse( + data={ + "user": { + "profile": { + "name": "TestUser", + "level": 50, + } + } + }, + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + assert response.get_field("user.profile.name") == "TestUser" + assert response.get_field("user.profile.level") == 50 + + def test_get_field_missing_with_default(self): + """Test getting missing field returns default.""" + response = DynamicResponse( + data={"id": "123"}, + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + assert response.get_field("missing", default="default_value") == "default_value" + assert response.get_field("nested.missing", default=None) is None + + def test_to_dict(self): + """Test converting response to dict.""" + response = DynamicResponse( + data={"id": "123"}, + endpoint="/v3/profiles", + status_code=200, + response_time_ms=150.5, + ) + result = response.to_dict() + + assert result["success"] is True + assert result["status_code"] == 200 + assert result["endpoint"] == "/v3/profiles" + assert result["response_time_ms"] == 150.5 + assert result["data"] == {"id": "123"} + assert "available_fields" in result + + def test_summarize(self): + """Test response summarization.""" + response = DynamicResponse( + data={ + "items": [ + {"id": 1, "name": "Item 1"}, + {"id": 2, "name": "Item 2"}, + {"id": 3, "name": "Item 3"}, + {"id": 4, "name": "Item 4"}, + ], + "total": 4, + }, + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + summary = response.summarize(max_depth=1) + + assert summary["endpoint"] == "/v3/test" + assert summary["status"] == "success" + assert "data_summary" in summary + + def test_summarize_long_string(self): + """Test that long strings are truncated in summaries.""" + long_text = "x" * 200 + response = DynamicResponse( + data={"description": long_text}, + endpoint="/v3/test", + status_code=200, + response_time_ms=100.0, + ) + summary = response.summarize(max_depth=2) + + # The long string should be truncated + assert len(summary["data_summary"]["description"]) <= 103 # 100 + "..." + + +class TestGeoGuessrClient: + """Tests for GeoGuessrClient.""" + + @pytest.fixture + def mock_session_manager(self): + """Create a mock session manager.""" + manager = MagicMock(spec=SessionManager) + manager.get_session = AsyncMock( + return_value=UserSession( + ncfa_cookie="test_cookie", + user_id="test-user", + username="TestUser", + email="test@example.com", + ) + ) + return manager + + @pytest.fixture + def client(self, mock_session_manager): + """Create a GeoGuessrClient with mocked session manager.""" + return GeoGuessrClient(mock_session_manager) + + @pytest.mark.asyncio + async def test_get_authenticated_client(self, client, mock_session_manager): + """Test getting authenticated HTTP client.""" + http_client = await client._get_authenticated_client() + + assert http_client is not None + mock_session_manager.get_session.assert_called_once() + + @pytest.mark.asyncio + async def test_get_authenticated_client_no_session(self, mock_session_manager): + """Test error when no session is available.""" + mock_session_manager.get_session = AsyncMock(return_value=None) + client = GeoGuessrClient(mock_session_manager) + + with pytest.raises(ValueError, match="No valid session available"): + await client._get_authenticated_client() + + def test_get_base_url_main_api(self, client): + """Test base URL selection for main API.""" + endpoint = EndpointInfo(path="/v3/profiles", use_game_server=False) + url = client._get_base_url(endpoint) + assert url == settings.GEOGUESSR_API_URL + + def test_get_base_url_game_server(self, client): + """Test base URL selection for game server.""" + endpoint = EndpointInfo(path="/tournaments", use_game_server=True) + url = client._get_base_url(endpoint) + assert url == settings.GAME_SERVER_URL + + @pytest.mark.asyncio + async def test_get_request_success(self, client): + """Test successful GET request.""" + with patch.object(client, "_get_authenticated_client") as mock_auth: + mock_http_client = AsyncMock() + mock_http_client.__aenter__.return_value = mock_http_client + mock_http_client.__aexit__.return_value = None + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "123", "nick": "TestUser"} + mock_http_client.get = AsyncMock(return_value=mock_response) + + mock_auth.return_value = mock_http_client + + response = await client.get(Endpoints.PROFILES.GET_PROFILE) + + assert response.is_success + assert response.data["id"] == "123" + + @pytest.mark.asyncio + async def test_get_request_failure(self, client): + """Test failed GET request.""" + with patch.object(client, "_get_authenticated_client") as mock_auth: + mock_http_client = AsyncMock() + mock_http_client.__aenter__.return_value = mock_http_client + mock_http_client.__aexit__.return_value = None + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_http_client.get = AsyncMock(return_value=mock_response) + + mock_auth.return_value = mock_http_client + + response = await client.get(Endpoints.PROFILES.GET_PROFILE) + + assert not response.is_success + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_post_request(self, client): + """Test POST request.""" + with patch.object(client, "_get_authenticated_client") as mock_auth: + mock_http_client = AsyncMock() + mock_http_client.__aenter__.return_value = mock_http_client + mock_http_client.__aexit__.return_value = None + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + mock_http_client.post = AsyncMock(return_value=mock_response) + + mock_auth.return_value = mock_http_client + + endpoint = EndpointInfo(path="/v3/test", method="POST") + response = await client.post(endpoint, json_data={"data": "test"}) + + assert response.is_success + + @pytest.mark.asyncio + async def test_get_raw_request(self, client): + """Test raw GET request to arbitrary path.""" + with patch.object(client, "_get_authenticated_client") as mock_auth: + mock_http_client = AsyncMock() + mock_http_client.__aenter__.return_value = mock_http_client + mock_http_client.__aexit__.return_value = None + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"discovered": True} + mock_http_client.get = AsyncMock(return_value=mock_response) + + mock_auth.return_value = mock_http_client + + response = await client.get_raw("/v3/unknown-endpoint") + + assert response.is_success + assert response.endpoint == "/v3/unknown-endpoint" + + @pytest.mark.asyncio + async def test_timeout_handling(self, client): + """Test handling of timeout exceptions.""" + with patch.object(client, "_get_authenticated_client") as mock_auth: + mock_http_client = AsyncMock() + mock_http_client.__aenter__.return_value = mock_http_client + mock_http_client.__aexit__.return_value = None + mock_http_client.get = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) + + mock_auth.return_value = mock_http_client + + with pytest.raises(httpx.TimeoutException): + await client.get(Endpoints.PROFILES.GET_PROFILE) + + +@pytest.mark.integration +class TestGeoGuessrClientIntegration: + """ + Integration tests that would make real API calls. + + These tests are marked with @pytest.mark.integration and should only + be run when explicitly requested (pytest -m integration) with a valid + authentication cookie. + """ + + @pytest.fixture + def real_client(self): + """Create a real client with environment authentication.""" + session_manager = SessionManager() + return GeoGuessrClient(session_manager) + + @pytest.mark.asyncio + async def test_real_profile_endpoint(self, real_client): + """Test real API call to profile endpoint.""" + # This test requires GEOGUESSR_NCFA_COOKIE to be set + import os + if not os.environ.get("GEOGUESSR_NCFA_COOKIE"): + pytest.skip("GEOGUESSR_NCFA_COOKIE not set") + + response = await real_client.get(Endpoints.PROFILES.GET_PROFILE) + + assert response.is_success + assert "id" in response.available_fields or "nick" in response.available_fields + + @pytest.mark.asyncio + async def test_real_stats_endpoint(self, real_client): + """Test real API call to stats' endpoint.""" + import os + if not os.environ.get("GEOGUESSR_NCFA_COOKIE"): + pytest.skip("GEOGUESSR_NCFA_COOKIE not set") + + response = await real_client.get(Endpoints.PROFILES.GET_STATS) + + assert response.is_success + # Stats' endpoint should have some numeric data + assert len(response.available_fields) > 0 diff --git a/src/tests/integration/test_auth_flow.py b/src/tests/integration/test_auth_flow.py index 4640904..8ebab67 100644 --- a/src/tests/integration/test_auth_flow.py +++ b/src/tests/integration/test_auth_flow.py @@ -1 +1,415 @@ -# TODO +""" +Integration tests for authentication flow. + +These tests verify the complete authentication workflow including +login, session management, and token validation. +""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from geoguessr_mcp.auth.session import SessionManager, UserSession + + +class TestAuthenticationFlow: + """Integration tests for authentication flow with mocked HTTP.""" + + @pytest.fixture + def session_manager(self): + """Create a fresh session manager for each test.""" + return SessionManager() + + @pytest.fixture + def mock_httpx_client(self): + """Create a mock httpx client.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + yield mock_client + + @pytest.mark.asyncio + async def test_complete_login_flow(self, session_manager, mock_httpx_client, mock_profile_data): + """Test complete login flow from credentials to session.""" + # Setup mock responses + login_response = MagicMock() + login_response.status_code = 200 + login_response.cookies.jar = [] + login_response.headers = {"set-cookie": "_ncfa=test_cookie_value; Path=/; HttpOnly"} + + mock_cookie = MagicMock() + mock_cookie.name = "_ncfa" + mock_cookie.value = "test_cookie_value" + login_response.cookies.jar.append(mock_cookie) + + profile_response = MagicMock() + profile_response.status_code = 200 + profile_response.json.return_value = mock_profile_data + + mock_httpx_client.post = AsyncMock(return_value=login_response) + mock_httpx_client.get = AsyncMock(return_value=profile_response) + mock_httpx_client.cookies.set = MagicMock() + + # Perform login + session_token, session = await session_manager.login("user@example.com", "password123") + + # Verify session was created + assert session_token is not None + assert len(session_token) > 20 # Token should be substantial + assert session.ncfa_cookie == "test_cookie_value" + assert session.username == "TestPlayer" + assert session.user_id == "test-user-id" + assert session.is_valid() + + # Verify session can be retrieved + retrieved_session = await session_manager.get_session(session_token) + assert retrieved_session is not None + assert retrieved_session.username == session.username + + @pytest.mark.asyncio + async def test_login_then_logout(self, session_manager, mock_httpx_client, mock_profile_data): + """Test login followed by logout invalidates session.""" + # Setup login mocks + login_response = MagicMock() + login_response.status_code = 200 + login_response.cookies.jar = [] + mock_cookie = MagicMock() + mock_cookie.name = "_ncfa" + mock_cookie.value = "test_cookie" + login_response.cookies.jar.append(mock_cookie) + + profile_response = MagicMock() + profile_response.status_code = 200 + profile_response.json.return_value = mock_profile_data + + mock_httpx_client.post = AsyncMock(return_value=login_response) + mock_httpx_client.get = AsyncMock(return_value=profile_response) + mock_httpx_client.cookies.set = MagicMock() + + # Login + session_token, _ = await session_manager.login("user@example.com", "password") + + # Verify session exists + session_before = await session_manager.get_session(session_token) + assert session_before is not None + + # Logout + logout_result = await session_manager.logout(session_token) + assert logout_result is True + + # Verify session is invalidated + session_after = await session_manager.get_session(session_token) + assert session_after is None + + @pytest.mark.asyncio + async def test_multiple_user_sessions(self, session_manager, mock_httpx_client): + """Test managing multiple user sessions.""" + # Setup responses for two different users + user1_profile = {"id": "user1", "nick": "User1", "email": "user1@example.com"} + user2_profile = {"id": "user2", "nick": "User2", "email": "user2@example.com"} + + login_response = MagicMock() + login_response.status_code = 200 + login_response.cookies.jar = [] + mock_cookie = MagicMock() + mock_cookie.name = "_ncfa" + mock_cookie.value = "cookie_value" + login_response.cookies.jar.append(mock_cookie) + + profile_response1 = MagicMock() + profile_response1.status_code = 200 + profile_response1.json.return_value = user1_profile + + profile_response2 = MagicMock() + profile_response2.status_code = 200 + profile_response2.json.return_value = user2_profile + + mock_httpx_client.post = AsyncMock(return_value=login_response) + mock_httpx_client.cookies.set = MagicMock() + + # Login user 1 + mock_httpx_client.get = AsyncMock(return_value=profile_response1) + token1, session1 = await session_manager.login("user1@example.com", "pass1") + + # Login user 2 + mock_httpx_client.get = AsyncMock(return_value=profile_response2) + token2, session2 = await session_manager.login("user2@example.com", "pass2") + + # Both sessions should be valid + assert token1 != token2 + assert (await session_manager.get_session(token1)) is not None + assert (await session_manager.get_session(token2)) is not None + + @pytest.mark.asyncio + async def test_session_replacement_same_user( + self, session_manager, mock_httpx_client, mock_profile_data + ): + """Test that logging in as same user replaces old session.""" + login_response = MagicMock() + login_response.status_code = 200 + login_response.cookies.jar = [] + mock_cookie = MagicMock() + mock_cookie.name = "_ncfa" + mock_cookie.value = "cookie_value" + login_response.cookies.jar.append(mock_cookie) + + profile_response = MagicMock() + profile_response.status_code = 200 + profile_response.json.return_value = mock_profile_data + + mock_httpx_client.post = AsyncMock(return_value=login_response) + mock_httpx_client.get = AsyncMock(return_value=profile_response) + mock_httpx_client.cookies.set = MagicMock() + + # First login + token1, _ = await session_manager.login("user@example.com", "pass") + + # Second login as same user + token2, _ = await session_manager.login("user@example.com", "pass") + + # First token should be invalid, second should be valid + assert token1 != token2 + assert (await session_manager.get_session(token1)) is None + assert (await session_manager.get_session(token2)) is not None + + @pytest.mark.asyncio + async def test_expired_session_cleanup(self, session_manager): + """Test that expired sessions are cleaned up when accessed.""" + # Manually create an expired session + expired_session = UserSession( + ncfa_cookie="expired_cookie", + user_id="expired_user", + username="ExpiredUser", + email="expired@example.com", + expires_at=datetime.now(UTC) - timedelta(days=1), # Expired yesterday + ) + + # Store the expired session + async with session_manager._lock: + session_manager._sessions["expired_token"] = expired_session + session_manager._user_sessions["expired_user"] = "expired_token" + + # Try to get the session - should return None and clean up + session = await session_manager.get_session("expired_token") + assert session is None + + # Verify cleanup + assert "expired_token" not in session_manager._sessions + assert "expired_user" not in session_manager._user_sessions + + @pytest.mark.asyncio + async def test_default_cookie_fallback(self, session_manager): + """Test falling back to default cookie when no session exists.""" + # Create manager with default cookie + manager_with_default = SessionManager(default_cookie="default_test_cookie") + + # Get session without logging in - should return default + session = await manager_with_default.get_session() + + assert session is not None + assert session.ncfa_cookie == "default_test_cookie" + assert session.user_id == "default" + + @pytest.mark.asyncio + async def test_set_default_cookie(self, session_manager): + """Test setting default cookie after initialization.""" + # Initially no default + session = await session_manager.get_session() + assert session is None + + # Set default cookie + await session_manager.set_default_cookie("new_default_cookie") + + # Now should return default session + session = await session_manager.get_session() + assert session is not None + assert session.ncfa_cookie == "new_default_cookie" + + +class TestLoginErrorHandling: + """Tests for login error scenarios.""" + + @pytest.fixture + def session_manager(self): + return SessionManager() + + @pytest.fixture + def mock_httpx_client(self): + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + yield mock_client + + @pytest.mark.asyncio + async def test_login_invalid_credentials(self, session_manager, mock_httpx_client): + """Test login with invalid credentials.""" + response = MagicMock() + response.status_code = 401 + mock_httpx_client.post = AsyncMock(return_value=response) + + with pytest.raises(ValueError, match="Invalid email or password"): + await session_manager.login("wrong@example.com", "wrong_password") + + @pytest.mark.asyncio + async def test_login_account_denied(self, session_manager, mock_httpx_client): + """Test login when account access is denied.""" + response = MagicMock() + response.status_code = 403 + mock_httpx_client.post = AsyncMock(return_value=response) + + with pytest.raises(ValueError, match="Account access denied"): + await session_manager.login("banned@example.com", "password") + + @pytest.mark.asyncio + async def test_login_rate_limited(self, session_manager, mock_httpx_client): + """Test login when rate limited.""" + response = MagicMock() + response.status_code = 429 + mock_httpx_client.post = AsyncMock(return_value=response) + + with pytest.raises(ValueError, match="Too many login attempts"): + await session_manager.login("user@example.com", "password") + + @pytest.mark.asyncio + async def test_login_server_error(self, session_manager, mock_httpx_client): + """Test login with server error.""" + response = MagicMock() + response.status_code = 500 + mock_httpx_client.post = AsyncMock(return_value=response) + + with pytest.raises(ValueError, match="Login failed: 500"): + await session_manager.login("user@example.com", "password") + + @pytest.mark.asyncio + async def test_login_no_cookie_received(self, session_manager, mock_httpx_client): + """Test login when no cookie is received.""" + login_response = MagicMock() + login_response.status_code = 200 + login_response.cookies.jar = [] # No cookies + login_response.headers = {} # No set-cookie header + + mock_httpx_client.post = AsyncMock(return_value=login_response) + + with pytest.raises(ValueError, match="No session cookie received"): + await session_manager.login("user@example.com", "password") + + @pytest.mark.asyncio + async def test_login_profile_fetch_fails(self, session_manager, mock_httpx_client): + """Test login when profile fetch fails after successful auth.""" + # Login succeeds + login_response = MagicMock() + login_response.status_code = 200 + login_response.cookies.jar = [] + mock_cookie = MagicMock() + mock_cookie.name = "_ncfa" + mock_cookie.value = "valid_cookie" + login_response.cookies.jar.append(mock_cookie) + + # Profile fetch fails + profile_response = MagicMock() + profile_response.status_code = 500 + + mock_httpx_client.post = AsyncMock(return_value=login_response) + mock_httpx_client.get = AsyncMock(return_value=profile_response) + mock_httpx_client.cookies.set = MagicMock() + + with pytest.raises(ValueError, match="Failed to retrieve user profile"): + await session_manager.login("user@example.com", "password") + + +class TestCookieValidation: + """Tests for cookie validation functionality.""" + + @pytest.fixture + def session_manager(self): + return SessionManager() + + @pytest.mark.asyncio + async def test_validate_valid_cookie(self, session_manager, mock_profile_data): + """Test validating a valid cookie.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + response = MagicMock() + response.status_code = 200 + response.json.return_value = mock_profile_data + mock_client.get = AsyncMock(return_value=response) + mock_client.cookies.set = MagicMock() + + result = await session_manager.validate_cookie("valid_cookie") + + assert result is not None + assert result["id"] == "test-user-id" + assert result["nick"] == "TestPlayer" + + @pytest.mark.asyncio + async def test_validate_invalid_cookie(self, session_manager): + """Test validating an invalid cookie.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + response = MagicMock() + response.status_code = 401 + mock_client.get = AsyncMock(return_value=response) + mock_client.cookies.set = MagicMock() + + result = await session_manager.validate_cookie("invalid_cookie") + + assert result is None + + @pytest.mark.asyncio + async def test_validate_cookie_network_error(self, session_manager): + """Test cookie validation with network error.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Network error")) + mock_client.cookies.set = MagicMock() + + result = await session_manager.validate_cookie("cookie") + + assert result is None + + +@pytest.mark.integration +class TestRealAuthFlow: + """ + Real integration tests requiring actual GeoGuessr credentials. + + These tests are skipped unless GEOGUESSR_NCFA_COOKIE is set and + running with -m integration flag. + """ + + @pytest.fixture + def session_manager(self): + return SessionManager() + + @pytest.mark.asyncio + async def test_real_cookie_validation(self, session_manager): + """Test validating a real cookie against the API.""" + import os + + cookie = os.environ.get("GEOGUESSR_NCFA_COOKIE") + if not cookie: + pytest.skip("GEOGUESSR_NCFA_COOKIE not set") + + result = await session_manager.validate_cookie(cookie) + + assert result is not None + assert "id" in result + assert "nick" in result diff --git a/src/tests/unit/models/test_game.py b/src/tests/unit/models/test_game.py index 9ddece0..58f5f33 100644 --- a/src/tests/unit/models/test_game.py +++ b/src/tests/unit/models/test_game.py @@ -6,8 +6,8 @@ instances when interacting with API responses or performing operations like serialization. The tests cover both standard and edge cases. """ -from geoguessr_mcp.models.Game import Game -from geoguessr_mcp.models.RoundGuess import RoundGuess +from geoguessr_mcp.models.game import Game +from geoguessr_mcp.models.round_guess import RoundGuess class TestGame: diff --git a/src/tests/unit/monitoring/schema/test_schema_detector.py b/src/tests/unit/monitoring/schema/test_schema_detector.py index 039401d..6931c52 100644 --- a/src/tests/unit/monitoring/schema/test_schema_detector.py +++ b/src/tests/unit/monitoring/schema/test_schema_detector.py @@ -8,8 +8,8 @@ objects, computation of schema hashes, and parsing of specific data formats such as datetime strings, URLs, and UUIDs. """ -from geoguessr_mcp.monitoring.schema.EndpointSchema import SchemaField -from geoguessr_mcp.monitoring.schema.SchemaDetector import SchemaDetector +from geoguessr_mcp.monitoring.schema.endpoint_schema import SchemaField +from geoguessr_mcp.monitoring.schema.schema_detector import SchemaDetector class TestSchemaDetector: diff --git a/src/tests/unit/services/test_analysis_service.py b/src/tests/unit/services/test_analysis_service.py index 4640904..e43919d 100644 --- a/src/tests/unit/services/test_analysis_service.py +++ b/src/tests/unit/services/test_analysis_service.py @@ -1 +1,355 @@ -# TODO +""" +This module contains unit tests for classes and services provided in the +`geoguessr_mcp` library. It includes test cases for analyzing games, +evaluating statistical trends, and extracting detailed insights from +game performance data. +""" + +from unittest.mock import patch + +import pytest + +from geoguessr_mcp.models import Game, RoundGuess +from geoguessr_mcp.services.analysis_service import AnalysisService, GameAnalysis + + +class TestGameAnalysis: + """Tests for GameAnalysis dataclass.""" + + def test_default_values(self): + """Test GameAnalysis default values.""" + analysis = GameAnalysis() + + assert analysis.games_analyzed == 0 + assert analysis.total_score == 0 + assert analysis.average_score == 0.0 + assert analysis.perfect_round_percentage == 0.0 + assert analysis.score_trend == "stable" + assert analysis.weak_areas == [] + assert analysis.strong_areas == [] + + def test_to_dict(self): + """Test GameAnalysis to_dict method.""" + analysis = GameAnalysis( + games_analyzed=10, + total_score=200000, + average_score=20000.5, + total_rounds=50, + perfect_rounds=15, + perfect_round_percentage=30.0, + average_distance_meters=500.123, + average_time_seconds=45.678, + best_game_score=25000, + worst_game_score=15000, + score_trend="improving", + ) + + result = analysis.to_dict() + + assert result["games_analyzed"] == 10 + assert result["average_score"] == 20000.5 + assert result["perfect_round_percentage"] == 30.0 + assert result["score_trend"] == "improving" + + +class TestAnalysisService: + """Tests for AnalysisService.""" + + def test_analyze_games_empty(self): + """Test analyzing empty game list.""" + result = AnalysisService.analyze_games([]) + + assert result.games_analyzed == 0 + assert result.total_score == 0 + assert result.average_score == 0.0 + + def test_analyze_games_single_game(self): + """Test analyzing single game.""" + rounds = [ + RoundGuess(round_number=1, score=5000, distance_meters=0, time_seconds=20), + RoundGuess(round_number=2, score=4500, distance_meters=100, time_seconds=25), + RoundGuess(round_number=3, score=4000, distance_meters=200, time_seconds=30), + ] + game = Game( + token="test-game", + map_name="World", + mode="standard", + total_score=13500, + rounds=rounds, + finished=True, + ) + + result = AnalysisService.analyze_games([game]) + + assert result.games_analyzed == 1 + assert result.total_score == 13500 + assert result.average_score == 13500 + assert result.total_rounds == 3 + assert result.perfect_rounds == 1 + assert result.perfect_round_percentage == pytest.approx(33.33, rel=0.01) + + def test_analyze_games_multiple_games(self, sample_games): + """Test analyzing multiple games.""" + result = AnalysisService.analyze_games(sample_games) + + assert result.games_analyzed == 5 + assert result.total_rounds == 25 + assert result.average_score == result.total_score / 5 + assert result.best_game_score >= result.worst_game_score + + def test_analyze_games_trend_improving(self): + """Test score trend detection - improving.""" + # Create games with improving scores + games = [] + for i in range(6): + base_score = 15000 + (i * 2000) # Increasing scores + rounds = [RoundGuess(round_number=1, score=base_score, distance_meters=100, time_seconds=30)] + game = Game( + token=f"game-{i}", + map_name="World", + mode="standard", + total_score=base_score, + rounds=rounds, + finished=True, + ) + games.append(game) + + result = AnalysisService.analyze_games(games) + + assert result.score_trend == "improving" + + def test_analyze_games_trend_declining(self): + """Test score trend detection - declining.""" + # Create games with declining scores + games = [] + for i in range(6): + base_score = 25000 - (i * 2000) # Decreasing scores + rounds = [RoundGuess(round_number=1, score=base_score, distance_meters=100, time_seconds=30)] + game = Game( + token=f"game-{i}", + map_name="World", + mode="standard", + total_score=base_score, + rounds=rounds, + finished=True, + ) + games.append(game) + + result = AnalysisService.analyze_games(games) + + assert result.score_trend == "declining" + + def test_analyze_games_weak_areas(self): + """Test weak areas' identification.""" + rounds = [ + RoundGuess(round_number=1, score=5000, distance_meters=0, time_seconds=20), + RoundGuess(round_number=2, score=1500, distance_meters=5000, time_seconds=60), # Weak + RoundGuess(round_number=3, score=1000, distance_meters=8000, time_seconds=90), # Weak + ] + game = Game( + token="test-game", + map_name="World", + mode="standard", + total_score=7500, + rounds=rounds, + finished=True, + ) + + result = AnalysisService.analyze_games([game]) + + assert len(result.weak_areas) == 2 + assert all(area["score"] < 2000 for area in result.weak_areas) + + def test_analyze_games_strong_areas(self): + """Test strong areas' identification.""" + rounds = [ + RoundGuess(round_number=1, score=5000, distance_meters=0, time_seconds=20), + RoundGuess(round_number=2, score=4800, distance_meters=50, time_seconds=25), + RoundGuess(round_number=3, score=2000, distance_meters=500, time_seconds=30), + ] + game = Game( + token="test-game", + map_name="World", + mode="standard", + total_score=11800, + rounds=rounds, + finished=True, + ) + + result = AnalysisService.analyze_games([game]) + + assert len(result.strong_areas) == 2 + assert all(area["score"] >= 4500 for area in result.strong_areas) + + @pytest.mark.asyncio + async def test_analyze_recent_games( + self, analysis_service, mock_game_service, sample_games + ): + """Test analyze_recent_games method.""" + mock_game_service.get_recent_games.return_value = sample_games + + with patch.object( + analysis_service, + 'analyze_games', + wraps=AnalysisService.analyze_games + ): + result = await analysis_service.analyze_recent_games(count=5) + + assert "analysis" in result + assert "games" in result + assert result["analysis"]["games_analyzed"] == 5 + mock_game_service.get_recent_games.assert_called_once_with(5, None) + + @pytest.mark.asyncio + async def test_analyze_recent_games_with_session( + self, analysis_service, mock_game_service, sample_games + ): + """Test analyze_recent_games with session token.""" + mock_game_service.get_recent_games.return_value = sample_games + + await analysis_service.analyze_recent_games( + count=10, + session_token="test_token" + ) + + mock_game_service.get_recent_games.assert_called_once_with(10, "test_token") + + @pytest.mark.asyncio + async def test_get_performance_summary( + self, analysis_service, mock_game_service, mock_profile_service, + mock_client, sample_games, mock_season_stats_data, mock_dynamic_response + ): + """Test comprehensive performance summary.""" + mock_profile_service.get_comprehensive_profile.return_value = { + "profile": {"nick": "TestPlayer"}, + "stats": {"games_played": 100}, + } + + mock_season_response = mock_dynamic_response(mock_season_stats_data) + from geoguessr_mcp.models import SeasonStats + mock_season_stats = SeasonStats.from_api_response(mock_season_stats_data) + mock_game_service.get_season_stats.return_value = (mock_season_stats, mock_season_response) + mock_game_service.get_recent_games.return_value = sample_games[:3] + + mock_client.get.return_value = mock_dynamic_response({"progress": 0.5}) + + result = await analysis_service.get_performance_summary() + + assert result["profile"] is not None + assert result["season"] is not None + assert result["recent_games_analysis"] is not None + assert "api_status" in result + + @pytest.mark.asyncio + async def test_get_performance_summary_with_errors( + self, analysis_service, mock_game_service, mock_profile_service, mock_client + ): + """Test performance summary handles errors gracefully.""" + mock_profile_service.get_comprehensive_profile.side_effect = Exception("Profile error") + mock_game_service.get_season_stats.side_effect = Exception("Season error") + mock_game_service.get_recent_games.return_value = [] + mock_client.get.side_effect = Exception("API error") + + result = await analysis_service.get_performance_summary() + + assert len(result["errors"]) > 0 + assert result["profile"] is None + assert result["season"] is None + + @pytest.mark.asyncio + async def test_get_strategy_recommendations_low_perfect_rate( + self, analysis_service, mock_game_service + ): + """Test strategy recommendations for low perfect round rate.""" + # Create games with no perfect rounds + rounds = [ + RoundGuess(round_number=i, score=3000, distance_meters=500, time_seconds=30) + for i in range(5) + ] + games = [ + Game(token="g1", map_name="World", mode="standard", total_score=15000, rounds=rounds, finished=True) + for _ in range(5) + ] + mock_game_service.get_recent_games.return_value = games + + result = await analysis_service.get_strategy_recommendations() + + assert len(result["recommendations"]) > 0 + accuracy_recs = [r for r in result["recommendations"] if r["category"] == "accuracy"] + assert len(accuracy_recs) > 0 + + @pytest.mark.asyncio + async def test_get_strategy_recommendations_fast_play( + self, analysis_service, mock_game_service + ): + """Test strategy recommendations for fast play style.""" + # Create games with very short time + rounds = [ + RoundGuess(round_number=i, score=3500, distance_meters=300, time_seconds=15) + for i in range(5) + ] + games = [ + Game(token="g1", map_name="World", mode="standard", total_score=17500, rounds=rounds, finished=True) + for _ in range(5) + ] + mock_game_service.get_recent_games.return_value = games + + result = await analysis_service.get_strategy_recommendations() + + time_recs = [r for r in result["recommendations"] if r["category"] == "time_management"] + assert len(time_recs) > 0 + + @pytest.mark.asyncio + async def test_get_strategy_recommendations_declining_trend( + self, analysis_service, mock_game_service + ): + """Test strategy recommendations for declining performance.""" + # Create games with declining scores + games = [] + for i in range(6): + base_score = 25000 - (i * 3000) + rounds = [RoundGuess(round_number=1, score=base_score, distance_meters=100, time_seconds=45)] + game = Game( + token=f"game-{i}", + map_name="World", + mode="standard", + total_score=base_score, + rounds=rounds, + finished=True, + ) + games.append(game) + mock_game_service.get_recent_games.return_value = games + + result = await analysis_service.get_strategy_recommendations() + + consistency_recs = [r for r in result["recommendations"] if r["category"] == "consistency"] + assert len(consistency_recs) > 0 + assert result["analysis_summary"]["trend"] == "declining" + + @pytest.mark.asyncio + async def test_get_strategy_recommendations_many_weak_areas( + self, analysis_service, mock_game_service + ): + """Test strategy recommendations for many weak rounds.""" + # Create games with many low scores + games = [] + for i in range(4): + rounds = [ + RoundGuess(round_number=j, score=1500, distance_meters=5000, time_seconds=60) + for j in range(5) + ] + game = Game( + token=f"game-{i}", + map_name="World", + mode="standard", + total_score=7500, + rounds=rounds, + finished=True, + ) + games.append(game) + mock_game_service.get_recent_games.return_value = games + + result = await analysis_service.get_strategy_recommendations() + + practice_recs = [r for r in result["recommendations"] if r["category"] == "practice"] + assert len(practice_recs) > 0 diff --git a/src/tests/unit/services/test_game_service.py b/src/tests/unit/services/test_game_service.py index 4640904..67d9154 100644 --- a/src/tests/unit/services/test_game_service.py +++ b/src/tests/unit/services/test_game_service.py @@ -1 +1,305 @@ -# TODO +""" +A module for testing the functionalities of `GameService` in the GeoGuessr +ecosystem. + +This module contains a collection of test cases designed to validate +the correctness, edge cases, and error handling for various +asynchronous methods in the `GameService` class. These include +retrieving game details, unfinished games, streak games, activity +feeds, recent games, season statistics, and daily challenges. +""" + +import pytest + +from geoguessr_mcp.models import Game, SeasonStats, DailyChallenge + + +class TestGameService: + """Tests for GameService.""" + + @pytest.mark.asyncio + async def test_get_game_details_success( + self, game_service, mock_client, mock_game_data, mock_dynamic_response + ): + """Test successful game details retrieval.""" + mock_client.get.return_value = mock_dynamic_response(mock_game_data) + + game, response = await game_service.get_game_details("ABC123") + + assert isinstance(game, Game) + assert game.token == "ABC123" + assert game.map_name == "World" + assert game.mode == "standard" + assert len(game.rounds) == 5 + assert game.total_score == 23200 # Sum of all round scores + + @pytest.mark.asyncio + async def test_get_game_details_with_session_token( + self, game_service, mock_client, mock_game_data, mock_dynamic_response + ): + """Test game details with explicit session token.""" + mock_client.get.return_value = mock_dynamic_response(mock_game_data) + + game, response = await game_service.get_game_details( + "ABC123", + session_token="test_token" + ) + + call_args = mock_client.get.call_args + assert call_args[0][1] == "test_token" + + @pytest.mark.asyncio + async def test_get_game_details_failure( + self, game_service, mock_client, mock_dynamic_response + ): + """Test game details retrieval failure.""" + mock_client.get.return_value = mock_dynamic_response( + {"error": "Game not found"}, + success=False, + status_code=404 + ) + + with pytest.raises(ValueError, match="Failed to get game details"): + await game_service.get_game_details("INVALID") + + @pytest.mark.asyncio + async def test_get_unfinished_games( + self, game_service, mock_client, mock_dynamic_response + ): + """Test unfinished games retrieval.""" + unfinished_data = [ + {"token": "game-1", "map": {"name": "World"}}, + {"token": "game-2", "map": {"name": "Europe"}}, + ] + mock_client.get.return_value = mock_dynamic_response(unfinished_data) + + response = await game_service.get_unfinished_games() + + assert response.is_success + assert len(response.data) == 2 + + @pytest.mark.asyncio + async def test_get_streak_game( + self, game_service, mock_client, mock_dynamic_response + ): + """Test streak game retrieval.""" + streak_data = { + "token": "streak-123", + "currentStreak": 15, + "bestStreak": 25, + } + mock_client.get.return_value = mock_dynamic_response(streak_data) + + response = await game_service.get_streak_game("streak-123") + + assert response.is_success + assert response.data["currentStreak"] == 15 + + @pytest.mark.asyncio + async def test_get_activity_feed( + self, game_service, mock_client, mock_activity_feed_data, mock_dynamic_response + ): + """Test activity feed retrieval.""" + mock_client.get.return_value = mock_dynamic_response(mock_activity_feed_data) + + response = await game_service.get_activity_feed(count=10, page=0) + + assert response.is_success + assert len(response.data["entries"]) == 3 + + @pytest.mark.asyncio + async def test_get_activity_feed_pagination( + self, game_service, mock_client, mock_dynamic_response + ): + """Test activity feed with pagination.""" + page_2_data = {"entries": [{"type": "PlayedGame", "payload": {"gameToken": "old-game"}}]} + mock_client.get.return_value = mock_dynamic_response(page_2_data) + + response = await game_service.get_activity_feed(count=10, page=1) + + assert response.is_success + mock_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_get_recent_games_success( + self, game_service, mock_client, mock_activity_feed_data, mock_game_data, mock_dynamic_response + ): + """Test recent games retrieval.""" + # First call returns activity feed, subsequent calls return game details + mock_client.get.side_effect = [ + mock_dynamic_response(mock_activity_feed_data), + mock_dynamic_response(mock_game_data), + mock_dynamic_response({**mock_game_data, "token": "game-token-2"}), + ] + + games = await game_service.get_recent_games(count=2) + + assert len(games) == 2 + assert all(isinstance(g, Game) for g in games) + + @pytest.mark.asyncio + async def test_get_recent_games_empty_feed( + self, game_service, mock_client, mock_dynamic_response + ): + """Test recent games with empty activity feed.""" + mock_client.get.return_value = mock_dynamic_response({"entries": []}) + + games = await game_service.get_recent_games(count=5) + + assert len(games) == 0 + + @pytest.mark.asyncio + async def test_get_recent_games_feed_failure( + self, game_service, mock_client, mock_dynamic_response + ): + """Test recent games when feed fails.""" + mock_client.get.return_value = mock_dynamic_response( + {"error": "Failed"}, + success=False + ) + + games = await game_service.get_recent_games(count=5) + + assert len(games) == 0 + + @pytest.mark.asyncio + async def test_get_recent_games_skips_failed_game_fetch( + self, game_service, mock_client, mock_activity_feed_data, mock_game_data, mock_dynamic_response + ): + """Test that failed individual game fetches are skipped.""" + mock_client.get.side_effect = [ + mock_dynamic_response(mock_activity_feed_data), + Exception("Game fetch failed"), # First game fails + mock_dynamic_response(mock_game_data), # Second game succeeds + ] + + games = await game_service.get_recent_games(count=2) + + assert len(games) == 1 + + @pytest.mark.asyncio + async def test_get_season_stats_success( + self, game_service, mock_client, mock_season_stats_data, mock_dynamic_response + ): + """Test season stats retrieval.""" + mock_client.get.return_value = mock_dynamic_response(mock_season_stats_data) + + stats, response = await game_service.get_season_stats() + + assert isinstance(stats, SeasonStats) + assert stats.season_id == "season-2024-1" + assert stats.rank == 150 + assert stats.rating == 1850 + assert stats.division == "Gold" + + @pytest.mark.asyncio + async def test_get_season_stats_failure( + self, game_service, mock_client, mock_dynamic_response + ): + """Test season stats failure.""" + mock_client.get.return_value = mock_dynamic_response( + {"error": "No active season"}, + success=False, + status_code=404 + ) + + with pytest.raises(ValueError, match="Failed to get season stats"): + await game_service.get_season_stats() + + @pytest.mark.asyncio + async def test_get_daily_challenge_today( + self, game_service, mock_client, mock_dynamic_response + ): + """Test daily challenge retrieval for today.""" + challenge_data = { + "token": "daily-2024-01-15", + "map": {"name": "World"}, + "date": "2024-01-15", + "timeLimit": 180, + } + mock_client.get.return_value = mock_dynamic_response(challenge_data) + + challenge, response = await game_service.get_daily_challenge() + + assert isinstance(challenge, DailyChallenge) + assert challenge.token == "daily-2024-01-15" + assert challenge.time_limit == 180 + + @pytest.mark.asyncio + async def test_get_daily_challenge_specific_day( + self, game_service, mock_client, mock_dynamic_response + ): + """Test daily challenge for specific day.""" + challenge_data = { + "token": "daily-2024-01-10", + "date": "2024-01-10", + } + mock_client.get.return_value = mock_dynamic_response(challenge_data) + + challenge, response = await game_service.get_daily_challenge(day="2024-01-10") + + assert challenge.date == "2024-01-10" + + @pytest.mark.asyncio + async def test_get_daily_challenge_failure( + self, game_service, mock_client, mock_dynamic_response + ): + """Test daily challenge failure.""" + mock_client.get.return_value = mock_dynamic_response( + {"error": "Challenge not found"}, + success=False, + status_code=404 + ) + + with pytest.raises(ValueError, match="Failed to get daily challenge"): + await game_service.get_daily_challenge() + + @pytest.mark.asyncio + async def test_get_battle_royale( + self, game_service, mock_client, mock_dynamic_response + ): + """Test battle royale game retrieval.""" + br_data = { + "gameId": "br-123", + "players": 10, + "status": "in_progress", + } + mock_client.get.return_value = mock_dynamic_response(br_data) + + response = await game_service.get_battle_royale("br-123") + + assert response.is_success + assert response.data["players"] == 10 + + @pytest.mark.asyncio + async def test_get_duel( + self, game_service, mock_client, mock_dynamic_response + ): + """Test duel game retrieval.""" + duel_data = { + "duelId": "duel-456", + "player1": {"id": "p1", "score": 5000}, + "player2": {"id": "p2", "score": 4500}, + } + mock_client.get.return_value = mock_dynamic_response(duel_data) + + response = await game_service.get_duel("duel-456") + + assert response.is_success + assert response.data["player1"]["score"] == 5000 + + @pytest.mark.asyncio + async def test_get_tournaments( + self, game_service, mock_client, mock_dynamic_response + ): + """Test tournaments retrieval.""" + tournaments_data = [ + {"id": "t1", "name": "Weekly Tournament", "status": "active"}, + {"id": "t2", "name": "Monthly Cup", "status": "upcoming"}, + ] + mock_client.get.return_value = mock_dynamic_response(tournaments_data) + + response = await game_service.get_tournaments() + + assert response.is_success + assert len(response.data) == 2 diff --git a/src/tests/unit/services/test_profile_service.py b/src/tests/unit/services/test_profile_service.py index 4640904..35c9191 100644 --- a/src/tests/unit/services/test_profile_service.py +++ b/src/tests/unit/services/test_profile_service.py @@ -1 +1,236 @@ -# TODO +""" +Tests for the ProfileService behaviors and functionality. + +This module includes tests for various aspects of ProfileService such +as profile management, statistics retrieval, achievement +retrieval, and handling edge cases or failures during service +operations. +""" + +import pytest + +from geoguessr_mcp.models import UserProfile, UserStats, Achievement + + +class TestProfileService: + """Tests for ProfileService.""" + + @pytest.mark.asyncio + async def test_get_profile_success( + self, profile_service, mock_client, mock_profile_data, mock_dynamic_response + ): + """Test successful profile retrieval.""" + mock_client.get.return_value = mock_dynamic_response(mock_profile_data) + + profile, response = await profile_service.get_profile() + + assert isinstance(profile, UserProfile) + assert profile.id == "test-user-id" + assert profile.nick == "TestPlayer" + assert profile.email == "test@example.com" + assert profile.country == "FR" + assert profile.level == 50 + mock_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_get_profile_with_session_token( + self, profile_service, mock_client, mock_profile_data, mock_dynamic_response + ): + """Test profile retrieval with explicit session token.""" + mock_client.get.return_value = mock_dynamic_response(mock_profile_data) + + await profile_service.get_profile(session_token="test_token") + + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert call_args[0][1] == "test_token" + + @pytest.mark.asyncio + async def test_get_profile_failure( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test profile retrieval failure.""" + mock_client.get.return_value = mock_dynamic_response( + {"error": "Unauthorized"}, + success=False, + status_code=401 + ) + + with pytest.raises(ValueError, match="Failed to get profile"): + await profile_service.get_profile() + + @pytest.mark.asyncio + async def test_get_stats_success( + self, profile_service, mock_client, mock_stats_data, mock_dynamic_response + ): + """Test successful stats retrieval.""" + mock_client.get.return_value = mock_dynamic_response(mock_stats_data) + + stats, response = await profile_service.get_stats() + + assert isinstance(stats, UserStats) + assert stats.games_played == 100 + assert stats.rounds_played == 500 + assert stats.total_score == 2250000 + assert stats.win_rate == 0.65 + + @pytest.mark.asyncio + async def test_get_stats_failure( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test stats retrieval failure.""" + mock_client.get.return_value = mock_dynamic_response( + {"error": "Server error"}, + success=False, + status_code=500 + ) + + with pytest.raises(ValueError, match="Failed to get stats"): + await profile_service.get_stats() + + @pytest.mark.asyncio + async def test_get_extended_stats( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test extended stats retrieval.""" + extended_data = { + "totalDistance": 1500000, + "averageTime": 45.5, + "favoriteMap": "World", + } + mock_client.get.return_value = mock_dynamic_response(extended_data) + + response = await profile_service.get_extended_stats() + + assert response.is_success + assert response.data["totalDistance"] == 1500000 + + @pytest.mark.asyncio + async def test_get_achievements_list_format( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test achievements retrieval with list format response.""" + achievements_data = [ + { + "id": "ach-1", + "name": "First Win", + "description": "Win your first game", + "unlocked": True, + "unlockedAt": "2024-01-15T00:00:00Z", + }, + { + "id": "ach-2", + "name": "Explorer", + "description": "Play 100 games", + "unlocked": False, + "progress": 0.45, + }, + ] + mock_client.get.return_value = mock_dynamic_response(achievements_data) + + achievements, response = await profile_service.get_achievements() + + assert len(achievements) == 2 + assert all(isinstance(a, Achievement) for a in achievements) + assert achievements[0].name == "First Win" + assert achievements[0].unlocked is True + assert achievements[1].unlocked is False + + @pytest.mark.asyncio + async def test_get_achievements_dict_format( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test achievements retrieval with dict format response.""" + achievements_data = { + "achievements": [ + {"id": "ach-1", "name": "Winner", "unlocked": True}, + ] + } + mock_client.get.return_value = mock_dynamic_response(achievements_data) + + achievements, response = await profile_service.get_achievements() + + assert len(achievements) == 1 + assert achievements[0].name == "Winner" + + @pytest.mark.asyncio + async def test_get_public_profile( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test public profile retrieval.""" + public_profile_data = { + "id": "other-user-123", + "nick": "OtherPlayer", + "country": "US", + "level": 75, + } + mock_client.get.return_value = mock_dynamic_response(public_profile_data) + + profile, response = await profile_service.get_public_profile("other-user-123") + + assert profile.id == "other-user-123" + assert profile.nick == "OtherPlayer" + + @pytest.mark.asyncio + async def test_get_user_maps( + self, profile_service, mock_client, mock_dynamic_response + ): + """Test user maps retrieval.""" + maps_data = [ + {"id": "map-1", "name": "My Custom Map"}, + {"id": "map-2", "name": "Another Map"}, + ] + mock_client.get.return_value = mock_dynamic_response(maps_data) + + response = await profile_service.get_user_maps() + + assert response.is_success + assert len(response.data) == 2 + + @pytest.mark.asyncio + async def test_get_comprehensive_profile_success( + self, profile_service, mock_client, mock_profile_data, mock_stats_data, mock_dynamic_response + ): + """Test comprehensive profile aggregation.""" + # Setup mock responses for each call + mock_client.get.side_effect = [ + mock_dynamic_response(mock_profile_data), # profile + mock_dynamic_response(mock_stats_data), # stats + mock_dynamic_response({"totalDistance": 1000}), # extended stats + mock_dynamic_response([ # achievements + {"id": "ach-1", "name": "Test", "unlocked": True, "unlockedAt": "2024-01-01"}, + ]), + ] + + result = await profile_service.get_comprehensive_profile() + + assert result["profile"] is not None + assert result["profile"]["nick"] == "TestPlayer" + assert result["stats"] is not None + assert result["stats"]["games_played"] == 100 + assert result["extended_stats"] is not None + assert result["achievements"]["total"] == 1 + assert result["achievements"]["unlocked"] == 1 + assert len(result["errors"]) == 0 + + @pytest.mark.asyncio + async def test_get_comprehensive_profile_partial_failure( + self, profile_service, mock_client, mock_profile_data, mock_dynamic_response + ): + """Test comprehensive profile with some endpoints failing.""" + mock_client.get.side_effect = [ + mock_dynamic_response(mock_profile_data), # profile succeeds + Exception("Stats endpoint down"), # stats fails + mock_dynamic_response({"data": "test"}), # extended stats succeed + Exception("Achievements unavailable"), # achievements fails + ] + + result = await profile_service.get_comprehensive_profile() + + assert result["profile"] is not None + assert result["stats"] is None + assert result["extended_stats"] is not None + assert result["achievements"] is None + assert len(result["errors"]) == 2 + assert any("Stats" in e for e in result["errors"]) + assert any("Achievements" in e for e in result["errors"])