✅ **MAJOR MILESTONE**: Bot successfully starts and loads all commands 🔧 **Key Fixes Applied**: - Fixed Pydantic configuration (SettingsConfigDict vs ConfigDict) - Resolved duplicate logging with hybrid propagation approach - Enhanced console logging with detailed format (function:line) - Eliminated redundant .log file handler (kept console + JSON) - Fixed Pylance type errors across views and modals - Added newline termination to JSON logs for better tool compatibility - Enabled league commands package in bot.py - Enhanced command tree hashing for proper type support 📦 **New Components Added**: - Complete views package (base.py, common.py, embeds.py, modals.py) - League service and commands integration - Comprehensive test coverage improvements - Enhanced decorator functionality with proper signature preservation 🎯 **Architecture Improvements**: - Hybrid logging: detailed console for dev + structured JSON for monitoring - Type-safe command tree handling for future extensibility - Proper optional parameter handling in Pydantic models - Eliminated duplicate log messages while preserving third-party library logs 🚀 **Ready for Production**: Bot loads all command packages successfully with no errors 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
537 lines
19 KiB
Python
537 lines
19 KiB
Python
"""
|
|
API client tests using aioresponses for clean HTTP mocking
|
|
"""
|
|
import pytest
|
|
import asyncio
|
|
import aiohttp
|
|
from unittest.mock import MagicMock, patch
|
|
from aioresponses import aioresponses
|
|
|
|
from api.client import APIClient, get_api_client, get_global_client, cleanup_global_client
|
|
from exceptions import APIException
|
|
|
|
|
|
class TestAPIClientWithAioresponses:
|
|
"""Test API client with aioresponses for HTTP mocking."""
|
|
|
|
@pytest.fixture
|
|
def mock_config(self):
|
|
"""Mock configuration for testing."""
|
|
config = MagicMock()
|
|
config.db_url = "https://api.example.com"
|
|
config.api_token = "test-token"
|
|
return config
|
|
|
|
@pytest.fixture
|
|
def api_client(self, mock_config):
|
|
"""Create API client with mocked config."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
return APIClient()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_request_success(self, api_client):
|
|
"""Test successful GET request."""
|
|
expected_data = {"id": 1, "name": "Test Player"}
|
|
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players/1",
|
|
payload=expected_data,
|
|
status=200
|
|
)
|
|
|
|
result = await api_client.get("players", object_id=1)
|
|
|
|
assert result == expected_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_request_404(self, api_client):
|
|
"""Test GET request returning 404."""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players/999",
|
|
status=404
|
|
)
|
|
|
|
result = await api_client.get("players", object_id=999)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_request_401_auth_error(self, api_client):
|
|
"""Test GET request with authentication error."""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players",
|
|
status=401
|
|
)
|
|
|
|
with pytest.raises(APIException, match="Authentication failed"):
|
|
await api_client.get("players")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_request_403_forbidden(self, api_client):
|
|
"""Test GET request with forbidden error."""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players",
|
|
status=403
|
|
)
|
|
|
|
with pytest.raises(APIException, match="Access forbidden"):
|
|
await api_client.get("players")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_request_500_server_error(self, api_client):
|
|
"""Test GET request with server error."""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players",
|
|
status=500,
|
|
body="Internal Server Error"
|
|
)
|
|
|
|
with pytest.raises(APIException, match="API request failed with status 500"):
|
|
await api_client.get("players")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_request_with_params(self, api_client):
|
|
"""Test GET request with query parameters."""
|
|
expected_data = {"count": 2, "players": [{"id": 1}, {"id": 2}]}
|
|
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players?team_id=5&season=12",
|
|
payload=expected_data,
|
|
status=200
|
|
)
|
|
|
|
result = await api_client.get("players", params=[("team_id", "5"), ("season", "12")])
|
|
|
|
assert result == expected_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_request_success(self, api_client):
|
|
"""Test successful POST request."""
|
|
input_data = {"name": "New Player", "position": "C"}
|
|
expected_response = {"id": 1, "name": "New Player", "position": "C"}
|
|
|
|
with aioresponses() as m:
|
|
m.post(
|
|
"https://api.example.com/v3/players",
|
|
payload=expected_response,
|
|
status=201
|
|
)
|
|
|
|
result = await api_client.post("players", input_data)
|
|
|
|
assert result == expected_response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_request_400_error(self, api_client):
|
|
"""Test POST request with validation error."""
|
|
input_data = {"invalid": "data"}
|
|
|
|
with aioresponses() as m:
|
|
m.post(
|
|
"https://api.example.com/v3/players",
|
|
status=400,
|
|
body="Invalid data"
|
|
)
|
|
|
|
with pytest.raises(APIException, match="POST request failed with status 400"):
|
|
await api_client.post("players", input_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_request_success(self, api_client):
|
|
"""Test successful PUT request."""
|
|
update_data = {"name": "Updated Player"}
|
|
expected_response = {"id": 1, "name": "Updated Player"}
|
|
|
|
with aioresponses() as m:
|
|
m.put(
|
|
"https://api.example.com/v3/players/1",
|
|
payload=expected_response,
|
|
status=200
|
|
)
|
|
|
|
result = await api_client.put("players", update_data, object_id=1)
|
|
|
|
assert result == expected_response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_request_404(self, api_client):
|
|
"""Test PUT request with 404."""
|
|
update_data = {"name": "Updated Player"}
|
|
|
|
with aioresponses() as m:
|
|
m.put(
|
|
"https://api.example.com/v3/players/999",
|
|
status=404
|
|
)
|
|
|
|
result = await api_client.put("players", update_data, object_id=999)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_request_success(self, api_client):
|
|
"""Test successful DELETE request."""
|
|
with aioresponses() as m:
|
|
m.delete(
|
|
"https://api.example.com/v3/players/1",
|
|
status=204
|
|
)
|
|
|
|
result = await api_client.delete("players", object_id=1)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_request_404(self, api_client):
|
|
"""Test DELETE request with 404."""
|
|
with aioresponses() as m:
|
|
m.delete(
|
|
"https://api.example.com/v3/players/999",
|
|
status=404
|
|
)
|
|
|
|
result = await api_client.delete("players", object_id=999)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_request_200_success(self, api_client):
|
|
"""Test DELETE request with 200 success."""
|
|
with aioresponses() as m:
|
|
m.delete(
|
|
"https://api.example.com/v3/players/1",
|
|
status=200
|
|
)
|
|
|
|
result = await api_client.delete("players", object_id=1)
|
|
|
|
assert result is True
|
|
|
|
|
|
class TestAPIClientHelpers:
|
|
"""Test API client helper functions."""
|
|
|
|
@pytest.fixture
|
|
def mock_config(self):
|
|
"""Mock configuration for testing."""
|
|
config = MagicMock()
|
|
config.db_url = "https://api.example.com"
|
|
config.api_token = "test-token"
|
|
return config
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_api_client_context_manager(self, mock_config):
|
|
"""Test get_api_client context manager."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/test",
|
|
payload={"success": True},
|
|
status=200
|
|
)
|
|
|
|
async with get_api_client() as client:
|
|
assert isinstance(client, APIClient)
|
|
result = await client.get("test")
|
|
assert result == {"success": True}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_client_management(self, mock_config):
|
|
"""Test global client getter and cleanup."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
# Get global client
|
|
client1 = await get_global_client()
|
|
client2 = await get_global_client()
|
|
|
|
# Should return same instance
|
|
assert client1 is client2
|
|
assert isinstance(client1, APIClient)
|
|
|
|
# Test cleanup
|
|
await cleanup_global_client()
|
|
|
|
# New client should be different instance
|
|
client3 = await get_global_client()
|
|
assert client3 is not client1
|
|
|
|
# Clean up for other tests
|
|
await cleanup_global_client()
|
|
|
|
|
|
class TestIntegrationScenarios:
|
|
"""Test realistic integration scenarios."""
|
|
|
|
@pytest.fixture
|
|
def mock_config(self):
|
|
"""Mock configuration for testing."""
|
|
config = MagicMock()
|
|
config.db_url = "https://api.example.com"
|
|
config.api_token = "test-token"
|
|
return config
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_retrieval_with_team_lookup(self, mock_config):
|
|
"""Test realistic scenario: get player with team data."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
with aioresponses() as m:
|
|
# Mock player data response
|
|
player_data = {
|
|
"id": 1,
|
|
"name": "Test Player",
|
|
"wara": 2.5,
|
|
"season": 12,
|
|
"team_id": 5,
|
|
"image": "https://example.com/player1.jpg",
|
|
"pos_1": "C"
|
|
}
|
|
m.get(
|
|
"https://api.example.com/v3/players/1",
|
|
payload=player_data,
|
|
status=200
|
|
)
|
|
|
|
# Mock team data response
|
|
team_data = {
|
|
"id": 5,
|
|
"abbrev": "TST",
|
|
"sname": "Test Team",
|
|
"lname": "Test Team Full Name",
|
|
"season": 12
|
|
}
|
|
m.get(
|
|
"https://api.example.com/v3/teams/5",
|
|
payload=team_data,
|
|
status=200
|
|
)
|
|
|
|
client = APIClient()
|
|
|
|
# Get player
|
|
player = await client.get("players", object_id=1)
|
|
assert player["name"] == "Test Player"
|
|
assert player["team_id"] == 5
|
|
|
|
# Get team for player
|
|
team = await client.get("teams", object_id=player["team_id"])
|
|
assert team["sname"] == "Test Team"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_response_format_handling(self, mock_config):
|
|
"""Test handling of the API's count + list format."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
with aioresponses() as m:
|
|
# Mock API response with count format
|
|
api_response = {
|
|
"count": 25,
|
|
"players": [
|
|
{
|
|
"id": 1,
|
|
"name": "Player 1",
|
|
"wara": 2.5,
|
|
"season": 12,
|
|
"team_id": 5,
|
|
"image": "https://example.com/player1.jpg",
|
|
"pos_1": "C"
|
|
},
|
|
{
|
|
"id": 2,
|
|
"name": "Player 2",
|
|
"wara": 1.8,
|
|
"season": 12,
|
|
"team_id": 6,
|
|
"image": "https://example.com/player2.jpg",
|
|
"pos_1": "1B"
|
|
}
|
|
]
|
|
}
|
|
|
|
m.get(
|
|
"https://api.example.com/v3/players?team_id=5",
|
|
payload=api_response,
|
|
status=200
|
|
)
|
|
|
|
client = APIClient()
|
|
result = await client.get("players", params=[("team_id", "5")])
|
|
|
|
assert result["count"] == 25
|
|
assert len(result["players"]) == 2
|
|
assert result["players"][0]["name"] == "Player 1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_recovery_scenarios(self, mock_config):
|
|
"""Test error handling and recovery."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
with aioresponses() as m:
|
|
# First request fails with 500
|
|
m.get(
|
|
"https://api.example.com/v3/players/1",
|
|
status=500,
|
|
body="Internal Server Error"
|
|
)
|
|
|
|
# Second request succeeds
|
|
m.get(
|
|
"https://api.example.com/v3/players/2",
|
|
payload={"id": 2, "name": "Working Player"},
|
|
status=200
|
|
)
|
|
|
|
client = APIClient()
|
|
|
|
# First request should raise exception
|
|
with pytest.raises(APIException, match="API request failed"):
|
|
await client.get("players", object_id=1)
|
|
|
|
# Second request should work fine
|
|
result = await client.get("players", object_id=2)
|
|
assert result["name"] == "Working Player"
|
|
|
|
# Client should still be functional
|
|
await client.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_requests(self, mock_config):
|
|
"""Test multiple concurrent requests."""
|
|
import asyncio
|
|
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
with aioresponses() as m:
|
|
# Mock multiple endpoints
|
|
for i in range(1, 4):
|
|
m.get(
|
|
f"https://api.example.com/v3/players/{i}",
|
|
payload={"id": i, "name": f"Player {i}"},
|
|
status=200
|
|
)
|
|
|
|
client = APIClient()
|
|
|
|
# Make concurrent requests
|
|
tasks = [
|
|
client.get("players", object_id=1),
|
|
client.get("players", object_id=2),
|
|
client.get("players", object_id=3)
|
|
]
|
|
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
assert len(results) == 3
|
|
assert results[0]["name"] == "Player 1"
|
|
assert results[1]["name"] == "Player 2"
|
|
assert results[2]["name"] == "Player 3"
|
|
|
|
await client.close()
|
|
|
|
|
|
class TestAPIClientCoverageExtras:
|
|
"""Additional coverage tests for API client edge cases."""
|
|
|
|
@pytest.fixture
|
|
def mock_config(self):
|
|
"""Mock configuration for testing."""
|
|
config = MagicMock()
|
|
config.db_url = "https://api.example.com"
|
|
config.api_token = "test-token"
|
|
return config
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_client_cleanup_when_none(self):
|
|
"""Test cleanup when no global client exists."""
|
|
# Ensure no global client exists
|
|
await cleanup_global_client()
|
|
|
|
# Should not raise error
|
|
await cleanup_global_client()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_url_building_edge_cases(self, mock_config):
|
|
"""Test URL building with various edge cases."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
client = APIClient()
|
|
|
|
# Test trailing slash handling
|
|
client.base_url = "https://api.example.com/"
|
|
url = client._build_url("players")
|
|
assert url == "https://api.example.com/v3/players"
|
|
assert "//" not in url.replace("https://", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parameter_handling_edge_cases(self, mock_config):
|
|
"""Test parameter handling with various scenarios."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
client = APIClient()
|
|
|
|
# Test with existing query string
|
|
url = client._add_params("https://example.com/api?existing=true", [("new", "param")])
|
|
assert url == "https://example.com/api?existing=true&new=param"
|
|
|
|
# Test with no parameters
|
|
url = client._add_params("https://example.com/api")
|
|
assert url == "https://example.com/api"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_error_handling(self, mock_config):
|
|
"""Test timeout error handling using aioresponses."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
client = APIClient()
|
|
|
|
# Test timeout using aioresponses exception parameter
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players",
|
|
exception=asyncio.TimeoutError("Request timed out")
|
|
)
|
|
|
|
with pytest.raises(APIException, match="API call failed.*Request timed out"):
|
|
await client.get("players")
|
|
|
|
await client.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_exception_handling(self, mock_config):
|
|
"""Test generic exception handling."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
client = APIClient()
|
|
|
|
# Test generic exception
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players",
|
|
exception=Exception("Generic error")
|
|
)
|
|
|
|
with pytest.raises(APIException, match="API call failed.*Generic error"):
|
|
await client.get("players")
|
|
|
|
await client.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_closed_handling(self, mock_config):
|
|
"""Test handling of closed session."""
|
|
with patch('api.client.get_config', return_value=mock_config):
|
|
# Test that the client recreates session when needed
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://api.example.com/v3/players",
|
|
payload={"success": True},
|
|
status=200
|
|
)
|
|
|
|
client = APIClient()
|
|
|
|
# Close the session manually
|
|
await client._ensure_session()
|
|
await client._session.close()
|
|
|
|
# Client should recreate session and work fine
|
|
result = await client.get("players")
|
|
assert result == {"success": True}
|
|
|
|
await client.close() |