From d8f1cb472a0d2a0a6f4eafa34a92b242824741b9 Mon Sep 17 00:00:00 2001 From: CodeKeanu <85296798+CodeKeanu@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:11:49 +0000 Subject: [PATCH] feat: v1.0.0 release readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update version to 1.0.0 in pyproject.toml and server.py - Change development status from Alpha to Production/Stable - Add missing test coverage for steam_user, user_stats, steam_news endpoints - Remove duplicate _resolve_steam_id methods, use base class implementation - Update CHANGELOG.md with v1.0.0 release notes - Fix test patches after code consolidation Closes #29 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 21 +- pyproject.toml | 4 +- src/steam_mcp/endpoints/player_service.py | 23 -- src/steam_mcp/endpoints/steam_trading.py | 22 -- src/steam_mcp/endpoints/steam_user.py | 21 - src/steam_mcp/endpoints/user_stats.py | 17 - src/steam_mcp/server.py | 2 +- tests/test_player_service.py | 16 +- tests/test_steam_news.py | 283 ++++++++++++++ tests/test_steam_trading.py | 18 +- tests/test_steam_user.py | 453 ++++++++++++++++++++++ tests/test_user_stats.py | 421 ++++++++++++++++++++ 12 files changed, 1197 insertions(+), 104 deletions(-) create mode 100644 tests/test_steam_news.py create mode 100644 tests/test_steam_user.py create mode 100644 tests/test_user_stats.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 84594b3..fff761e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v1.0.0] - 2025-01-01 + +### Added +- Full test coverage for all 10 endpoint modules (36 tools total) + - Added `test_steam_user.py` (6 tools) + - Added `test_user_stats.py` (6 tools) + - Added `test_steam_news.py` (1 tool) + +### Changed +- Updated version to 1.0.0 across all files +- Changed development status from "Alpha" to "Production/Stable" + +### Fixed +- Removed duplicate `_resolve_steam_id` methods in endpoint modules + - Consolidated to use base class implementation + - Affected files: steam_user.py, user_stats.py, player_service.py, steam_trading.py +- Cleaned up unused imports after code consolidation + ## [v0.9.0] - 2025-12-12 ### Added @@ -237,7 +255,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Docker Compose configuration - Comprehensive README with usage instructions -[Unreleased]: https://github.com/CodeKeanu/steam-mcp/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/CodeKeanu/steam-mcp/compare/v1.0.0...HEAD +[v1.0.0]: https://github.com/CodeKeanu/steam-mcp/compare/v0.9.0...v1.0.0 [v0.9.0]: https://github.com/CodeKeanu/steam-mcp/compare/v0.8.0...v0.9.0 [v0.8.0]: https://github.com/CodeKeanu/steam-mcp/compare/v0.7.1...v0.8.0 [v0.7.1]: https://github.com/CodeKeanu/steam-mcp/compare/v0.7.0...v0.7.1 diff --git a/pyproject.toml b/pyproject.toml index 87b674c..afe338a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "steam-mcp" -version = "0.8.0" +version = "1.0.0" description = "Steam API integration via Model Context Protocol (MCP)" readme = "README.md" license = "MIT" @@ -14,7 +14,7 @@ authors = [ ] keywords = ["steam", "mcp", "api", "model-context-protocol", "claude"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", diff --git a/src/steam_mcp/endpoints/player_service.py b/src/steam_mcp/endpoints/player_service.py index 47e0b47..b88cd7c 100644 --- a/src/steam_mcp/endpoints/player_service.py +++ b/src/steam_mcp/endpoints/player_service.py @@ -11,7 +11,6 @@ from typing import Any from steam_mcp.endpoints.base import BaseEndpoint, endpoint -from steam_mcp.utils.steam_id import normalize_steam_id, SteamIDError # Default games to display in detailed output @@ -318,28 +317,6 @@ async def get_steam_level(self, steam_id: str) -> str: return f"Steam Level for {normalized_id}: {level} ({tier})" - async def _resolve_steam_id(self, steam_id: str) -> str: - """ - Resolve steam_id, handling 'me'/'my' shortcuts. - - Returns: - Normalized SteamID64 or error message starting with "Error" - """ - # Handle "me" / "my" shortcuts - steam_id_lower = steam_id.strip().lower() - if steam_id_lower in ("me", "my", "myself", "mine"): - if not self.client.owner_steam_id: - return ( - "Error: No owner Steam ID configured. " - "Set STEAM_USER_ID environment variable to use 'me'/'my' shortcuts." - ) - return self.client.owner_steam_id - - try: - return await normalize_steam_id(steam_id, self.client) - except SteamIDError as e: - return f"Error resolving Steam ID: {e}" - async def _fetch_games_raw( self, steam_id: str, include_free: bool = True ) -> list[dict[str, Any]]: diff --git a/src/steam_mcp/endpoints/steam_trading.py b/src/steam_mcp/endpoints/steam_trading.py index f36ca7b..867d8d9 100644 --- a/src/steam_mcp/endpoints/steam_trading.py +++ b/src/steam_mcp/endpoints/steam_trading.py @@ -14,33 +14,11 @@ from typing import Any from steam_mcp.endpoints.base import BaseEndpoint, endpoint -from steam_mcp.utils.steam_id import normalize_steam_id, SteamIDError class IEconService(BaseEndpoint): """Steam Economy Service API endpoints for trading and market data.""" - async def _resolve_steam_id(self, steam_id: str) -> str: - """ - Resolve steam_id, handling 'me'/'my' shortcuts. - - Returns: - Normalized SteamID64 or error message starting with "Error" - """ - steam_id_lower = steam_id.strip().lower() - if steam_id_lower in ("me", "my", "myself", "mine"): - if not self.client.owner_steam_id: - return ( - "Error: No owner Steam ID configured. " - "Set STEAM_USER_ID environment variable to use 'me'/'my' shortcuts." - ) - return self.client.owner_steam_id - - try: - return await normalize_steam_id(steam_id, self.client) - except SteamIDError as e: - return f"Error resolving Steam ID: {e}" - @endpoint( name="get_trade_offers", description=( diff --git a/src/steam_mcp/endpoints/steam_user.py b/src/steam_mcp/endpoints/steam_user.py index 459785c..9a6ee91 100644 --- a/src/steam_mcp/endpoints/steam_user.py +++ b/src/steam_mcp/endpoints/steam_user.py @@ -21,27 +21,6 @@ class ISteamUser(BaseEndpoint): """ISteamUser API endpoints for player identity and profile data.""" - async def _resolve_steam_id(self, steam_id: str) -> str: - """ - Resolve steam_id, handling 'me'/'my' shortcuts. - - Returns: - Normalized SteamID64 or error message starting with "Error" - """ - steam_id_lower = steam_id.strip().lower() - if steam_id_lower in ("me", "my", "myself", "mine"): - if not self.client.owner_steam_id: - return ( - "Error: No owner Steam ID configured. " - "Set STEAM_USER_ID environment variable to use 'me'/'my' shortcuts." - ) - return self.client.owner_steam_id - - try: - return await normalize_steam_id(steam_id, self.client) - except SteamIDError as e: - return f"Error resolving Steam ID: {e}" - @endpoint( name="get_my_steam_id", description=( diff --git a/src/steam_mcp/endpoints/user_stats.py b/src/steam_mcp/endpoints/user_stats.py index a19d618..b3eb738 100644 --- a/src/steam_mcp/endpoints/user_stats.py +++ b/src/steam_mcp/endpoints/user_stats.py @@ -11,7 +11,6 @@ from typing import Any from steam_mcp.endpoints.base import BaseEndpoint, endpoint -from steam_mcp.utils.steam_id import normalize_steam_id, SteamIDError # Maximum achievements to display in detailed output @@ -588,19 +587,3 @@ async def get_global_stats_for_game( output.append(f" {stat_name}: {total_str}") return "\n".join(output) - - async def _resolve_steam_id(self, steam_id: str) -> str: - """Resolve steam_id, handling 'me'/'my' shortcuts.""" - steam_id_lower = steam_id.strip().lower() - if steam_id_lower in ("me", "my", "myself", "mine"): - if not self.client.owner_steam_id: - return ( - "Error: No owner Steam ID configured. " - "Set STEAM_USER_ID environment variable to use 'me'/'my' shortcuts." - ) - return self.client.owner_steam_id - - try: - return await normalize_steam_id(steam_id, self.client) - except SteamIDError as e: - return f"Error resolving Steam ID: {e}" diff --git a/src/steam_mcp/server.py b/src/steam_mcp/server.py index ae1c45d..9b447ec 100644 --- a/src/steam_mcp/server.py +++ b/src/steam_mcp/server.py @@ -125,7 +125,7 @@ async def run_server() -> None: write_stream, InitializationOptions( server_name="steam-mcp-server", - server_version="0.1.0", + server_version="1.0.0", capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, diff --git a/tests/test_player_service.py b/tests/test_player_service.py index d180bd7..c5a800e 100644 --- a/tests/test_player_service.py +++ b/tests/test_player_service.py @@ -43,7 +43,7 @@ async def test_self_comparison_rejected(self, player_service, mock_client): """Friend ID matching user ID should be rejected.""" # Both resolve to same ID with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -58,7 +58,7 @@ async def test_self_comparison_rejected(self, player_service, mock_client): async def test_invalid_steam_id_returns_error(self, player_service): """Invalid Steam ID should return error.""" with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=SteamIDError("Invalid Steam ID"), ): @@ -90,7 +90,7 @@ async def test_finds_shared_unplayed_games(self, player_service, mock_client): ] with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=["76561198000000001", "76561198000000002"], ): @@ -115,7 +115,7 @@ async def test_no_unplayed_games_message(self, player_service, mock_client): ] with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=["76561198000000001", "76561198000000002"], ): @@ -141,7 +141,7 @@ async def test_private_profile_handled_gracefully(self, player_service, mock_cli ] with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=["76561198000000001", "76561198000000002", "76561198000000003"], ): @@ -176,7 +176,7 @@ async def test_multiple_friends_all_must_own(self, player_service, mock_client): ] with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=["76561198000000001", "76561198000000002", "76561198000000003"], ): @@ -203,7 +203,7 @@ async def test_all_friends_must_have_zero_playtime(self, player_service, mock_cl ] with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=["76561198000000001", "76561198000000002", "76561198000000003"], ): @@ -223,7 +223,7 @@ async def test_my_profile_private_returns_error(self, player_service, mock_clien ] with patch( - "steam_mcp.endpoints.player_service.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=["76561198000000001", "76561198000000002"], ): diff --git a/tests/test_steam_news.py b/tests/test_steam_news.py new file mode 100644 index 0000000..6fd1b8e --- /dev/null +++ b/tests/test_steam_news.py @@ -0,0 +1,283 @@ +"""Tests for ISteamNews endpoints.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from steam_mcp.endpoints.steam_news import ISteamNews + + +@pytest.fixture +def mock_client(): + """Create mock Steam client.""" + client = MagicMock() + client.get = AsyncMock() + return client + + +@pytest.fixture +def steam_news(mock_client): + """Create ISteamNews instance with mock client.""" + return ISteamNews(mock_client) + + +class TestGetNewsForApp: + """Tests for get_news_for_app endpoint.""" + + @pytest.mark.asyncio + async def test_returns_news_items(self, steam_news, mock_client): + """Should return formatted news items.""" + mock_client.get.return_value = { + "appnews": { + "newsitems": [ + { + "title": "Big Update", + "author": "Developer", + "url": "https://example.com/news", + "contents": "Exciting news about the update!", + "date": 1702000000, + "feedlabel": "Steam Blog", + "is_external_url": False, + } + ] + } + } + + result = await steam_news.get_news_for_app(app_id=440) + + assert "News for App ID 440" in result + assert "Big Update" in result + assert "Developer" in result + assert "Steam Blog" in result + assert "Exciting news" in result + assert "https://example.com/news" in result + + @pytest.mark.asyncio + async def test_no_news_found(self, steam_news, mock_client): + """Should return message when no news found.""" + mock_client.get.return_value = {"appnews": {"newsitems": []}} + + result = await steam_news.get_news_for_app(app_id=12345) + + assert "No news found" in result + assert "12345" in result + + @pytest.mark.asyncio + async def test_multiple_news_items(self, steam_news, mock_client): + """Should return multiple news items.""" + mock_client.get.return_value = { + "appnews": { + "newsitems": [ + { + "title": "First News", + "author": "Author1", + "url": "url1", + "contents": "Content 1", + "date": 1702000000, + }, + { + "title": "Second News", + "author": "Author2", + "url": "url2", + "contents": "Content 2", + "date": 1701000000, + }, + ] + } + } + + result = await steam_news.get_news_for_app(app_id=440, count=5) + + assert "2 article(s)" in result + assert "First News" in result + assert "Second News" in result + + @pytest.mark.asyncio + async def test_count_clamped_to_max(self, steam_news, mock_client): + """Should clamp count to maximum of 20.""" + mock_client.get.return_value = {"appnews": {"newsitems": []}} + + await steam_news.get_news_for_app(app_id=440, count=50) + + # Verify the count passed to API is clamped + call_args = mock_client.get.call_args + assert call_args[1]["params"]["count"] == 20 + + @pytest.mark.asyncio + async def test_count_clamped_to_min(self, steam_news, mock_client): + """Should clamp count to minimum of 1.""" + mock_client.get.return_value = {"appnews": {"newsitems": []}} + + await steam_news.get_news_for_app(app_id=440, count=0) + + call_args = mock_client.get.call_args + assert call_args[1]["params"]["count"] == 1 + + @pytest.mark.asyncio + async def test_external_url_marked(self, steam_news, mock_client): + """Should mark external URLs.""" + mock_client.get.return_value = { + "appnews": { + "newsitems": [ + { + "title": "External News", + "author": "Author", + "url": "https://external.com/news", + "contents": "Content", + "date": 1702000000, + "is_external_url": True, + } + ] + } + } + + result = await steam_news.get_news_for_app(app_id=440) + + assert "[External]" in result + + @pytest.mark.asyncio + async def test_handles_missing_date(self, steam_news, mock_client): + """Should handle missing date gracefully.""" + mock_client.get.return_value = { + "appnews": { + "newsitems": [ + { + "title": "No Date News", + "author": "Author", + "url": "url", + "contents": "Content", + "date": 0, + } + ] + } + } + + result = await steam_news.get_news_for_app(app_id=440) + + assert "Unknown date" in result + + @pytest.mark.asyncio + async def test_max_length_passed_to_api(self, steam_news, mock_client): + """Should pass max_length parameter to API.""" + mock_client.get.return_value = {"appnews": {"newsitems": []}} + + await steam_news.get_news_for_app(app_id=440, max_length=500) + + call_args = mock_client.get.call_args + assert call_args[1]["params"]["maxlength"] == 500 + + @pytest.mark.asyncio + async def test_zero_max_length_for_full_content(self, steam_news, mock_client): + """Should pass 0 for full content when max_length is 0.""" + mock_client.get.return_value = {"appnews": {"newsitems": []}} + + await steam_news.get_news_for_app(app_id=440, max_length=0) + + call_args = mock_client.get.call_args + assert call_args[1]["params"]["maxlength"] == 0 + + +class TestCleanHtml: + """Tests for _clean_html helper method.""" + + @pytest.fixture + def steam_news_instance(self, mock_client): + """Create ISteamNews instance for testing.""" + return ISteamNews(mock_client) + + def test_removes_html_tags(self, steam_news_instance): + """Should remove HTML tags.""" + text = "

Hello World

" + result = steam_news_instance._clean_html(text) + + assert "

" not in result + assert "" not in result + assert "Hello" in result + assert "World" in result + + def test_decodes_html_entities(self, steam_news_instance): + """Should decode common HTML entities.""" + text = "Hello & World <test> "quoted"" + result = steam_news_instance._clean_html(text) + + assert "&" in result + assert "" in result + assert '"quoted"' in result + + def test_normalizes_whitespace(self, steam_news_instance): + """Should normalize multiple whitespaces.""" + text = "Hello World\n\nTest" + result = steam_news_instance._clean_html(text) + + assert " " not in result + assert result == "Hello World Test" + + def test_handles_empty_string(self, steam_news_instance): + """Should handle empty string.""" + result = steam_news_instance._clean_html("") + + assert result == "" + + def test_handles_none_like_empty(self, steam_news_instance): + """Should handle None-like empty values.""" + result = steam_news_instance._clean_html(None) + + assert result == "" + + def test_replaces_nbsp(self, steam_news_instance): + """Should replace   with space.""" + text = "Hello World" + result = steam_news_instance._clean_html(text) + + assert result == "Hello World" + + +class TestContentTruncation: + """Tests for content truncation in output.""" + + @pytest.mark.asyncio + async def test_long_content_truncated(self, steam_news, mock_client): + """Should truncate content longer than MAX_CONTENT_LENGTH.""" + long_content = "A" * 600 # Longer than MAX_CONTENT_LENGTH (500) + mock_client.get.return_value = { + "appnews": { + "newsitems": [ + { + "title": "Long Content", + "author": "Author", + "url": "url", + "contents": long_content, + "date": 1702000000, + } + ] + } + } + + result = await steam_news.get_news_for_app(app_id=440) + + # Content should be truncated and have ellipsis + assert "..." in result + # Should not contain all 600 As + assert "A" * 600 not in result + + @pytest.mark.asyncio + async def test_short_content_not_truncated(self, steam_news, mock_client): + """Should not truncate content shorter than MAX_CONTENT_LENGTH.""" + short_content = "Short content" + mock_client.get.return_value = { + "appnews": { + "newsitems": [ + { + "title": "Short Content", + "author": "Author", + "url": "url", + "contents": short_content, + "date": 1702000000, + } + ] + } + } + + result = await steam_news.get_news_for_app(app_id=440) + + assert "Short content" in result diff --git a/tests/test_steam_trading.py b/tests/test_steam_trading.py index 2b8549a..0792391 100644 --- a/tests/test_steam_trading.py +++ b/tests/test_steam_trading.py @@ -58,7 +58,7 @@ async def test_returns_incoming_offers(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -100,7 +100,7 @@ async def test_returns_outgoing_offers(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -125,7 +125,7 @@ async def test_no_offers_found(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -139,7 +139,7 @@ async def test_no_offers_found(self, econ_service, mock_client): async def test_invalid_steam_id(self, econ_service): """Should return error for invalid Steam ID.""" with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, side_effect=SteamIDError("Invalid Steam ID"), ): @@ -194,7 +194,7 @@ async def test_returns_trade_history(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -219,7 +219,7 @@ async def test_empty_trade_history(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -298,7 +298,7 @@ async def test_eligible_user(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -321,7 +321,7 @@ async def test_ineligible_user_with_reason(self, econ_service, mock_client): } with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): @@ -339,7 +339,7 @@ async def test_empty_response_handled(self, econ_service, mock_client): mock_client.get.return_value = {"response": {}} with patch( - "steam_mcp.endpoints.steam_trading.normalize_steam_id", + "steam_mcp.endpoints.base.normalize_steam_id", new_callable=AsyncMock, return_value="76561198000000001", ): diff --git a/tests/test_steam_user.py b/tests/test_steam_user.py new file mode 100644 index 0000000..1f88067 --- /dev/null +++ b/tests/test_steam_user.py @@ -0,0 +1,453 @@ +"""Tests for ISteamUser endpoints.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from steam_mcp.client import SteamAPIError +from steam_mcp.endpoints.steam_user import ISteamUser +from steam_mcp.utils.steam_id import SteamIDError + + +@pytest.fixture +def mock_client(): + """Create mock Steam client.""" + client = MagicMock() + client.owner_steam_id = None + client.get = AsyncMock() + client.get_player_summaries = AsyncMock() + client.resolve_vanity_url = AsyncMock() + return client + + +@pytest.fixture +def steam_user(mock_client): + """Create ISteamUser instance with mock client.""" + return ISteamUser(mock_client) + + +class TestGetMySteamId: + """Tests for get_my_steam_id endpoint.""" + + @pytest.mark.asyncio + async def test_no_owner_configured(self, steam_user): + """Should return instructions when no owner ID is set.""" + result = await steam_user.get_my_steam_id() + + assert "No owner Steam ID configured" in result + assert "STEAM_USER_ID" in result + + @pytest.mark.asyncio + async def test_owner_configured_with_profile(self, steam_user, mock_client): + """Should return owner info when ID is configured.""" + mock_client.owner_steam_id = "76561198000000001" + mock_client.get_player_summaries.return_value = [ + { + "steamid": "76561198000000001", + "personaname": "TestUser", + "profileurl": "https://steamcommunity.com/profiles/76561198000000001", + } + ] + + result = await steam_user.get_my_steam_id() + + assert "Owner Steam ID configured" in result + assert "TestUser" in result + assert "76561198000000001" in result + + @pytest.mark.asyncio + async def test_owner_configured_profile_fetch_fails(self, steam_user, mock_client): + """Should return basic info if profile fetch fails.""" + mock_client.owner_steam_id = "76561198000000001" + mock_client.get_player_summaries.return_value = [] + + result = await steam_user.get_my_steam_id() + + assert "76561198000000001" in result + assert "Could not fetch profile details" in result + + +class TestGetPlayerSummary: + """Tests for get_player_summary endpoint.""" + + @pytest.mark.asyncio + async def test_valid_steam_id(self, steam_user, mock_client): + """Should return player summary for valid ID.""" + mock_client.get_player_summaries.return_value = [ + { + "steamid": "76561198000000001", + "personaname": "TestPlayer", + "profileurl": "https://steamcommunity.com/profiles/76561198000000001", + "communityvisibilitystate": 3, + "personastate": 1, + } + ] + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_player_summary(steam_id="76561198000000001") + + assert "TestPlayer" in result + assert "76561198000000001" in result + assert "Online" in result + + @pytest.mark.asyncio + async def test_player_not_found(self, steam_user, mock_client): + """Should return error when player not found.""" + mock_client.get_player_summaries.return_value = [] + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_player_summary(steam_id="76561198000000001") + + assert "Player not found" in result + + @pytest.mark.asyncio + async def test_json_format(self, steam_user, mock_client): + """Should return JSON when format is json.""" + mock_client.get_player_summaries.return_value = [ + { + "steamid": "76561198000000001", + "personaname": "TestPlayer", + "profileurl": "https://steamcommunity.com/profiles/76561198000000001", + "communityvisibilitystate": 3, + "personastate": 1, + } + ] + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_player_summary( + steam_id="76561198000000001", format="json" + ) + + assert '"steam_id"' in result + assert '"persona_name"' in result + + @pytest.mark.asyncio + async def test_invalid_steam_id(self, steam_user): + """Should return error for invalid Steam ID.""" + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + side_effect=SteamIDError("Invalid Steam ID"), + ): + result = await steam_user.get_player_summary(steam_id="invalid") + + assert "Error" in result + + +class TestGetPlayerSummaries: + """Tests for get_player_summaries endpoint.""" + + @pytest.mark.asyncio + async def test_empty_list(self, steam_user): + """Should return error for empty list.""" + result = await steam_user.get_player_summaries(steam_ids=[]) + + assert "Error" in result + assert "No Steam IDs provided" in result + + @pytest.mark.asyncio + async def test_exceeds_max_limit(self, steam_user): + """Should return error when exceeding 100 IDs.""" + result = await steam_user.get_player_summaries(steam_ids=["id"] * 101) + + assert "Error" in result + assert "Maximum 100" in result + + @pytest.mark.asyncio + async def test_multiple_players(self, steam_user, mock_client): + """Should return summaries for multiple players.""" + mock_client.get_player_summaries.return_value = [ + { + "steamid": "76561198000000001", + "personaname": "Player1", + "profileurl": "url1", + "communityvisibilitystate": 3, + "personastate": 1, + }, + { + "steamid": "76561198000000002", + "personaname": "Player2", + "profileurl": "url2", + "communityvisibilitystate": 3, + "personastate": 0, + }, + ] + + with patch( + "steam_mcp.endpoints.steam_user.normalize_steam_id", + new_callable=AsyncMock, + side_effect=["76561198000000001", "76561198000000002"], + ): + result = await steam_user.get_player_summaries( + steam_ids=["76561198000000001", "76561198000000002"] + ) + + assert "Found 2 player(s)" in result + assert "Player1" in result + assert "Player2" in result + + @pytest.mark.asyncio + async def test_partial_resolution_failure(self, steam_user, mock_client): + """Should continue with valid IDs when some fail.""" + mock_client.get_player_summaries.return_value = [ + { + "steamid": "76561198000000001", + "personaname": "ValidPlayer", + "profileurl": "url", + "communityvisibilitystate": 3, + "personastate": 1, + } + ] + + with patch( + "steam_mcp.endpoints.steam_user.normalize_steam_id", + new_callable=AsyncMock, + side_effect=["76561198000000001", SteamIDError("Invalid")], + ): + result = await steam_user.get_player_summaries( + steam_ids=["76561198000000001", "invalid_id"] + ) + + assert "Found 1 player(s)" in result + assert "ValidPlayer" in result + assert "Errors" in result + + +class TestResolveVanityUrl: + """Tests for resolve_vanity_url endpoint.""" + + @pytest.mark.asyncio + async def test_valid_vanity_name(self, steam_user, mock_client): + """Should resolve vanity name to Steam ID.""" + mock_client.resolve_vanity_url.return_value = "76561198000000001" + + result = await steam_user.resolve_vanity_url(vanity_name="testuser") + + assert "76561198000000001" in result + assert "testuser" in result + mock_client.resolve_vanity_url.assert_called_once_with("testuser") + + @pytest.mark.asyncio + async def test_full_url_input(self, steam_user, mock_client): + """Should extract vanity name from full URL.""" + mock_client.resolve_vanity_url.return_value = "76561198000000001" + + await steam_user.resolve_vanity_url( + vanity_name="https://steamcommunity.com/id/testuser/" + ) + + mock_client.resolve_vanity_url.assert_called_once_with("testuser") + + @pytest.mark.asyncio + async def test_vanity_not_found(self, steam_user, mock_client): + """Should return error when vanity not found.""" + mock_client.resolve_vanity_url.return_value = None + + result = await steam_user.resolve_vanity_url(vanity_name="nonexistent") + + assert "Could not resolve" in result + + +class TestGetFriendList: + """Tests for get_friend_list endpoint.""" + + @pytest.mark.asyncio + async def test_public_profile_with_friends(self, steam_user, mock_client): + """Should return friend list for public profile.""" + mock_client.get.return_value = { + "friendslist": { + "friends": [ + {"steamid": "76561198000000002", "friend_since": 1600000000}, + {"steamid": "76561198000000003", "friend_since": 1700000000}, + ] + } + } + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_friend_list(steam_id="76561198000000001") + + assert "2 friends" in result + assert "76561198000000002" in result + assert "76561198000000003" in result + + @pytest.mark.asyncio + async def test_private_profile(self, steam_user, mock_client): + """Should return error for private profile.""" + mock_client.get.side_effect = SteamAPIError("Unauthorized", status_code=401) + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_friend_list(steam_id="76561198000000001") + + assert "private" in result.lower() + + @pytest.mark.asyncio + async def test_no_friends(self, steam_user, mock_client): + """Should handle empty friend list.""" + mock_client.get.return_value = {"friendslist": {"friends": []}} + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_friend_list(steam_id="76561198000000001") + + assert "No friends found" in result + + @pytest.mark.asyncio + async def test_json_format(self, steam_user, mock_client): + """Should return JSON when format is json.""" + mock_client.get.return_value = { + "friendslist": { + "friends": [ + {"steamid": "76561198000000002", "friend_since": 1600000000} + ] + } + } + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_friend_list( + steam_id="76561198000000001", format="json" + ) + + assert '"steam_id"' in result + assert '"total_friends"' in result + + +class TestGetPlayerBans: + """Tests for get_player_bans endpoint.""" + + @pytest.mark.asyncio + async def test_empty_list(self, steam_user): + """Should return error for empty list.""" + result = await steam_user.get_player_bans(steam_ids=[]) + + assert "Error" in result + assert "No Steam IDs provided" in result + + @pytest.mark.asyncio + async def test_exceeds_max_limit(self, steam_user): + """Should return error when exceeding 100 IDs.""" + result = await steam_user.get_player_bans(steam_ids=["id"] * 101) + + assert "Error" in result + assert "Maximum 100" in result + + @pytest.mark.asyncio + async def test_clean_player(self, steam_user, mock_client): + """Should show clean status for unbanned player.""" + mock_client.get.return_value = { + "players": [ + { + "SteamId": "76561198000000001", + "VACBanned": False, + "NumberOfVACBans": 0, + "NumberOfGameBans": 0, + "CommunityBanned": False, + "EconomyBan": "none", + } + ] + } + + with patch( + "steam_mcp.endpoints.steam_user.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_player_bans(steam_ids=["76561198000000001"]) + + assert "VAC Banned: No" in result + assert "Game Bans: None" in result + assert "Community Banned: No" in result + + @pytest.mark.asyncio + async def test_banned_player(self, steam_user, mock_client): + """Should show ban details for banned player.""" + mock_client.get.return_value = { + "players": [ + { + "SteamId": "76561198000000001", + "VACBanned": True, + "NumberOfVACBans": 2, + "DaysSinceLastBan": 365, + "NumberOfGameBans": 1, + "CommunityBanned": True, + "EconomyBan": "banned", + } + ] + } + + with patch( + "steam_mcp.endpoints.steam_user.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await steam_user.get_player_bans(steam_ids=["76561198000000001"]) + + assert "VAC Banned: Yes" in result + assert "2 ban(s)" in result + assert "365 days ago" in result + assert "Game Bans: 1" in result + assert "Community Banned: Yes" in result + + @pytest.mark.asyncio + async def test_multiple_players(self, steam_user, mock_client): + """Should return ban info for multiple players.""" + mock_client.get.return_value = { + "players": [ + { + "SteamId": "76561198000000001", + "VACBanned": False, + "NumberOfVACBans": 0, + "NumberOfGameBans": 0, + "CommunityBanned": False, + "EconomyBan": "none", + }, + { + "SteamId": "76561198000000002", + "VACBanned": True, + "NumberOfVACBans": 1, + "DaysSinceLastBan": 100, + "NumberOfGameBans": 0, + "CommunityBanned": False, + "EconomyBan": "none", + }, + ] + } + + with patch( + "steam_mcp.endpoints.steam_user.normalize_steam_id", + new_callable=AsyncMock, + side_effect=["76561198000000001", "76561198000000002"], + ): + result = await steam_user.get_player_bans( + steam_ids=["76561198000000001", "76561198000000002"] + ) + + assert "2 player(s)" in result + assert "76561198000000001" in result + assert "76561198000000002" in result diff --git a/tests/test_user_stats.py b/tests/test_user_stats.py new file mode 100644 index 0000000..54d04b3 --- /dev/null +++ b/tests/test_user_stats.py @@ -0,0 +1,421 @@ +"""Tests for ISteamUserStats endpoints.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from steam_mcp.endpoints.user_stats import ISteamUserStats +from steam_mcp.utils.steam_id import SteamIDError + + +@pytest.fixture +def mock_client(): + """Create mock Steam client.""" + client = MagicMock() + client.owner_steam_id = None + client.get = AsyncMock() + return client + + +@pytest.fixture +def user_stats(mock_client): + """Create ISteamUserStats instance with mock client.""" + return ISteamUserStats(mock_client) + + +class TestGetPlayerAchievements: + """Tests for get_player_achievements endpoint.""" + + @pytest.mark.asyncio + async def test_returns_achievements(self, user_stats, mock_client): + """Should return achievements for a player.""" + mock_client.get.return_value = { + "playerstats": { + "success": True, + "steamID": "76561198000000001", + "gameName": "Test Game", + "achievements": [ + {"apiname": "ACH_1", "name": "First Achievement", "achieved": 1, "unlocktime": 1700000000}, + {"apiname": "ACH_2", "name": "Second Achievement", "achieved": 0}, + ], + } + } + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_player_achievements( + steam_id="76561198000000001", app_id=440 + ) + + assert "Test Game" in result + assert "1/2" in result + assert "First Achievement" in result + assert "✓" in result # Unlocked marker + + @pytest.mark.asyncio + async def test_json_format(self, user_stats, mock_client): + """Should return JSON when format is json.""" + mock_client.get.return_value = { + "playerstats": { + "success": True, + "steamID": "76561198000000001", + "gameName": "Test Game", + "achievements": [ + {"apiname": "ACH_1", "name": "Test", "achieved": 1}, + ], + } + } + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_player_achievements( + steam_id="76561198000000001", app_id=440, format="json" + ) + + assert '"steam_id"' in result + assert '"game_name"' in result + assert '"achievements"' in result + + @pytest.mark.asyncio + async def test_private_profile_error(self, user_stats, mock_client): + """Should return error for private profile.""" + mock_client.get.side_effect = Exception("Profile is not public") + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_player_achievements( + steam_id="76561198000000001", app_id=440 + ) + + assert "private" in result.lower() + + @pytest.mark.asyncio + async def test_no_achievements(self, user_stats, mock_client): + """Should handle game with no achievements.""" + mock_client.get.return_value = { + "playerstats": { + "success": True, + "steamID": "76561198000000001", + "gameName": "No Achievements Game", + "achievements": [], + } + } + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_player_achievements( + steam_id="76561198000000001", app_id=440 + ) + + assert "No achievements found" in result + + @pytest.mark.asyncio + async def test_invalid_steam_id(self, user_stats): + """Should return error for invalid Steam ID.""" + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + side_effect=SteamIDError("Invalid Steam ID"), + ): + result = await user_stats.get_player_achievements( + steam_id="invalid", app_id=440 + ) + + assert "Error" in result + + +class TestGetGameSchema: + """Tests for get_game_schema endpoint.""" + + @pytest.mark.asyncio + async def test_returns_schema(self, user_stats, mock_client): + """Should return game schema.""" + mock_client.get.return_value = { + "game": { + "gameName": "Test Game", + "availableGameStats": { + "achievements": [ + {"name": "ACH_1", "displayName": "First", "hidden": 0}, + {"name": "ACH_2", "displayName": "Second", "hidden": 1}, + ], + "stats": [ + {"name": "kills", "displayName": "Total Kills"}, + ], + }, + } + } + + result = await user_stats.get_game_schema(app_id=440) + + assert "Test Game" in result + assert "2 total" in result + assert "1 hidden" in result + assert "First" in result + assert "[HIDDEN]" in result + + @pytest.mark.asyncio + async def test_no_schema_found(self, user_stats, mock_client): + """Should handle missing schema.""" + mock_client.get.return_value = {"game": {}} + + result = await user_stats.get_game_schema(app_id=99999) + + assert "No schema found" in result + + @pytest.mark.asyncio + async def test_no_achievements(self, user_stats, mock_client): + """Should handle game with no achievements.""" + mock_client.get.return_value = { + "game": { + "gameName": "No Achievements", + "availableGameStats": { + "achievements": [], + "stats": [], + }, + } + } + + result = await user_stats.get_game_schema(app_id=440) + + assert "No achievements for this game" in result + + +class TestGetGlobalAchievementPercentages: + """Tests for get_global_achievement_percentages endpoint.""" + + @pytest.mark.asyncio + async def test_returns_percentages(self, user_stats, mock_client): + """Should return achievement percentages sorted by rarity.""" + mock_client.get.return_value = { + "achievementpercentages": { + "achievements": [ + {"name": "Common", "percent": 80.0}, + {"name": "Ultra Rare", "percent": 0.5}, + {"name": "Rare", "percent": 5.0}, + ] + } + } + + result = await user_stats.get_global_achievement_percentages(app_id=440) + + assert "Ultra Rare" in result + assert "0.5%" in result + assert "Rarest achievements" in result + + @pytest.mark.asyncio + async def test_rarity_indicators(self, user_stats, mock_client): + """Should show rarity indicators.""" + mock_client.get.return_value = { + "achievementpercentages": { + "achievements": [ + {"name": "Ultra", "percent": 0.5}, + {"name": "Very Rare", "percent": 3.0}, + {"name": "Rare", "percent": 8.0}, + ] + } + } + + result = await user_stats.get_global_achievement_percentages(app_id=440) + + assert "🏆" in result # Ultra rare + assert "💎" in result # Very rare + assert "⭐" in result # Rare + + @pytest.mark.asyncio + async def test_no_data(self, user_stats, mock_client): + """Should handle no achievement data.""" + mock_client.get.return_value = { + "achievementpercentages": {"achievements": []} + } + + result = await user_stats.get_global_achievement_percentages(app_id=99999) + + assert "No global achievement data" in result + + +class TestGetUserStatsForGame: + """Tests for get_user_stats_for_game endpoint.""" + + @pytest.mark.asyncio + async def test_returns_stats(self, user_stats, mock_client): + """Should return player stats.""" + mock_client.get.return_value = { + "playerstats": { + "steamID": "76561198000000001", + "gameName": "Test Game", + "stats": [ + {"name": "total_kills", "value": 1234567}, + {"name": "accuracy", "value": 0.75}, + ], + "achievements": [ + {"name": "ACH_1", "achieved": 1}, + {"name": "ACH_2", "achieved": 0}, + ], + } + } + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_user_stats_for_game( + steam_id="76561198000000001", app_id=440 + ) + + assert "Test Game" in result + assert "total_kills" in result + assert "1,234,567" in result + assert "1/2 unlocked" in result + + @pytest.mark.asyncio + async def test_private_profile(self, user_stats, mock_client): + """Should handle private profile.""" + mock_client.get.side_effect = Exception("private profile") + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_user_stats_for_game( + steam_id="76561198000000001", app_id=440 + ) + + assert "private" in result.lower() + + @pytest.mark.asyncio + async def test_no_stats(self, user_stats, mock_client): + """Should handle no stats found.""" + mock_client.get.return_value = {"playerstats": {}} + + with patch( + "steam_mcp.endpoints.base.normalize_steam_id", + new_callable=AsyncMock, + return_value="76561198000000001", + ): + result = await user_stats.get_user_stats_for_game( + steam_id="76561198000000001", app_id=440 + ) + + assert "No stats found" in result + + +class TestGetCurrentPlayers: + """Tests for get_current_players endpoint.""" + + @pytest.mark.asyncio + async def test_returns_player_count(self, user_stats, mock_client): + """Should return current player count.""" + mock_client.get.return_value = { + "response": {"result": 1, "player_count": 50000} + } + + result = await user_stats.get_current_players(app_id=440) + + assert "50,000" in result + assert "440" in result + + @pytest.mark.asyncio + async def test_popularity_indicators(self, user_stats, mock_client): + """Should show popularity indicators based on count.""" + # Extremely popular + mock_client.get.return_value = { + "response": {"result": 1, "player_count": 150000} + } + result = await user_stats.get_current_players(app_id=440) + assert "Extremely Popular" in result + + # Very active + mock_client.get.return_value = { + "response": {"result": 1, "player_count": 15000} + } + result = await user_stats.get_current_players(app_id=440) + assert "Very Active" in result + + # Low + mock_client.get.return_value = { + "response": {"result": 1, "player_count": 50} + } + result = await user_stats.get_current_players(app_id=440) + assert "Low" in result + + @pytest.mark.asyncio + async def test_invalid_app(self, user_stats, mock_client): + """Should handle invalid app ID.""" + mock_client.get.return_value = {"response": {"result": 0}} + + result = await user_stats.get_current_players(app_id=99999) + + assert "Could not get player count" in result + + +class TestGetGlobalStatsForGame: + """Tests for get_global_stats_for_game endpoint.""" + + @pytest.mark.asyncio + async def test_returns_global_stats(self, user_stats, mock_client): + """Should return global stats.""" + mock_client.get.return_value = { + "response": { + "result": 1, + "globalstats": { + "total_kills": {"total": 1000000000}, + "total_deaths": {"total": 500000000}, + }, + } + } + + result = await user_stats.get_global_stats_for_game( + app_id=440, stat_names=["total_kills", "total_deaths"] + ) + + assert "total_kills" in result + assert "1,000,000,000" in result + assert "total_deaths" in result + + @pytest.mark.asyncio + async def test_empty_stat_names(self, user_stats): + """Should return error for empty stat names.""" + result = await user_stats.get_global_stats_for_game( + app_id=440, stat_names=[] + ) + + assert "Error" in result + assert "at least one stat name" in result + + @pytest.mark.asyncio + async def test_invalid_stats(self, user_stats, mock_client): + """Should handle invalid stat names.""" + mock_client.get.return_value = {"response": {"result": 0}} + + result = await user_stats.get_global_stats_for_game( + app_id=440, stat_names=["invalid_stat"] + ) + + assert "Could not get global stats" in result + + @pytest.mark.asyncio + async def test_no_stats_returned(self, user_stats, mock_client): + """Should handle empty globalstats response.""" + mock_client.get.return_value = { + "response": {"result": 1, "globalstats": {}} + } + + result = await user_stats.get_global_stats_for_game( + app_id=440, stat_names=["some_stat"] + ) + + assert "No global stats returned" in result