GeoGuessrMCP/src/tests/unit/services/test_profile_service.py
Claude 482daa73e0
Fix CI/CD issues and add comprehensive tests for multi-user features
This commit fixes three critical issues identified in CI/CD and adds
comprehensive test coverage for the new multi-user functionality.

## Fixes

### 1. FastMCP Middleware Registration Error

**Problem**: `AttributeError: 'FastMCP' object has no attribute 'app'`

**Solution**: Implemented robust middleware registration that:
- Tries multiple possible locations where FastMCP might store the app
- Gracefully handles cases where app isn't immediately available
- Wraps the run() method to defer middleware addition if needed
- Attempts: _transport.app, sse.app, http_server.app, _app, _asgi_app
- Falls back gracefully with warning if middleware can't be added

**Files Changed**:
- src/geoguessr_mcp/main.py: Added smart middleware registration logic

### 2. Test Permission Errors

**Problem**: `PermissionError: [Errno 13] Permission denied: '/app'`
Schema registry tried to create /app/data/schemas in CI without permission

**Solution**: Made schema cache directory creation fault-tolerant:
- Catches PermissionError and OSError when creating cache directory
- Falls back to temporary directory (tempfile.mkdtemp) if permission denied
- Logs clear warning messages about fallback behavior
- Tests can now run in restricted environments

**Files Changed**:
- src/geoguessr_mcp/monitoring/schema/schema_registry.py: Added fallback logic

### 3. Black Formatting Issues

**Problem**: 10 files needed reformatting

**Solution**: Ran `black src/ --line-length 100` on all source files

**Files Formatted**:
- src/geoguessr_mcp/config.py
- src/geoguessr_mcp/api/dynamic_response.py
- src/geoguessr_mcp/middleware/auth.py
- src/geoguessr_mcp/main.py
- src/geoguessr_mcp/auth/multi_user_session.py
- src/geoguessr_mcp/tools/auth_tools.py
- src/tests/integration/test_auth_flow.py
- src/tests/unit/services/*.py (3 files)

## New Tests

Added comprehensive test coverage for multi-user features:

### test_user_context.py
- Tests UserContext creation with/without sessions
- Tests authentication status checking
- Tests session expiration handling
- Tests string representation
- Tests API key hashing for anonymous users
- Tests consistency of anonymous user IDs

### test_multi_user_session.py
- Tests MultiUserSessionManager initialization
- Tests session manager creation per API key
- Tests session manager reuse for same API key
- Tests isolation between different users
- Tests auth status reporting
- Tests context creation and retrieval

### test_request_context.py
- Tests context variable get/set operations
- Tests require_user_context() error handling
- Tests context isolation between requests
- Tests context updates and clearing
- Tests None handling

## Code Quality

All changes pass:
-  Python syntax checks (py_compile)
-  Black formatting (line-length 100)
-  Test structure validation
-  Import resolution

## CI/CD Impact

These fixes should resolve:
-  Test execution failures (permission errors)
-  Black formatting check failures
-  Runtime errors when starting server with auth enabled

Tests can now run in CI environment without requiring:
- Root permissions
- /app directory access
- Pre-created cache directories
2025-11-29 23:11:32 +00:00

229 lines
8.4 KiB
Python

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