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.

This commit is contained in:
Yûki VACHOT 2025-11-29 02:27:15 +01:00
parent 8cc53378b7
commit 1b7963c239
9 changed files with 1814 additions and 17 deletions

View file

@ -2,9 +2,10 @@
# Copy this file to .env and fill in your values # Copy this file to .env and fill in your values
# ============================================================================= # =============================================================================
# REQUIRED: GeoGuessr Authentication # OPTIONAL: GeoGuessr Authentication
# ============================================================================= # =============================================================================
# Your GeoGuessr _ncfa cookie for API authentication # Your GeoGuessr _ncfa cookie for API authentication
# If not set, users must authenticate using the login tool
# #
# How to get your _ncfa cookie: # How to get your _ncfa cookie:
# 1. Log in to GeoGuessr in your browser # 1. Log in to GeoGuessr in your browser
@ -15,9 +16,8 @@
# 6. Copy its value and paste it below # 6. Copy its value and paste it below
# #
# IMPORTANT: Keep this secret! Anyone with this cookie can access your account. # 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 # MCP Server Configuration
@ -25,12 +25,35 @@ GEOGUESSR_NCFA_COOKIE=your_ncfa_cookie_value_here
# Transport protocol: "streamable-http" (recommended) or "sse" (legacy) # Transport protocol: "streamable-http" (recommended) or "sse" (legacy)
MCP_TRANSPORT=streamable-http 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 # Port to expose the server on
MCP_PORT=8000 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 # Enable automatic API endpoint monitoring
# Uncomment and set a secure API key MONITORING_ENABLED=true
# API_KEYS=your-secure-api-key-here,another-api-key-if-needed
# 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

View file

@ -1,9 +1,13 @@
"""Shared test fixtures.""" """Shared test fixtures."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, MagicMock
import pytest 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) @pytest.fixture(autouse=True)
def mock_env(monkeypatch): def mock_env(monkeypatch):
@ -11,6 +15,14 @@ def mock_env(monkeypatch):
monkeypatch.setenv("GEOGUESSR_NCFA_COOKIE", "test_cookie_value") 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 @pytest.fixture
def mock_session(): def mock_session():
"""Create a mock async HTTP session.""" """Create a mock async HTTP session."""
@ -20,6 +32,61 @@ def mock_session():
return mock_client 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 @pytest.fixture
def mock_profile_data(): def mock_profile_data():
"""Standard profile response data.""" """Standard profile response data."""
@ -81,3 +148,53 @@ def mock_season_stats_data():
"wins": 30, "wins": 30,
"tier": "Gold", "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

View file

@ -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

View file

@ -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

View file

@ -6,8 +6,8 @@ instances when interacting with API responses or performing operations like
serialization. The tests cover both standard and edge cases. serialization. The tests cover both standard and edge cases.
""" """
from geoguessr_mcp.models.Game import Game from geoguessr_mcp.models.game import Game
from geoguessr_mcp.models.RoundGuess import RoundGuess from geoguessr_mcp.models.round_guess import RoundGuess
class TestGame: class TestGame:

View file

@ -8,8 +8,8 @@ objects, computation of schema hashes, and parsing of specific data formats
such as datetime strings, URLs, and UUIDs. such as datetime strings, URLs, and UUIDs.
""" """
from geoguessr_mcp.monitoring.schema.EndpointSchema import SchemaField from geoguessr_mcp.monitoring.schema.endpoint_schema import SchemaField
from geoguessr_mcp.monitoring.schema.SchemaDetector import SchemaDetector from geoguessr_mcp.monitoring.schema.schema_detector import SchemaDetector
class TestSchemaDetector: class TestSchemaDetector:

View file

@ -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

View file

@ -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

View file

@ -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"])