diff --git a/src/http_client.py b/src/http_client.py index 256cf0b..dd294c8 100755 --- a/src/http_client.py +++ b/src/http_client.py @@ -19,10 +19,6 @@ DEFAULT_TIMEOUT = 30 -def paginate_url(url, limit, offset=0): - return url + "&limit={limit}&offset={offset}".format(limit=limit, offset=offset) - - class CookieJar(aiohttp.CookieJar): def __init__(self): super().__init__() diff --git a/src/psn_client.py b/src/psn_client.py index 60b3846..ff8d582 100755 --- a/src/psn_client.py +++ b/src/psn_client.py @@ -1,26 +1,23 @@ import asyncio import logging -import math -import re -from datetime import datetime, timezone, timedelta from functools import partial -from typing import List, NewType, Optional +from typing import List, NewType from galaxy.api.errors import UnknownBackendResponse from galaxy.api.types import SubscriptionGame -from http_client import paginate_url from parsers import PSNGamesParser + GAME_LIST_URL = "https://web.np.playstation.com/api/graphql/v1/op" \ "?operationName=getPurchasedGameList" \ - '&variables={"isActive":true,"platform":["ps3","ps4","ps5"],"subscriptionService":"NONE"}' \ - '&extensions={"persistedQuery":{"version":1,"sha256Hash":"2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2"}}' + '&variables={{"isActive":true,"platform":["ps3","ps4","ps5"],"start":{start},"size":{size},"subscriptionService":"NONE"}}' \ + '&extensions={{"persistedQuery":{{"version":1,"sha256Hash":"2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2"}}}}' PLAYED_GAME_LIST_URL = "https://web.np.playstation.com/api/graphql/v1/op" \ "?operationName=getUserGameList" \ - '&variables={"limit":50,"categories":"ps3_game,ps4_game,ps5_native_game"}' \ - '&extensions={"persistedQuery":{"version":1,"sha256Hash":"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae"}}' + '&variables={{"categories":"ps3_game,ps4_game,ps5_native_game","limit":{size}}}' \ + '&extensions={{"persistedQuery":{{"version":1,"sha256Hash":"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae"}}}}' USER_INFO_URL = "https://web.np.playstation.com/api/graphql/v1/op" \ "?operationName=getProfileOracle" \ @@ -31,40 +28,10 @@ DEFAULT_LIMIT = 100 -TitleId = NewType("TitleId", str) -UnixTimestamp = NewType("UnixTimestamp", int) - - -def parse_timestamp(earned_date) -> UnixTimestamp: - date_format = "%Y-%m-%dT%H:%M:%S.%fZ" if '.' in earned_date else "%Y-%m-%dT%H:%M:%SZ" - dt = datetime.strptime(earned_date, date_format) - dt = datetime.combine(dt.date(), dt.time(), timezone.utc) - return UnixTimestamp(int(dt.timestamp())) +# 100 is a maximum possible value to provide +PLAYED_GAME_LIST_URL = PLAYED_GAME_LIST_URL.format(size=DEFAULT_LIMIT) - -def parse_play_duration(duration: Optional[str]) -> int: - """Returns time of played game in minutes from PSN API format `PT{HOURS}H{MINUTES}M{SECONDS}S`. Example: `PT2H33M3S`""" - if not duration: - raise UnknownBackendResponse(f'nullable playtime duration: {type(duration)}') - try: - result = re.match( - r'(?:PT)?' - r'(?:(?P\d*)H)?' - r'(?:(?P\d*)M)?' - r'(?:(?P\d*)S)?$', - duration - ) - mapped_result = {k: float(v) for k, v in result.groupdict(0).items()} - time = timedelta(**mapped_result) - except (ValueError, AttributeError, TypeError): - raise UnknownBackendResponse(f'Unmatchable gametime: {duration}') - - total_minutes = math.ceil(time.seconds / 60 + time.days * 24 * 60) - return total_minutes - - -def date_today(): - return datetime.today() +UnixTimestamp = NewType("UnixTimestamp", int) class PSNClient: @@ -80,22 +47,23 @@ async def fetch_paginated_data( self, parser, url, + operation_name, counter_name, limit=DEFAULT_LIMIT, *args, **kwargs ): - response = await self._http_client.get(paginate_url(url=url, limit=limit), *args, **kwargs) + response = await self._http_client.get(url.format(size=limit, start=0), *args, **kwargs) if not response: return [] try: - total = int(response.get(counter_name, 0)) - except ValueError: - raise UnknownBackendResponse() + total = int(response["data"][operation_name]["pageInfo"].get(counter_name, 0)) + except (ValueError, KeyError, TypeError) as e: + raise UnknownBackendResponse(e) responses = [response] + await asyncio.gather(*[ - self._http_client.get(paginate_url(url=url, limit=limit, offset=offset), *args, **kwargs) + self._http_client.get(url.format(size=limit, start=offset), *args, **kwargs) for offset in range(limit, total, limit) ]) @@ -122,10 +90,7 @@ def user_info_parser(response): response["data"]["oracleUserProfileRetrieve"]["onlineId"] except (KeyError, TypeError) as e: raise UnknownBackendResponse(e) - return await self.fetch_data( - user_info_parser, - USER_INFO_URL, - ) + return await self.fetch_data(user_info_parser, USER_INFO_URL) async def get_psplus_status(self) -> bool: @@ -138,10 +103,10 @@ def user_subscription_parser(response): except (KeyError, TypeError) as e: raise UnknownBackendResponse(e) - return await self.fetch_data( - user_subscription_parser, - USER_INFO_URL, - ) + return await self.fetch_data(user_subscription_parser, USER_INFO_URL) + + async def get_subscription_games(self) -> List[SubscriptionGame]: + return await self.fetch_data(PSNGamesParser().parse, PSN_PLUS_SUBSCRIPTIONS_URL, get_json=False, silent=True) async def async_get_purchased_games(self): def games_parser(response): @@ -153,10 +118,7 @@ def games_parser(response): except (KeyError, TypeError) as e: raise UnknownBackendResponse(e) - return await self.fetch_paginated_data(games_parser, GAME_LIST_URL, "totalCount") - - async def get_subscription_games(self) -> List[SubscriptionGame]: - return await self.fetch_data(PSNGamesParser().parse, PSN_PLUS_SUBSCRIPTIONS_URL, get_json=False, silent=True) + return await self.fetch_paginated_data(games_parser, GAME_LIST_URL, "purchasedTitlesRetrieve", "totalCount") async def async_get_played_games(self): def games_parser(response): @@ -168,4 +130,4 @@ def games_parser(response): except (KeyError, TypeError) as e: raise UnknownBackendResponse(e) - return await self.fetch_paginated_data(games_parser, PLAYED_GAME_LIST_URL, 'totalItemCount') + return await self.fetch_data(games_parser, PLAYED_GAME_LIST_URL) diff --git a/src/version.py b/src/version.py index 3cd0e92..bcea1d4 100755 --- a/src/version.py +++ b/src/version.py @@ -1,6 +1,11 @@ -__version__ = "0.34" +__version__ = "0.35" __changelog__ = { + "unreleased": """ + """, + "0.35": """ + - Fix pagination of fetched purchased games + """, "0.34": """ - Add refreshing cookies by doing request to playstation login website - Fix logging by changing oauth which is the same as in web client for playstation.com diff --git a/tests/test_data.py b/tests/test_data.py index 9e926fb..4f6cf9b 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -39,12 +39,14 @@ ] BACKEND_GAME_TITLES = { - "start": 0, - "size": 11, - "totalResults": 11, "data": { "purchasedTitlesRetrieve": { - "games": PARSED_GAME_TITLES + "games": PARSED_GAME_TITLES, + "pageInfo": { + "start": 0, + "size": 11, + "totalCount": 11, + } } } } diff --git a/tests/test_owned_games.py b/tests/test_owned_games.py index be9033c..373d1b9 100644 --- a/tests/test_owned_games.py +++ b/tests/test_owned_games.py @@ -1,15 +1,17 @@ +import copy + import pytest +from galaxy.api.consts import LicenseType from galaxy.api.errors import UnknownBackendResponse +from galaxy.api.types import Game, LicenseInfo from galaxy.unittest.mock import async_return_value -from http_client import paginate_url -from psn_client import DEFAULT_LIMIT, GAME_LIST_URL from tests.test_data import GAMES, BACKEND_GAME_TITLES, PARSED_GAME_TITLES @pytest.mark.asyncio @pytest.mark.parametrize("backend_response, games", [ - pytest.param({"data": {"purchasedTitlesRetrieve": {"games": []}}}, [], id='no games'), + pytest.param({"data": {"purchasedTitlesRetrieve": {"games": [], "pageInfo":{"totalResults": 0}}}}, [], id='no games'), pytest.param(BACKEND_GAME_TITLES, GAMES, id='multiple game: real case scenario'), ]) async def test_get_owned_games__only_purchased_games( @@ -26,7 +28,6 @@ async def test_get_owned_games__only_purchased_games( ) assert games == await authenticated_plugin.get_owned_games() - http_get.assert_called_once_with(paginate_url(GAME_LIST_URL, DEFAULT_LIMIT)) @pytest.mark.asyncio @@ -70,4 +71,35 @@ async def test_bad_format( with pytest.raises(UnknownBackendResponse): await authenticated_plugin.get_owned_games() - http_get.assert_called_once_with(paginate_url(GAME_LIST_URL, DEFAULT_LIMIT)) + +async def test_fetch_200_purchased_games( + authenticated_plugin, + mocker, + http_get +): + mocker.patch( + "psn_client.PSNClient.async_get_played_games", + return_value=async_return_value([]) + ) + response_size = 100 + purchased_games = [{"titleId": f"GAME_ID_{i}", "name": f"GAME_NAME_{i}"} for i in range(200)] + response = { + "data": { + "purchasedTitlesRetrieve": { + "games": [], + "pageInfo": { + "totalCount": len(purchased_games), + "size": response_size, + } + } + } + } + r1, r2 = copy.deepcopy(response), copy.deepcopy(response) + r1["data"]["purchasedTitlesRetrieve"]["games"] = purchased_games[:response_size] + r2["data"]["purchasedTitlesRetrieve"]["games"] = purchased_games[response_size:] + http_get.side_effect = [r1, r2] + expected_games = [ + Game(game["titleId"], game["name"], [], LicenseInfo(LicenseType.SinglePurchase, None)) for game in purchased_games + ] + + assert await authenticated_plugin.get_owned_games() == expected_games diff --git a/tests/test_psn_client.py b/tests/test_psn_client.py index bc302a9..ebbe62e 100644 --- a/tests/test_psn_client.py +++ b/tests/test_psn_client.py @@ -3,138 +3,147 @@ from galaxy.api.errors import UnknownBackendResponse -TROPHIES = [ - {"id": "NPWR15900_00", "name": "Persona 5: Dancing in Starlight"}, - {"id": "NPWR16532_00", "name": "KINGDOM HEARTS III"}, - {"id": "NPWR15608_00", "name": "Persona 4 Dancing All Night"}, - {"id": "NPWR15899_00", "name": "Persona 3: Dancing in Moonlight"}, - {"id": "NPWR11036_00", "name": "ACE COMBAT™ 7: SKIES UNKNOWN"}, - {"id": "NPWR03852_00", "name": "Dragon's Crown"}, - {"id": "NPWR16122_00", "name": "Fist of the North Star: Lost Paradise"}, - {"id": "NPWR16634_00", "name": "ACE COMBAT™ SQUADRON LEADER"}, - {"id": "NPWR16410_00", "name": "Castlevania Requiem: Symphony Of The Night & Rondo Of Blood"}, - {"id": "NPWR13243_00", "name": "SNK HEROINES Tag Team Frenzy"}, - {"id": "NPWR09167_00", "name": "Marvel's Spider-Man"}, - {"id": "NPWR15315_00", "name": "DRAGON QUEST XI: Echoes of an Elusive Age"}, - {"id": "NPWR13281_00", "name": "DARK SOULS™: REMASTERED"}, - {"id": "NPWR12990_00", "name": "Street Fighter 30th Anniversary Collection\n"}, - {"id": "NPWR14092_00", "name": "SEGA Mega Drive & Genesis Classics"}, - {"id": "NPWR13872_00", "name": "DETROIT: BECOME HUMAN"}, - {"id": "NPWR10879_00", "name": "Paladins"}, - {"id": "NPWR09886_00", "name": "School Girl/Zombie Hunter"}, - {"id": "NPWR12223_00", "name": "Tokyo Xanadu eX+"}, - {"id": "NPWR12961_00", "name": "WARRIORS ALL-STARS"}, - {"id": "NPWR12518_00", "name": "God of War"}, - {"id": "NPWR10979_00", "name": "Brawlhalla"}, - {"id": "NPWR11885_00", "name": "TERA"}, - {"id": "NPWR14858_00", "name": "Ni no Kuni™ II: Revenant Kingdom"}, - {"id": "NPWR08053_00", "name": "War of the Monsters™"}, - {"id": "NPWR11401_00", "name": "South Park™: The Fractured But Whole™"}, - {"id": "NPWR10890_00", "name": "Sniper Ghost Warrior 3"}, - {"id": "NPWR13550_00", "name": "SWORD ART ONLINE: FATAL BULLET"}, - {"id": "NPWR11735_00", "name": "METAL GEAR SURVIVE"}, - {"id": "NPWR11209_00", "name": "Hand of Fate 2"}, - {"id": "NPWR12176_00", "name": "88 Heroes"}, - {"id": "NPWR14240_00", "name": "DYNASTY WARRIORS 9"}, - {"id": "NPWR10035_00", "name": "Sky Force Anniversary"}, - {"id": "NPWR10673_00", "name": "Downwell"}, - {"id": "NPWR10126_00", "name": "Guilty Gear Xrd -Revelator- Trophy"}, - {"id": "NPWR06535_00", "name": "Blue Estate"}, - {"id": "NPWR07552_00", "name": "Rollers of the Realm"}, - {"id": "NPWR13920_00", "name": "Morphite"}, - {"id": "NPWR10039_00", "name": "Joe Dever's Lone Wolf Console Edition"}, - {"id": "NPWR11631_00", "name": "Monster Hunter: World"}, - {"id": "NPWR11350_00", "name": "Night In The Woods"}, - {"id": "NPWR14140_00", "name": "Iconoclasts"}, - {"id": "NPWR12541_00", "name": "The Silver Case HD"}, - {"id": "NPWR10787_00", "name": "Exist Archive"}, - {"id": "NPWR13363_00", "name": "Pyre"}, - {"id": "NPWR14333_00", "name": "STAR OCEAN™ - THE LAST HOPE -™ 4K & Full HD Remaster"}, - {"id": "NPWR07518_00", "name": "STREET FIGHTER V"}, - {"id": "NPWR09921_00", "name": "Gran Turismo Sport"}, - {"id": "NPWR11338_00", "name": "Injustice 2 Trophies"}, - {"id": "NPWR11619_00", "name": "Need for Speed™ Payback"}, - {"id": "NPWR12510_00", "name": "Wolfenstein® II: The New Colossus"}, - {"id": "NPWR12420_00", "name": ".hack//G.U. Last Recode"}, - {"id": "NPWR11424_00", "name": "Assassin's Creed® Origins"}, - {"id": "NPWR12568_00", "name": "ELEX"}, - {"id": "NPWR13161_00", "name": "Cyberdimension Neptunia: 4 Goddesses Online"}, - {"id": "NPWR11756_00", "name": "The Evil Within® 2"}, - {"id": "NPWR12586_00", "name": "_>OBSERVER_"}, - {"id": "NPWR10569_00", "name": "Middle-earth™: Shadow of War™"}, - {"id": "NPWR13858_00", "name": "Battle Chasers: Nightwar"}, - {"id": "NPWR12435_00", "name": "Dragon's Dogma: Dark Arisen"}, - {"id": "NPWR13202_00", "name": "Ruiner"}, - {"id": "NPWR10344_00", "name": "Hard Reset Redux"}, - {"id": "NPWR12704_00", "name": "Yonder: The Cloud Catcher Chronicles"}, - {"id": "NPWR11056_00", "name": "Destiny 2"}, - {"id": "NPWR11920_00", "name": "BLUE REFLECTION "}, - {"id": "NPWR13133_00", "name": "Spacelords"}, - {"id": "NPWR09395_00", "name": "Tom Clancy’s Ghost Recon® Wildlands"}, - {"id": "NPWR13712_00", "name": "Ys VIII -Lacrimosa of DANA-"}, - {"id": "NPWR11690_00", "name": "Senran Kagura\nPEACH BEACH SPLASH"}, - {"id": "NPWR10362_00", "name": "Project CARS 2"}, - {"id": "NPWR11874_00", "name": "MARVEL VS. CAPCOM: INFINITE"}, - {"id": "NPWR12219_00", "name": "Pillars of Eternity: Complete Edition"}, - {"id": "NPWR13354_00", "name": "Life is Strange: Before the Storm"}, - {"id": "NPWR08922_00", "name": "LEGO® MARVEL's Avengers"}, - {"id": "NPWR11777_00", "name": "Resident Evil Revelations"}, - {"id": "NPWR09497_00", "name": "Agents of Mayhem"}, - {"id": "NPWR13408_00", "name": "Uncharted™: The Lost Legacy"}, - {"id": "NPWR06510_00", "name": "LEGO® Batman™ 3"}, - {"id": "NPWR11243_00", "name": "Batman"}, - {"id": "NPWR11474_00", "name": "RiME"}, - {"id": "NPWR12591_00", "name": "Hellblade: Senua's Sacrifice"}, - {"id": "NPWR08516_00", "name": "Overwatch: Origins Edition"}, - {"id": "NPWR10320_00", "name": "Sniper Elite 4"}, - {"id": "NPWR08661_00", "name": "DOOM®"}, - {"id": "NPWR12756_00", "name": "Shadow Tactics"}, - {"id": "NPWR12411_00", "name": "Bulletstorm: Full Clip Edition"}, - {"id": "NPWR12196_00", "name": "Stardew Valley"}, - {"id": "NPWR06600_00", "name": "Assassin's Creed® Unity"}, - {"id": "NPWR11261_00", "name": "Alone With You"}, - {"id": "NPWR12540_00", "name": "Kona"}, - {"id": "NPWR12765_00", "name": "Late Shift"}, - {"id": "NPWR13412_00", "name": "Fortnite"}, - {"id": "NPWR13022_00", "name": "Cosmic Star Heroine"}, - {"id": "NPWR07942_00", "name": "Ratchet & Clank™"}, - {"id": "NPWR12115_00", "name": "Full Throttle Remastered"}, - {"id": "NPWR08193_00", "name": "Mirror's Edge™ Catalyst"}, - {"id": "NPWR08609_00", "name": "Bastion"}, - {"id": "NPWR10066_00", "name": "Enter The Gungeon"}, - {"id": "NPWR09096_00", "name": "Curses 'n Chaos"}, - {"id": "NPWR11367_00", "name": "FINAL FANTASY Ⅻ THE ZODIAC AGE"} +GAMES = [ + {"id": "CUSA15900_00", "name": "Persona 5: Dancing in Starlight"}, + {"id": "CUSA16532_00", "name": "KINGDOM HEARTS III"}, + {"id": "CUSA15608_00", "name": "Persona 4 Dancing All Night"}, + {"id": "CUSA15899_00", "name": "Persona 3: Dancing in Moonlight"}, + {"id": "CUSA11036_00", "name": "ACE COMBAT™ 7: SKIES UNKNOWN"}, + {"id": "CUSA03852_00", "name": "Dragon's Crown"}, + {"id": "CUSA16122_00", "name": "Fist of the North Star: Lost Paradise"}, + {"id": "CUSA16634_00", "name": "ACE COMBAT™ SQUADRON LEADER"}, + {"id": "CUSA16410_00", "name": "Castlevania Requiem: Symphony Of The Night & Rondo Of Blood"}, + {"id": "CUSA13243_00", "name": "SNK HEROINES Tag Team Frenzy"}, + {"id": "CUSA09167_00", "name": "Marvel's Spider-Man"}, + {"id": "CUSA15315_00", "name": "DRAGON QUEST XI: Echoes of an Elusive Age"}, + {"id": "CUSA13281_00", "name": "DARK SOULS™: REMASTERED"}, + {"id": "CUSA12990_00", "name": "Street Fighter 30th Anniversary Collection\n"}, + {"id": "CUSA14092_00", "name": "SEGA Mega Drive & Genesis Classics"}, + {"id": "CUSA13872_00", "name": "DETROIT: BECOME HUMAN"}, + {"id": "CUSA10879_00", "name": "Paladins"}, + {"id": "CUSA09886_00", "name": "School Girl/Zombie Hunter"}, + {"id": "CUSA12223_00", "name": "Tokyo Xanadu eX+"}, + {"id": "CUSA12961_00", "name": "WARRIORS ALL-STARS"}, + {"id": "CUSA12518_00", "name": "God of War"}, + {"id": "CUSA10979_00", "name": "Brawlhalla"}, + {"id": "CUSA11885_00", "name": "TERA"}, + {"id": "CUSA14858_00", "name": "Ni no Kuni™ II: Revenant Kingdom"}, + {"id": "CUSA08053_00", "name": "War of the Monsters™"}, + {"id": "CUSA11401_00", "name": "South Park™: The Fractured But Whole™"}, + {"id": "CUSA10890_00", "name": "Sniper Ghost Warrior 3"}, + {"id": "CUSA13550_00", "name": "SWORD ART ONLINE: FATAL BULLET"}, + {"id": "CUSA11735_00", "name": "METAL GEAR SURVIVE"}, + {"id": "CUSA11209_00", "name": "Hand of Fate 2"}, + {"id": "CUSA12176_00", "name": "88 Heroes"}, + {"id": "CUSA14240_00", "name": "DYNASTY WARRIORS 9"}, + {"id": "CUSA10035_00", "name": "Sky Force Anniversary"}, + {"id": "CUSA10673_00", "name": "Downwell"}, + {"id": "CUSA10126_00", "name": "Guilty Gear Xrd -Revelator- Trophy"}, + {"id": "CUSA06535_00", "name": "Blue Estate"}, + {"id": "CUSA07552_00", "name": "Rollers of the Realm"}, + {"id": "CUSA13920_00", "name": "Morphite"}, + {"id": "CUSA10039_00", "name": "Joe Dever's Lone Wolf Console Edition"}, + {"id": "CUSA11631_00", "name": "Monster Hunter: World"}, + {"id": "CUSA11350_00", "name": "Night In The Woods"}, + {"id": "CUSA14140_00", "name": "Iconoclasts"}, + {"id": "CUSA12541_00", "name": "The Silver Case HD"}, + {"id": "CUSA10787_00", "name": "Exist Archive"}, + {"id": "CUSA13363_00", "name": "Pyre"}, + {"id": "CUSA14333_00", "name": "STAR OCEAN™ - THE LAST HOPE -™ 4K & Full HD Remaster"}, + {"id": "CUSA07518_00", "name": "STREET FIGHTER V"}, + {"id": "CUSA09921_00", "name": "Gran Turismo Sport"}, + {"id": "CUSA11338_00", "name": "Injustice 2 Trophies"}, + {"id": "CUSA11619_00", "name": "Need for Speed™ Payback"}, + {"id": "CUSA12510_00", "name": "Wolfenstein® II: The New Colossus"}, + {"id": "CUSA12420_00", "name": ".hack//G.U. Last Recode"}, + {"id": "CUSA11424_00", "name": "Assassin's Creed® Origins"}, + {"id": "CUSA12568_00", "name": "ELEX"}, + {"id": "CUSA13161_00", "name": "Cyberdimension Neptunia: 4 Goddesses Online"}, + {"id": "CUSA11756_00", "name": "The Evil Within® 2"}, + {"id": "CUSA12586_00", "name": "_>OBSERVER_"}, + {"id": "CUSA10569_00", "name": "Middle-earth™: Shadow of War™"}, + {"id": "CUSA13858_00", "name": "Battle Chasers: Nightwar"}, + {"id": "CUSA12435_00", "name": "Dragon's Dogma: Dark Arisen"}, + {"id": "CUSA13202_00", "name": "Ruiner"}, + {"id": "CUSA10344_00", "name": "Hard Reset Redux"}, + {"id": "CUSA12704_00", "name": "Yonder: The Cloud Catcher Chronicles"}, + {"id": "CUSA11056_00", "name": "Destiny 2"}, + {"id": "CUSA11920_00", "name": "BLUE REFLECTION "}, + {"id": "CUSA13133_00", "name": "Spacelords"}, + {"id": "CUSA09395_00", "name": "Tom Clancy’s Ghost Recon® Wildlands"}, + {"id": "CUSA13712_00", "name": "Ys VIII -Lacrimosa of DANA-"}, + {"id": "CUSA11690_00", "name": "Senran Kagura\nPEACH BEACH SPLASH"}, + {"id": "CUSA10362_00", "name": "Project CARS 2"}, + {"id": "CUSA11874_00", "name": "MARVEL VS. CAPCOM: INFINITE"}, + {"id": "CUSA12219_00", "name": "Pillars of Eternity: Complete Edition"}, + {"id": "CUSA13354_00", "name": "Life is Strange: Before the Storm"}, + {"id": "CUSA08922_00", "name": "LEGO® MARVEL's Avengers"}, + {"id": "CUSA11777_00", "name": "Resident Evil Revelations"}, + {"id": "CUSA09497_00", "name": "Agents of Mayhem"}, + {"id": "CUSA13408_00", "name": "Uncharted™: The Lost Legacy"}, + {"id": "CUSA06510_00", "name": "LEGO® Batman™ 3"}, + {"id": "CUSA11243_00", "name": "Batman"}, + {"id": "CUSA11474_00", "name": "RiME"}, + {"id": "CUSA12591_00", "name": "Hellblade: Senua's Sacrifice"}, + {"id": "CUSA08516_00", "name": "Overwatch: Origins Edition"}, + {"id": "CUSA10320_00", "name": "Sniper Elite 4"}, + {"id": "CUSA08661_00", "name": "DOOM®"}, + {"id": "CUSA12756_00", "name": "Shadow Tactics"}, + {"id": "CUSA12411_00", "name": "Bulletstorm: Full Clip Edition"}, + {"id": "CUSA12196_00", "name": "Stardew Valley"}, + {"id": "CUSA06600_00", "name": "Assassin's Creed® Unity"}, + {"id": "CUSA11261_00", "name": "Alone With You"}, + {"id": "CUSA12540_00", "name": "Kona"}, + {"id": "CUSA12765_00", "name": "Late Shift"}, + {"id": "CUSA13412_00", "name": "Fortnite"}, + {"id": "CUSA13022_00", "name": "Cosmic Star Heroine"}, + {"id": "CUSA07942_00", "name": "Ratchet & Clank™"}, + {"id": "CUSA12115_00", "name": "Full Throttle Remastered"}, + {"id": "CUSA08193_00", "name": "Mirror's Edge™ Catalyst"}, + {"id": "CUSA08609_00", "name": "Bastion"}, + {"id": "CUSA10066_00", "name": "Enter The Gungeon"}, + {"id": "CUSA09096_00", "name": "Curses 'n Chaos"}, + {"id": "CUSA11367_00", "name": "FINAL FANTASY Ⅻ THE ZODIAC AGE"} ] -TROPHIES_PAGE = "https://url.com/get-prophies?limit={limit}&offset={offset}" +GAMES_PAGE = "https://url.com/get-games" \ + "?operationName=getGames" \ + '&variables={{"start":{start},"size":{size}}}' -def create_backend_response_generator(limit=len(TROPHIES)): +def create_backend_response_generator(size=len(GAMES)): def gen_range(start, stop, step): return ((n, min((n + step), stop)) for n in range(start, stop, step)) def response_generator(): - for start, stop in gen_range(0, len(TROPHIES), limit): + for start, stop in gen_range(0, len(GAMES), size): yield { - "totalResults": len(TROPHIES), - "offset": start, - "limit": limit, - "trophyTitles": TROPHIES[start:stop] + "data": { + "getGames": { + "games": GAMES[start:stop], + "pageInfo": { + "totalCount": len(GAMES), + "offset": start, + "size": size, + "isLast": True if GAMES[-1] in GAMES[start:stop] else False + } + } + } } - return response_generator def create_backend_response_all_trophies(): - return {"totalResults": len(TROPHIES), "offset": 0, "limit": len(TROPHIES), "trophyTitles": TROPHIES} + response = create_backend_response_generator()() + return [_ for _ in response][0] def parser(data): - return [{g["id"]: g["name"]} for g in data["trophyTitles"]] if data else [] + return [{g["id"]: g["name"]} for g in data["data"]["getGames"]["games"]] if data else [] def assert_all_games_fetched(games): - assert games == [{g["id"]: g["name"]} for g in TROPHIES] + assert games == [{g["id"]: g["name"]} for g in GAMES] @pytest.mark.asyncio @@ -143,7 +152,7 @@ async def test_simple_fetch( authenticated_psn_client, ): http_get.side_effect = create_backend_response_generator()() - assert_all_games_fetched(await authenticated_psn_client.fetch_data(parser, TROPHIES_PAGE)) + assert_all_games_fetched(await authenticated_psn_client.fetch_data(parser, GAMES_PAGE)) http_get.assert_called_once() @@ -156,9 +165,9 @@ async def test_pagination( http_get.side_effect = create_backend_response_generator(limit)() assert_all_games_fetched(await authenticated_psn_client.fetch_paginated_data( - parser, TROPHIES_PAGE, "totalResults", limit)) + parser, GAMES_PAGE, "getGames", "totalCount", limit)) http_get.assert_called() - assert math.ceil(len(TROPHIES) / limit) == http_get.call_count + assert math.ceil(len(GAMES) / limit) == http_get.call_count @pytest.mark.asyncio @@ -168,7 +177,7 @@ async def test_single_fetch( ): http_get.side_effect = create_backend_response_generator()() assert_all_games_fetched(await authenticated_psn_client.fetch_paginated_data( - parser, TROPHIES_PAGE, "totalResults", len(TROPHIES))) + parser, GAMES_PAGE, "getGames", "totalCount", len(GAMES))) http_get.assert_called_once() @@ -179,7 +188,7 @@ async def test_less_than_expected( ): http_get.side_effect = create_backend_response_generator()() assert_all_games_fetched(await authenticated_psn_client.fetch_paginated_data( - parser, TROPHIES_PAGE, "totalResults", len(TROPHIES) + 50)) + parser, GAMES_PAGE, "getGames", "totalCount", len(GAMES) + 50)) http_get.assert_called_once() @@ -189,10 +198,10 @@ async def test_no_total_results( authenticated_psn_client, ): response = create_backend_response_all_trophies() - del response["totalResults"] + del response["data"]["getGames"]["pageInfo"]["totalCount"] http_get.return_value = response assert_all_games_fetched(await authenticated_psn_client.fetch_paginated_data( - parser, TROPHIES_PAGE, "totalResults", len(TROPHIES))) + parser, GAMES_PAGE, "getGames", "totalCount", len(GAMES))) http_get.assert_called_once() @@ -202,9 +211,9 @@ async def test_invalid_total_results( authenticated_psn_client, ): response = create_backend_response_all_trophies() - response["totalResults"] = "bad_number" + response["data"]["getGames"]["pageInfo"]["totalCount"] = "bad_number" http_get.return_value = response with pytest.raises(UnknownBackendResponse): - await authenticated_psn_client.fetch_paginated_data(parser, TROPHIES_PAGE, "totalResults", len(TROPHIES)) + await authenticated_psn_client.fetch_paginated_data(parser, GAMES_PAGE, "getGames", "totalCount", len(GAMES)) http_get.assert_called_once()