diff --git a/app/players/parsers/player_career_parser.py b/app/players/parsers/player_career_parser.py index 12fc4a5d..75ea85c3 100644 --- a/app/players/parsers/player_career_parser.py +++ b/app/players/parsers/player_career_parser.py @@ -54,7 +54,13 @@ def filter_request_using_query(self, **kwargs) -> dict: if kwargs.get("summary"): return self.data.get("summary") - return self._filter_stats(**kwargs) if kwargs.get("stats") else self.data + if kwargs.get("stats"): + return self._filter_stats(**kwargs) + + return { + "summary": self.data["summary"], + "stats": self._filter_all_stats_data(**kwargs), + } def _filter_stats(self, **kwargs) -> dict: filtered_data = self.data["stats"] or {} @@ -72,6 +78,7 @@ def _filter_stats(self, **kwargs) -> dict: platform = possible_platforms[0] else: return {} + filtered_data = filtered_data.get(platform) or {} if not filtered_data: return {} @@ -89,6 +96,44 @@ def _filter_stats(self, **kwargs) -> dict: if not hero_filter or hero_filter == hero_key } + def _filter_all_stats_data(self, **kwargs) -> dict: + stats_data = self.data["stats"] or {} + platform_filter = kwargs.get("platform") + gamemode_filter = kwargs.get("gamemode") + + # Return early if no platform or gamemode is specified + if not platform_filter and not gamemode_filter: + return stats_data + + # Extract platform-specific data and update stats data + # to contain only platform-specific information + if platform_filter: + stats_data = { + platform_key: ( + platform_data if platform_key == platform_filter else None + ) + for platform_key, platform_data in stats_data.items() + } + + # Extract gamemode-specific data within the remaining platforms + # and update stats data to contain only gamemode-specific information + if gamemode_filter: + stats_data = { + platform_key: ( + { + gamemode_key: ( + gamemode_data if gamemode_key == gamemode_filter else None + ) + for gamemode_key, gamemode_data in platform_data.items() + } + if platform_data is not None + else None + ) + for platform_key, platform_data in stats_data.items() + } + + return stats_data + def parse_data(self) -> dict: # We must check if we have the expected section for profile. If not, # it means the player doesn't exist or hasn't been found. diff --git a/app/players/parsers/player_search_parser.py b/app/players/parsers/player_search_parser.py index 20ffdd3b..22c86584 100644 --- a/app/players/parsers/player_search_parser.py +++ b/app/players/parsers/player_search_parser.py @@ -23,7 +23,8 @@ def __init__(self, **kwargs): def get_blizzard_url(self, **kwargs) -> str: """URL used when requesting data to Blizzard.""" - return f"{super().get_blizzard_url(**kwargs)}/{kwargs.get('name')}/" + search_name = kwargs.get("name").split("#")[0] + return f"{super().get_blizzard_url(**kwargs)}/{search_name}/" def parse_data(self) -> dict: # Transform into PlayerSearchResult format diff --git a/app/players/parsers/search_data_parser.py b/app/players/parsers/search_data_parser.py index 54576069..536794d9 100644 --- a/app/players/parsers/search_data_parser.py +++ b/app/players/parsers/search_data_parser.py @@ -48,8 +48,8 @@ def parse_data(self) -> dict: return {self.data_type: data_value} def get_blizzard_url(self, **kwargs) -> str: - player_battletag = kwargs.get("player_id").replace("-", "#") - return f"{super().get_blizzard_url(**kwargs)}/{player_battletag}" + player_name = kwargs.get("player_id").split("-")[0] + return f"{super().get_blizzard_url(**kwargs)}/{player_name}/" def retrieve_data_value(self, player_data: dict) -> str | None: # If the player doesn't have any related data, directly return nothing here diff --git a/app/players/router.py b/app/players/router.py index 768feda6..6d6a1818 100644 --- a/app/players/router.py +++ b/app/players/router.py @@ -260,7 +260,21 @@ async def get_player_stats( async def get_player_career( request: Request, commons: CommonsPlayerDep, + gamemode: PlayerGamemode = Query( + None, + title="Gamemode", + description="Filter on a specific gamemode. All gamemodes are displayed by default.", + examples=["competitive"], + ), + platform: PlayerPlatform = Query( + None, + title="Platform", + description="Filter on a specific platform. All platforms are displayed by default.", + examples=["pc"], + ), ) -> Player: return await GetPlayerCareerController(request).process_request( player_id=commons.get("player_id"), + gamemode=gamemode, + platform=platform, ) diff --git a/pyproject.toml b/pyproject.toml index e66d1678..e31e3b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "overfast-api" -version = "3.0.0" +version = "3.1.0" description = "Overwatch API giving data about heroes, maps, and players statistics." license = {file = "LICENSE"} authors = [ diff --git a/tests/players/parsers/test_player_career_parser.py b/tests/players/parsers/test_player_career_parser.py index 8d710494..00acbab6 100644 --- a/tests/players/parsers/test_player_career_parser.py +++ b/tests/players/parsers/test_player_career_parser.py @@ -1,58 +1,126 @@ +import re +from collections.abc import Callable from unittest.mock import Mock, patch import pytest from fastapi import status -from app.exceptions import ParserBlizzardError +from app.exceptions import ParserBlizzardError, ParserParsingError +from app.players.enums import PlayerGamemode, PlayerPlatform from app.players.helpers import players_ids -from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser +from app.players.parsers.player_career_parser import PlayerCareerParser @pytest.mark.parametrize( - ("player_career_stats_parser", "player_html_data", "player_career_json_data"), + ("player_career_parser", "player_html_data", "player_json_data", "kwargs_filter"), [ - (player_id, player_id, player_id) + (player_id, player_id, player_id, kwargs_filter) for player_id in players_ids + for kwargs_filter in ({}, {"summary": True}, {"stats": True}) if player_id != "Unknown-1234" ], - indirect=[ - "player_career_stats_parser", - "player_html_data", - "player_career_json_data", - ], + indirect=["player_career_parser", "player_html_data", "player_json_data"], ) @pytest.mark.asyncio async def test_player_page_parsing_with_filters( - player_career_stats_parser: PlayerCareerStatsParser, + player_career_parser: PlayerCareerParser, player_html_data: str, - player_career_json_data: dict, + player_json_data: dict, + kwargs_filter: dict, player_search_response_mock: Mock, + search_data_func: Callable[[str, str], str | None], ): - with patch( - "httpx.AsyncClient.get", - side_effect=[ - # Players search call first - player_search_response_mock, - # Player profile page - Mock(status_code=status.HTTP_200_OK, text=player_html_data), - ], + with ( + patch( + "httpx.AsyncClient.get", + side_effect=[ + # Players search call first + player_search_response_mock, + # Player profile page + Mock(status_code=status.HTTP_200_OK, text=player_html_data), + ], + ), + patch( + "app.cache_manager.CacheManager.get_search_data_cache", + side_effect=search_data_func, + ), ): - await player_career_stats_parser.parse() + await player_career_parser.parse() # Just check that the parsing is working properly - player_career_stats_parser.filter_request_using_query() + player_career_parser.filter_request_using_query(**kwargs_filter) - assert player_career_stats_parser.data == player_career_json_data + assert player_career_parser.data == player_json_data @pytest.mark.parametrize( - ("player_career_stats_parser"), + ("player_career_parser", "player_html_data", "gamemode", "platform"), + [ + ("TeKrop-2217", "TeKrop-2217", gamemode, platform) + for gamemode in (None, *PlayerGamemode) + for platform in (None, *PlayerPlatform) + ], + indirect=["player_career_parser", "player_html_data"], +) +@pytest.mark.asyncio +async def test_filter_all_stats_data( + player_career_parser: PlayerCareerParser, + player_html_data: str, + gamemode: PlayerGamemode | None, + platform: PlayerPlatform | None, + player_search_response_mock: Mock, + search_data_func: Callable[[str, str], str | None], +): + with ( + patch( + "httpx.AsyncClient.get", + side_effect=[ + # Players search call first + player_search_response_mock, + # Player profile page + Mock(status_code=status.HTTP_200_OK, text=player_html_data), + ], + ), + patch( + "app.cache_manager.CacheManager.get_search_data_cache", + side_effect=search_data_func, + ), + ): + await player_career_parser.parse() + + # Just check that the parsing is working properly + filtered_data = player_career_parser._filter_all_stats_data( + platform=platform, gamemode=gamemode + ) + + if platform: + assert all( + platform_data is None + for platform_key, platform_data in filtered_data.items() + if filtered_data is not None and platform_key != platform + ) + + if gamemode: + assert all( + gamemode_data is None + for platform_key, platform_data in filtered_data.items() + if platform_data is not None + for gamemode_key, gamemode_data in platform_data.items() + if gamemode_key != gamemode + ) + + if not platform and not gamemode: + assert filtered_data == player_career_parser.data["stats"] + + +@pytest.mark.parametrize( + ("player_career_parser"), [("Unknown-1234")], indirect=True, ) @pytest.mark.asyncio -async def test_unknown_player_parser_blizzard_error( - player_career_stats_parser: PlayerCareerStatsParser, +async def test_unknown_player_career_parser_blizzard_error( + player_career_parser: PlayerCareerParser, player_search_response_mock: Mock, ): with ( @@ -62,4 +130,109 @@ async def test_unknown_player_parser_blizzard_error( return_value=player_search_response_mock, ), ): - await player_career_stats_parser.parse() + await player_career_parser.parse() + + +@pytest.mark.parametrize( + ("player_career_parser", "player_html_data"), + [("TeKrop-2217", "TeKrop-2217")], + indirect=["player_career_parser", "player_html_data"], +) +@pytest.mark.asyncio +async def test_player_career_parser_parsing_error_attribute_error( + player_career_parser: PlayerCareerParser, + player_html_data: str, + player_search_response_mock: Mock, +): + player_attr_error = player_html_data.replace( + 'class="Profile-player--summaryWrapper"', + 'class="blabla"', + ) + + with ( + patch( + "httpx.AsyncClient.get", + side_effect=[ + # Players search call first + player_search_response_mock, + # Player profile page + Mock(status_code=status.HTTP_200_OK, text=player_attr_error), + ], + ), + pytest.raises(ParserParsingError) as error, + ): + await player_career_parser.parse() + + assert ( + error.value.message + == "AttributeError(\"'NoneType' object has no attribute 'find'\")" + ) + + +@pytest.mark.parametrize( + ("player_career_parser", "player_html_data"), + [("TeKrop-2217", "TeKrop-2217")], + indirect=["player_career_parser", "player_html_data"], +) +@pytest.mark.asyncio +async def test_player_career_parser_parsing_error_key_error( + player_career_parser: PlayerCareerParser, + player_html_data: str, + player_search_response_mock: Mock, +): + player_key_error = re.sub( + 'class="Profile-playerSummary--endorsement" src="[^"]*"', + 'class="Profile-playerSummary--endorsement"', + player_html_data, + ) + + with ( + patch( + "httpx.AsyncClient.get", + side_effect=[ + # Players search call first + player_search_response_mock, + # Player profile page + Mock(status_code=status.HTTP_200_OK, text=player_key_error), + ], + ), + pytest.raises(ParserParsingError) as error, + ): + await player_career_parser.parse() + + assert error.value.message == "KeyError('src')" + + +@pytest.mark.parametrize( + ("player_career_parser", "player_html_data"), + [("TeKrop-2217", "TeKrop-2217")], + indirect=["player_career_parser", "player_html_data"], +) +@pytest.mark.asyncio +async def test_player_career_parser_parsing_error_type_error( + player_career_parser: PlayerCareerParser, + player_html_data: str, + player_search_response_mock: Mock, +): + player_type_error = player_html_data.replace( + 'class="Profile-playerSummary--endorsement"', + "", + ) + + with ( + patch( + "httpx.AsyncClient.get", + side_effect=[ + # Players search call first + player_search_response_mock, + # Player profile page + Mock(status_code=status.HTTP_200_OK, text=player_type_error), + ], + ), + pytest.raises(ParserParsingError) as error, + ): + await player_career_parser.parse() + + assert ( + error.value.message == "TypeError(\"'NoneType' object is not subscriptable\")" + ) diff --git a/tests/players/parsers/test_player_career_stats_parser.py b/tests/players/parsers/test_player_career_stats_parser.py new file mode 100644 index 00000000..8d710494 --- /dev/null +++ b/tests/players/parsers/test_player_career_stats_parser.py @@ -0,0 +1,65 @@ +from unittest.mock import Mock, patch + +import pytest +from fastapi import status + +from app.exceptions import ParserBlizzardError +from app.players.helpers import players_ids +from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser + + +@pytest.mark.parametrize( + ("player_career_stats_parser", "player_html_data", "player_career_json_data"), + [ + (player_id, player_id, player_id) + for player_id in players_ids + if player_id != "Unknown-1234" + ], + indirect=[ + "player_career_stats_parser", + "player_html_data", + "player_career_json_data", + ], +) +@pytest.mark.asyncio +async def test_player_page_parsing_with_filters( + player_career_stats_parser: PlayerCareerStatsParser, + player_html_data: str, + player_career_json_data: dict, + player_search_response_mock: Mock, +): + with patch( + "httpx.AsyncClient.get", + side_effect=[ + # Players search call first + player_search_response_mock, + # Player profile page + Mock(status_code=status.HTTP_200_OK, text=player_html_data), + ], + ): + await player_career_stats_parser.parse() + + # Just check that the parsing is working properly + player_career_stats_parser.filter_request_using_query() + + assert player_career_stats_parser.data == player_career_json_data + + +@pytest.mark.parametrize( + ("player_career_stats_parser"), + [("Unknown-1234")], + indirect=True, +) +@pytest.mark.asyncio +async def test_unknown_player_parser_blizzard_error( + player_career_stats_parser: PlayerCareerStatsParser, + player_search_response_mock: Mock, +): + with ( + pytest.raises(ParserBlizzardError), + patch( + "httpx.AsyncClient.get", + return_value=player_search_response_mock, + ), + ): + await player_career_stats_parser.parse() diff --git a/tests/players/parsers/test_player_parser.py b/tests/players/parsers/test_player_parser.py deleted file mode 100644 index 10175c21..00000000 --- a/tests/players/parsers/test_player_parser.py +++ /dev/null @@ -1,177 +0,0 @@ -import re -from collections.abc import Callable -from unittest.mock import Mock, patch - -import pytest -from fastapi import status - -from app.exceptions import ParserBlizzardError, ParserParsingError -from app.players.helpers import players_ids -from app.players.parsers.player_career_parser import PlayerCareerParser - - -@pytest.mark.parametrize( - ("player_career_parser", "player_html_data", "player_json_data", "kwargs_filter"), - [ - (player_id, player_id, player_id, kwargs_filter) - for player_id in players_ids - for kwargs_filter in ({}, {"summary": True}, {"stats": True}) - if player_id != "Unknown-1234" - ], - indirect=["player_career_parser", "player_html_data", "player_json_data"], -) -@pytest.mark.asyncio -async def test_player_page_parsing_with_filters( - player_career_parser: PlayerCareerParser, - player_html_data: str, - player_json_data: dict, - kwargs_filter: dict, - player_search_response_mock: Mock, - search_data_func: Callable[[str, str], str | None], -): - with ( - patch( - "httpx.AsyncClient.get", - side_effect=[ - # Players search call first - player_search_response_mock, - # Player profile page - Mock(status_code=status.HTTP_200_OK, text=player_html_data), - ], - ), - patch( - "app.cache_manager.CacheManager.get_search_data_cache", - side_effect=search_data_func, - ), - ): - await player_career_parser.parse() - - # Just check that the parsing is working properly - player_career_parser.filter_request_using_query(**kwargs_filter) - - assert player_career_parser.data == player_json_data - - -@pytest.mark.parametrize( - ("player_career_parser"), - [("Unknown-1234")], - indirect=True, -) -@pytest.mark.asyncio -async def test_unknown_player_career_parser_blizzard_error( - player_career_parser: PlayerCareerParser, - player_search_response_mock: Mock, -): - with ( - pytest.raises(ParserBlizzardError), - patch( - "httpx.AsyncClient.get", - return_value=player_search_response_mock, - ), - ): - await player_career_parser.parse() - - -@pytest.mark.parametrize( - ("player_career_parser", "player_html_data"), - [("TeKrop-2217", "TeKrop-2217")], - indirect=["player_career_parser", "player_html_data"], -) -@pytest.mark.asyncio -async def test_player_career_parser_parsing_error_attribute_error( - player_career_parser: PlayerCareerParser, - player_html_data: str, - player_search_response_mock: Mock, -): - player_attr_error = player_html_data.replace( - 'class="Profile-player--summaryWrapper"', - 'class="blabla"', - ) - - with ( - patch( - "httpx.AsyncClient.get", - side_effect=[ - # Players search call first - player_search_response_mock, - # Player profile page - Mock(status_code=status.HTTP_200_OK, text=player_attr_error), - ], - ), - pytest.raises(ParserParsingError) as error, - ): - await player_career_parser.parse() - - assert ( - error.value.message - == "AttributeError(\"'NoneType' object has no attribute 'find'\")" - ) - - -@pytest.mark.parametrize( - ("player_career_parser", "player_html_data"), - [("TeKrop-2217", "TeKrop-2217")], - indirect=["player_career_parser", "player_html_data"], -) -@pytest.mark.asyncio -async def test_player_career_parser_parsing_error_key_error( - player_career_parser: PlayerCareerParser, - player_html_data: str, - player_search_response_mock: Mock, -): - player_key_error = re.sub( - 'class="Profile-playerSummary--endorsement" src="[^"]*"', - 'class="Profile-playerSummary--endorsement"', - player_html_data, - ) - - with ( - patch( - "httpx.AsyncClient.get", - side_effect=[ - # Players search call first - player_search_response_mock, - # Player profile page - Mock(status_code=status.HTTP_200_OK, text=player_key_error), - ], - ), - pytest.raises(ParserParsingError) as error, - ): - await player_career_parser.parse() - - assert error.value.message == "KeyError('src')" - - -@pytest.mark.parametrize( - ("player_career_parser", "player_html_data"), - [("TeKrop-2217", "TeKrop-2217")], - indirect=["player_career_parser", "player_html_data"], -) -@pytest.mark.asyncio -async def test_player_career_parser_parsing_error_type_error( - player_career_parser: PlayerCareerParser, - player_html_data: str, - player_search_response_mock: Mock, -): - player_type_error = player_html_data.replace( - 'class="Profile-playerSummary--endorsement"', - "", - ) - - with ( - patch( - "httpx.AsyncClient.get", - side_effect=[ - # Players search call first - player_search_response_mock, - # Player profile page - Mock(status_code=status.HTTP_200_OK, text=player_type_error), - ], - ), - pytest.raises(ParserParsingError) as error, - ): - await player_career_parser.parse() - - assert ( - error.value.message == "TypeError(\"'NoneType' object is not subscriptable\")" - )