pynintendoparental 2.0.0__tar.gz → 2.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/PKG-INFO +7 -3
  2. pynintendoparental-2.1.1/pynintendoparental/_version.py +1 -0
  3. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/api.py +0 -14
  4. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/const.py +2 -2
  5. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/device.py +29 -7
  6. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/exceptions.py +2 -0
  7. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/player.py +17 -8
  8. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental.egg-info/PKG-INFO +7 -3
  9. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental.egg-info/SOURCES.txt +5 -1
  10. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental.egg-info/requires.txt +6 -2
  11. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/setup.py +6 -2
  12. pynintendoparental-2.1.1/tests/test_api.py +193 -0
  13. pynintendoparental-2.1.1/tests/test_device.py +110 -0
  14. pynintendoparental-2.1.1/tests/test_init.py +80 -0
  15. pynintendoparental-2.1.1/tests/test_player.py +46 -0
  16. pynintendoparental-2.0.0/pynintendoparental/_version.py +0 -1
  17. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/LICENSE +0 -0
  18. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/README.md +0 -0
  19. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/__init__.py +0 -0
  20. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/application.py +0 -0
  21. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/authenticator.py +0 -0
  22. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/enum.py +0 -0
  23. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/py.typed +0 -0
  24. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental/utils.py +0 -0
  25. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental.egg-info/dependency_links.txt +0 -0
  26. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pynintendoparental.egg-info/top_level.txt +0 -0
  27. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/pyproject.toml +0 -0
  28. {pynintendoparental-2.0.0 → pynintendoparental-2.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pynintendoparental
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: A Python module to interact with Nintendo Parental Controls
5
5
  Home-page: http://github.com/pantherale0/pynintendoparental
6
6
  Author: pantherale0
@@ -11,16 +11,20 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.8, <4
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: pynintendoauth~=1.0.0
14
+ Requires-Dist: pynintendoauth==1.0.0
15
15
  Provides-Extra: dev
16
+ Requires-Dist: aiofiles<26,>=23; extra == "dev"
16
17
  Requires-Dist: bandit<1.9,>=1.7; extra == "dev"
17
18
  Requires-Dist: black<26,>=23; extra == "dev"
18
19
  Requires-Dist: build<1.4,>=0.10; extra == "dev"
20
+ Requires-Dist: Faker<39,>=38; extra == "dev"
19
21
  Requires-Dist: flake8<8,>=6; extra == "dev"
20
22
  Requires-Dist: isort<7,>=5; extra == "dev"
21
- Requires-Dist: mypy<1.19,>=1.5; extra == "dev"
23
+ Requires-Dist: mypy<1.20,>=1.5; extra == "dev"
22
24
  Requires-Dist: pytest<9,>=7; extra == "dev"
23
25
  Requires-Dist: pytest-cov<8,>=4; extra == "dev"
26
+ Requires-Dist: pytest-asyncio<1.0,>=0.21; extra == "dev"
27
+ Requires-Dist: syrupy<6,>=5; extra == "dev"
24
28
  Requires-Dist: twine<7,>=4; extra == "dev"
25
29
  Dynamic: author
26
30
  Dynamic: classifier
@@ -0,0 +1 @@
1
+ __version__ = "2.1.1"
@@ -112,12 +112,6 @@ class Api:
112
112
  # now return the resp dict
113
113
  return resp
114
114
 
115
- async def async_get_account_details(self) -> dict:
116
- """Get account details."""
117
- return await self.send_request(
118
- endpoint="get_account_details", ACCOUNT_ID=self.account_id
119
- )
120
-
121
115
  async def async_get_account_devices(self) -> dict:
122
116
  """Get account devices."""
123
117
  return await self.send_request(endpoint="get_account_devices")
@@ -146,14 +140,6 @@ class Api:
146
140
  endpoint="get_device_parental_control_setting", DEVICE_ID=device_id
147
141
  )
148
142
 
149
- async def async_get_device_parental_control_setting_state(
150
- self, device_id: str
151
- ) -> dict:
152
- """Get device parental control setting state."""
153
- return await self.send_request(
154
- endpoint="get_device_parental_control_setting_state", DEVICE_ID=device_id
155
- )
156
-
157
143
  async def async_get_device_monthly_summary(
158
144
  self, device_id: str, year: int, month: int
159
145
  ) -> dict:
@@ -6,8 +6,8 @@ import logging
6
6
  _LOGGER = logging.getLogger(__package__)
7
7
  CLIENT_ID = "54789befb391a838"
8
8
  MOBILE_APP_PKG = "com.nintendo.znma"
9
- MOBILE_APP_VERSION = "2.2.0"
10
- MOBILE_APP_BUILD = "560"
9
+ MOBILE_APP_VERSION = "2.3.0"
10
+ MOBILE_APP_BUILD = "600"
11
11
  OS_NAME = "ANDROID"
12
12
  OS_VERSION = "34"
13
13
  OS_STR = f"{OS_NAME} {OS_VERSION}"
@@ -32,8 +32,9 @@ class Device:
32
32
  self._api: Api = api
33
33
  self.daily_summaries: dict = {}
34
34
  self.parental_control_settings: dict = {}
35
- self.players: list[Player] = []
35
+ self.players: dict[str, Player] = {}
36
36
  self.limit_time: int | float | None = 0
37
+ self.extra_playing_time: int | None = None
37
38
  self.timer_mode: DeviceTimerMode | None = None
38
39
  self.today_playing_time: int | float = 0
39
40
  self.today_time_remaining: int | float = 0
@@ -66,6 +67,13 @@ class Device:
66
67
  """Return the generation."""
67
68
  return self.extra.get("platformGeneration", None)
68
69
 
70
+ @property
71
+ def last_sync(self) -> float | None:
72
+ """Return the last time this device was synced."""
73
+ return self.extra.get("synchronizedParentalControlSetting", {}).get(
74
+ "synchronizedAt", None
75
+ )
76
+
69
77
  async def update(self):
70
78
  """Update data."""
71
79
  _LOGGER.debug(">> Device.update()")
@@ -76,11 +84,8 @@ class Device:
76
84
  self.get_monthly_summary(),
77
85
  self._get_extras(),
78
86
  )
79
- if not self.players:
80
- self.players = Player.from_device_daily_summary(self.daily_summaries)
81
- else:
82
- for player in self.players:
83
- player.update_from_daily_summary(self.daily_summaries)
87
+ for player in self.players.values():
88
+ player.update_from_daily_summary(self.daily_summaries)
84
89
  await self._execute_callbacks()
85
90
 
86
91
  def add_device_callback(self, callback):
@@ -321,6 +326,14 @@ class Device:
321
326
  today_reg = self._get_today_regulation(now)
322
327
  limit_time = today_reg.get("timeToPlayInOneDay", {}).get("limitTime")
323
328
  self.limit_time = limit_time if limit_time is not None else -1
329
+ extra_playing_time_data = (
330
+ pcs.get("ownedDevice", {}).get("device", {}).get("extraPlayingTime")
331
+ )
332
+ self.extra_playing_time = None
333
+ if extra_playing_time_data:
334
+ self.extra_playing_time = extra_playing_time_data.get("inOneDay", {}).get(
335
+ "duration"
336
+ )
324
337
 
325
338
  bedtime_setting = today_reg.get("bedtime", {})
326
339
  if bedtime_setting.get("enabled"):
@@ -527,6 +540,15 @@ class Device:
527
540
  )
528
541
  if latest:
529
542
  self.last_month_summary = summary = response["json"]["summary"]
543
+ # Generate player objects
544
+ for player in response.get("json", {}).get("summary", {}).get("players", []):
545
+ profile = player.get("profile")
546
+ if not profile or not profile.get("playerId"):
547
+ continue
548
+ player_id = profile["playerId"]
549
+ if player_id not in self.players:
550
+ self.players[player_id] = Player.from_profile(profile)
551
+ self.players[player_id].month_summary = player.get("summary", {})
530
552
  return summary
531
553
  return response["json"]["summary"]
532
554
 
@@ -564,7 +586,7 @@ class Device:
564
586
 
565
587
  def get_player(self, player_id: str) -> Player:
566
588
  """Returns a player."""
567
- player = next((p for p in self.players if p.player_id == player_id), None)
589
+ player = self.players.get(player_id)
568
590
  if player:
569
591
  return player
570
592
  raise ValueError(f"Player with id {player_id} not found.")
@@ -2,12 +2,14 @@
2
2
 
3
3
  from enum import StrEnum
4
4
 
5
+
5
6
  class RangeErrorKeys(StrEnum):
6
7
  """Keys for range errors."""
7
8
 
8
9
  DAILY_PLAYTIME = "daily_playtime_out_of_range"
9
10
  BEDTIME = "bedtime_alarm_out_of_range"
10
11
 
12
+
11
13
  class NoDevicesFoundException(Exception):
12
14
  """No devices were found for the account."""
13
15
 
@@ -8,22 +8,22 @@ class Player:
8
8
 
9
9
  def __init__(self):
10
10
  """Init a player."""
11
- self.player_image: str = None
12
- self.nickname: str = None
11
+ self.player_image: str | None = None
12
+ self.nickname: str | None = None
13
13
  self.apps: list = []
14
- self.player_id: str = None
15
- self.playing_time: int = None
14
+ self.month_summary: dict = {}
15
+ self.player_id: str | None = None
16
+ self.playing_time: int = 0
16
17
 
17
18
  def update_from_daily_summary(self, raw: list[dict]):
18
19
  """Update the current instance of the player from the daily summery"""
19
20
  _LOGGER.debug("Updating player %s daily summary", self.player_id)
20
- for player in raw[0].get("devicePlayers", []):
21
- if self.player_id is player["profile"].get("playerId"):
22
- self.player_id = player["profile"].get("playerId")
21
+ for player in raw[0].get("players", []):
22
+ if self.player_id == player["profile"].get("playerId"):
23
23
  self.player_image = player["profile"].get("imageUri")
24
24
  self.nickname = player["profile"].get("nickname")
25
25
  self.playing_time = player.get("playingTime")
26
- self.apps = player.get("playedApps")
26
+ self.apps = player.get("playedGames")
27
27
  break
28
28
 
29
29
  @classmethod
@@ -41,3 +41,12 @@ class Player:
41
41
  players.append(parsed)
42
42
  _LOGGER.debug("Built player %s", parsed.player_id)
43
43
  return players
44
+
45
+ @classmethod
46
+ def from_profile(cls, raw: dict) -> "Player":
47
+ """Converts a profile response into a player."""
48
+ parsed = cls()
49
+ parsed.player_id = raw.get("playerId")
50
+ parsed.player_image = raw.get("imageUri")
51
+ parsed.nickname = raw.get("nickname")
52
+ return parsed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pynintendoparental
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: A Python module to interact with Nintendo Parental Controls
5
5
  Home-page: http://github.com/pantherale0/pynintendoparental
6
6
  Author: pantherale0
@@ -11,16 +11,20 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.8, <4
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: pynintendoauth~=1.0.0
14
+ Requires-Dist: pynintendoauth==1.0.0
15
15
  Provides-Extra: dev
16
+ Requires-Dist: aiofiles<26,>=23; extra == "dev"
16
17
  Requires-Dist: bandit<1.9,>=1.7; extra == "dev"
17
18
  Requires-Dist: black<26,>=23; extra == "dev"
18
19
  Requires-Dist: build<1.4,>=0.10; extra == "dev"
20
+ Requires-Dist: Faker<39,>=38; extra == "dev"
19
21
  Requires-Dist: flake8<8,>=6; extra == "dev"
20
22
  Requires-Dist: isort<7,>=5; extra == "dev"
21
- Requires-Dist: mypy<1.19,>=1.5; extra == "dev"
23
+ Requires-Dist: mypy<1.20,>=1.5; extra == "dev"
22
24
  Requires-Dist: pytest<9,>=7; extra == "dev"
23
25
  Requires-Dist: pytest-cov<8,>=4; extra == "dev"
26
+ Requires-Dist: pytest-asyncio<1.0,>=0.21; extra == "dev"
27
+ Requires-Dist: syrupy<6,>=5; extra == "dev"
24
28
  Requires-Dist: twine<7,>=4; extra == "dev"
25
29
  Dynamic: author
26
30
  Dynamic: classifier
@@ -18,4 +18,8 @@ pynintendoparental.egg-info/PKG-INFO
18
18
  pynintendoparental.egg-info/SOURCES.txt
19
19
  pynintendoparental.egg-info/dependency_links.txt
20
20
  pynintendoparental.egg-info/requires.txt
21
- pynintendoparental.egg-info/top_level.txt
21
+ pynintendoparental.egg-info/top_level.txt
22
+ tests/test_api.py
23
+ tests/test_device.py
24
+ tests/test_init.py
25
+ tests/test_player.py
@@ -1,12 +1,16 @@
1
- pynintendoauth~=1.0.0
1
+ pynintendoauth==1.0.0
2
2
 
3
3
  [dev]
4
+ aiofiles<26,>=23
4
5
  bandit<1.9,>=1.7
5
6
  black<26,>=23
6
7
  build<1.4,>=0.10
8
+ Faker<39,>=38
7
9
  flake8<8,>=6
8
10
  isort<7,>=5
9
- mypy<1.19,>=1.5
11
+ mypy<1.20,>=1.5
10
12
  pytest<9,>=7
11
13
  pytest-cov<8,>=4
14
+ pytest-asyncio<1.0,>=0.21
15
+ syrupy<6,>=5
12
16
  twine<7,>=4
@@ -16,18 +16,22 @@ with open('pynintendoparental/_version.py', 'r') as version_file:
16
16
  REQUIREMENTS = [
17
17
  # Add your list of production dependencies here, eg:
18
18
  # 'requests == 2.*',
19
- 'pynintendoauth~=1.0.0'
19
+ 'pynintendoauth==1.0.0'
20
20
  ]
21
21
 
22
22
  DEV_REQUIREMENTS = [
23
+ 'aiofiles >= 23,< 26',
23
24
  'bandit >= 1.7,< 1.9',
24
25
  'black >= 23,< 26',
25
26
  'build >= 0.10,< 1.4',
27
+ 'Faker >= 38,< 39',
26
28
  'flake8 >= 6,< 8',
27
29
  'isort >= 5,< 7',
28
- 'mypy >= 1.5,< 1.19',
30
+ 'mypy >= 1.5,< 1.20',
29
31
  'pytest >= 7,< 9',
30
32
  'pytest-cov >= 4,< 8',
33
+ 'pytest-asyncio >= 0.21,< 1.0',
34
+ 'syrupy >= 5,< 6',
31
35
  'twine >= 4,< 7',
32
36
  ]
33
37
 
@@ -0,0 +1,193 @@
1
+ """Tests for the API class."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, Mock
4
+
5
+ import pytest
6
+ from aiohttp import ContentTypeError
7
+ from pynintendoauth.exceptions import HttpException
8
+
9
+ from pynintendoparental.api import Api, _check_http_success
10
+ from pynintendoparental.authenticator import Authenticator
11
+
12
+
13
+ @pytest.mark.parametrize(
14
+ "status, expected", [(200, True), (204, True), (300, False), (404, False)]
15
+ )
16
+ def test_check_http_success(status, expected):
17
+ """Test the _check_http_success function."""
18
+ assert _check_http_success(status) == expected
19
+
20
+
21
+ def test_api_init_and_properties(mock_authenticator: Authenticator):
22
+ """Test API initialization and properties."""
23
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
24
+ assert api.account_id == "ACCOUNT_ID"
25
+ headers = api._headers
26
+ assert headers["Authorization"] == "ACCESS_TOKEN"
27
+ assert headers["X-Moon-TimeZone"] == "Europe/London"
28
+ assert headers["X-Moon-App-Language"] == "en-GB"
29
+
30
+
31
+ async def test_send_request_invalid_endpoint(mock_authenticator: Authenticator):
32
+ """Test sending a request to an invalid endpoint."""
33
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
34
+ with pytest.raises(ValueError, match="Endpoint does not exist"):
35
+ await api.send_request("invalid_endpoint")
36
+
37
+
38
+ async def test_send_request_token_refresh(mock_authenticator: Authenticator):
39
+ """Test that the token is refreshed if it's expired."""
40
+ mock_authenticator.access_token_expired = True
41
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
42
+
43
+ mock_response = MagicMock()
44
+ mock_response.status = 200
45
+ mock_response.json = AsyncMock(return_value={"status": "ok"})
46
+ mock_response.text = AsyncMock(return_value='{"status": "ok"}')
47
+ mock_authenticator.async_authenticated_request = AsyncMock(
48
+ return_value=mock_response
49
+ )
50
+
51
+ await api.send_request("get_account_devices")
52
+ mock_authenticator.perform_refresh.assert_called_once()
53
+
54
+
55
+ async def test_send_request_http_exception(mock_authenticator: Authenticator):
56
+ """Test a generic HttpException is raised on non-2xx response."""
57
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
58
+
59
+ mock_response = MagicMock()
60
+ mock_response.status = 500
61
+ mock_response.content_type = "text/plain"
62
+ mock_response.text = AsyncMock(return_value="Internal Server Error")
63
+ mock_authenticator.async_authenticated_request = AsyncMock(
64
+ return_value=mock_response
65
+ )
66
+
67
+ with pytest.raises(HttpException, match="Internal Server Error"):
68
+ await api.send_request("get_account_devices")
69
+
70
+
71
+ async def test_send_request_http_exception_problem_json(
72
+ mock_authenticator: Authenticator,
73
+ ):
74
+ """Test that HttpException is raised with details from a problem+json response."""
75
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
76
+
77
+ mock_response = MagicMock()
78
+ mock_response.status = 400
79
+ mock_response.content_type = "application/problem+json"
80
+ mock_response.json = AsyncMock(
81
+ return_value={"detail": "Bad Request", "errorCode": "E0001"}
82
+ )
83
+ mock_authenticator.async_authenticated_request = AsyncMock(
84
+ return_value=mock_response
85
+ )
86
+
87
+ with pytest.raises(HttpException, match="Bad Request"):
88
+ await api.send_request("get_account_devices")
89
+
90
+
91
+ async def test_send_request_http_exception_problem_json_invalid(
92
+ mock_authenticator: Authenticator,
93
+ ):
94
+ """Test HttpException with a generic message for invalid problem+json."""
95
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
96
+ mock_request_info = Mock(real_url="http://mock.url")
97
+ content_type_error = ContentTypeError(mock_request_info, ())
98
+
99
+ mock_response = MagicMock()
100
+ mock_response.status = 400
101
+ mock_response.content_type = "application/problem+json"
102
+ mock_response.json = AsyncMock(side_effect=content_type_error)
103
+ mock_response.text = AsyncMock(return_value="Invalid JSON")
104
+ mock_authenticator.async_authenticated_request = AsyncMock(
105
+ return_value=mock_response
106
+ )
107
+
108
+ with pytest.raises(HttpException, match="Invalid JSON"):
109
+ await api.send_request("get_account_devices")
110
+
111
+
112
+ async def test_send_request_json_decode_error(mock_authenticator: Authenticator):
113
+ """Test that an empty json dict is returned on a JSON decode error."""
114
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
115
+ mock_request_info = Mock(real_url="http://mock.url")
116
+ content_type_error = ContentTypeError(mock_request_info, ())
117
+
118
+ mock_response = MagicMock()
119
+ mock_response.status = 200
120
+ mock_response.json = AsyncMock(side_effect=content_type_error)
121
+ mock_response.text = AsyncMock(return_value="<not_json>")
122
+ mock_response.url = "http://mock.url"
123
+ mock_authenticator.async_authenticated_request = AsyncMock(
124
+ return_value=mock_response
125
+ )
126
+
127
+ result = await api.send_request("get_account_devices")
128
+ assert result["json"] == {}
129
+
130
+
131
+ async def test_api_methods(mock_authenticator: Authenticator):
132
+ """Test that API methods call send_request with correct parameters."""
133
+ api = Api(auth=mock_authenticator, tz="Europe/London", lang="en-GB")
134
+ api.send_request = AsyncMock()
135
+
136
+ await api.async_get_account_devices()
137
+ api.send_request.assert_called_with(endpoint="get_account_devices")
138
+
139
+ await api.async_get_account_device("DEVICE_ID")
140
+ api.send_request.assert_called_with(
141
+ endpoint="get_account_device", DEVICE_ID="DEVICE_ID"
142
+ )
143
+
144
+ await api.async_get_device_daily_summaries("DEVICE_ID")
145
+ api.send_request.assert_called_with(
146
+ endpoint="get_device_daily_summaries", DEVICE_ID="DEVICE_ID"
147
+ )
148
+
149
+ await api.async_get_device_monthly_summaries("DEVICE_ID")
150
+ api.send_request.assert_called_with(
151
+ endpoint="get_device_monthly_summaries", DEVICE_ID="DEVICE_ID"
152
+ )
153
+
154
+ await api.async_get_device_parental_control_setting("DEVICE_ID")
155
+ api.send_request.assert_called_with(
156
+ endpoint="get_device_parental_control_setting", DEVICE_ID="DEVICE_ID"
157
+ )
158
+
159
+ await api.async_get_device_monthly_summary("DEVICE_ID", 2023, 11)
160
+ api.send_request.assert_called_with(
161
+ endpoint="get_device_monthly_summary",
162
+ DEVICE_ID="DEVICE_ID",
163
+ YEAR=2023,
164
+ MONTH="11",
165
+ )
166
+
167
+ await api.async_update_restriction_level({"some": "setting"})
168
+ api.send_request.assert_called_with(
169
+ endpoint="update_restriction_level", body={"some": "setting"}
170
+ )
171
+
172
+ await api.async_update_extra_playing_time("DEVICE_ID", -1)
173
+ api.send_request.assert_called_with(
174
+ endpoint="update_extra_playing_time",
175
+ body={"deviceId": "DEVICE_ID", "status": "TO_INFINITY"},
176
+ )
177
+
178
+ await api.async_update_extra_playing_time("DEVICE_ID", 60)
179
+ api.send_request.assert_called_with(
180
+ endpoint="update_extra_playing_time",
181
+ body={"deviceId": "DEVICE_ID", "additionalTime": 60, "status": "TO_ADDED"},
182
+ )
183
+
184
+ await api.async_update_play_timer({"some": "setting"})
185
+ api.send_request.assert_called_with(
186
+ endpoint="update_play_timer", body={"some": "setting"}
187
+ )
188
+
189
+ await api.async_update_unlock_code("1234", "DEVICE_ID")
190
+ api.send_request.assert_called_with(
191
+ endpoint="update_unlock_code",
192
+ body={"deviceId": "DEVICE_ID", "unlockCode": "1234"},
193
+ )
@@ -0,0 +1,110 @@
1
+ """Tests for the Device class."""
2
+
3
+ import copy
4
+ import pytest
5
+
6
+ from syrupy.assertion import SnapshotAssertion
7
+ from syrupy.filters import props
8
+
9
+ from pynintendoauth.exceptions import HttpException
10
+ from pynintendoparental.device import Device
11
+ from pynintendoparental.api import Api
12
+
13
+ from .helpers import load_fixture, clean_device_for_snapshot
14
+
15
+
16
+ async def test_device_parsing(mock_api: Api, snapshot: SnapshotAssertion):
17
+ """Test that the device class parsing works as expected."""
18
+ devices_response = await load_fixture("account_devices")
19
+ devices = await Device.from_devices_response(devices_response, mock_api)
20
+ assert len(devices) > 0
21
+ device = devices[0]
22
+
23
+ mock_api.async_get_device_monthly_summary.assert_called_once()
24
+
25
+ test_device = copy.deepcopy(device)
26
+ assert test_device.last_sync is not None
27
+ assert test_device.last_sync == device.extra["synchronizedParentalControlSetting"][
28
+ "synchronizedAt"
29
+ ]
30
+ del test_device.extra["synchronizedParentalControlSetting"]["synchronizedAt"]
31
+ assert test_device.last_sync is None
32
+ del test_device.extra["synchronizedParentalControlSetting"]
33
+ assert test_device.last_sync is None
34
+
35
+ assert clean_device_for_snapshot(device) == snapshot(
36
+ exclude=props("today_time_remaining")
37
+ )
38
+
39
+ async def test_player_discovery(mock_api: Api):
40
+ """Test that the device correctly parses players in different scenarios."""
41
+ devices_response = await load_fixture("account_devices")
42
+ devices = await Device.from_devices_response(devices_response, mock_api)
43
+ assert len(devices) > 0
44
+ device = devices[0]
45
+ assert len(device.players) > 0
46
+
47
+ # Test that the library correctly handles cases where the playerId is not found
48
+ monthly_summary_response = await load_fixture("device_monthly_summary")
49
+ players = copy.deepcopy(monthly_summary_response["summary"]["players"][0])
50
+ del players["profile"]["playerId"]
51
+ monthly_summary_response["summary"]["players"][0] = players
52
+
53
+ mock_api.async_get_device_monthly_summary.return_value = {
54
+ "status": 200,
55
+ "json": monthly_summary_response,
56
+ }
57
+
58
+ await device.update()
59
+ assert mock_api.async_get_device_monthly_summary.call_count == 2
60
+
61
+ async def test_get_player(mock_api: Api):
62
+ """Test that the get_player method works as expected."""
63
+ devices_response = await load_fixture("account_devices")
64
+ devices = await Device.from_devices_response(devices_response, mock_api)
65
+ assert len(devices) > 0
66
+ device = devices[0]
67
+ assert len(device.players) > 0
68
+
69
+ # Get the ID of the first player in the dictionary
70
+ first_player_id = list(device.players.keys())[0]
71
+ player = device.get_player(first_player_id)
72
+ assert player.player_id == first_player_id
73
+
74
+ # Now test that it errors
75
+ with pytest.raises(ValueError):
76
+ device.get_player("invalid_player_id")
77
+
78
+ @pytest.mark.parametrize(
79
+ "mock_api_function,side_effect,expected_log",
80
+ [
81
+ pytest.param(
82
+ "async_get_device_monthly_summary",
83
+ HttpException(404, "test", "test"),
84
+ "HTTP Exception raised while getting monthly summary for device {DEVICE_ID}: HTTP 404: test (test)"
85
+ ),
86
+ pytest.param(
87
+ "async_get_device_monthly_summaries",
88
+ HttpException(404, "test", "test"),
89
+ "Could not retrieve monthly summaries: HTTP 404: test (test)"
90
+ )
91
+ ]
92
+ )
93
+ async def test_get_monthly_summary_error(
94
+ mock_api: Api,
95
+ caplog: pytest.LogCaptureFixture,
96
+ mock_api_function: str,
97
+ side_effect: Exception,
98
+ expected_log: str
99
+ ):
100
+ """Test that get_monthly_summary calls correctly handle and log HTTP exceptions."""
101
+ devices_response = await load_fixture("account_devices")
102
+ devices = await Device.from_devices_response(devices_response, mock_api)
103
+ assert len(devices) > 0
104
+ device = devices[0]
105
+ assert len(device.players) > 0
106
+
107
+ getattr(mock_api, mock_api_function).side_effect = side_effect
108
+
109
+ await device.get_monthly_summary()
110
+ assert expected_log.format(DEVICE_ID=device.device_id) in caplog.text
@@ -0,0 +1,80 @@
1
+ """Tests for the pynintendoparental package."""
2
+
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ import pytest
6
+ from pynintendoauth.exceptions import HttpException
7
+
8
+ from pynintendoparental import NintendoParental, NoDevicesFoundException
9
+ from pynintendoparental.authenticator import Authenticator
10
+
11
+ from .helpers import load_fixture
12
+
13
+
14
+ @pytest.fixture(name="mock_api_init")
15
+ def fixture_mock_api_init():
16
+ """Fixture to mock the Api class."""
17
+ with patch("pynintendoparental.Api", autospec=True) as mock_api:
18
+ yield mock_api
19
+
20
+
21
+ async def test_create_method(
22
+ mock_authenticator: Authenticator, mock_api_init: AsyncMock
23
+ ):
24
+ """Test the create class method."""
25
+ devices_fixture = await load_fixture("account_devices")
26
+ device_id = devices_fixture["ownedDevices"][0]["deviceId"]
27
+ mock_api_instance = mock_api_init.return_value
28
+ mock_api_instance.async_get_account_devices.return_value = {"json": devices_fixture}
29
+
30
+ parental = await NintendoParental.create(mock_authenticator)
31
+
32
+ assert isinstance(parental, NintendoParental)
33
+ assert parental.account_id == mock_authenticator.account_id
34
+ assert len(parental.devices) == 1
35
+ assert device_id in parental.devices
36
+ mock_api_init.assert_called_once()
37
+ mock_api_instance.async_get_account_devices.assert_called_once()
38
+
39
+
40
+ async def test_no_devices_found(
41
+ mock_authenticator: Authenticator, mock_api_init: AsyncMock
42
+ ):
43
+ """Test the create class method when no devices are found."""
44
+ mock_api_instance = mock_api_init.return_value
45
+ mock_api_instance.async_get_account_devices.side_effect = HttpException(
46
+ 404, "Not Found"
47
+ )
48
+
49
+ with pytest.raises(NoDevicesFoundException):
50
+ await NintendoParental.create(mock_authenticator)
51
+
52
+
53
+ async def test_device_fetch_http_exception(
54
+ mock_authenticator: Authenticator, mock_api_init: AsyncMock
55
+ ):
56
+ """Test an HttpException when fetching devices."""
57
+ mock_api_instance = mock_api_init.return_value
58
+ mock_api_instance.async_get_account_devices.side_effect = HttpException(
59
+ 500, "Internal Server Error"
60
+ )
61
+
62
+ with pytest.raises(HttpException):
63
+ await NintendoParental.create(mock_authenticator)
64
+
65
+
66
+ async def test_device_update_exception(
67
+ mock_authenticator: Authenticator, mock_api_init: AsyncMock
68
+ ):
69
+ """Test an exception during device update doesn't stop creation."""
70
+ devices_fixture = await load_fixture("account_devices")
71
+ device_id = devices_fixture["ownedDevices"][0]["deviceId"]
72
+ mock_api_instance = mock_api_init.return_value
73
+ mock_api_instance.async_get_account_devices.return_value = {"json": devices_fixture}
74
+ with patch(
75
+ "pynintendoparental.device.Device.update",
76
+ new=AsyncMock(side_effect=Exception("Update Failed")),
77
+ ):
78
+ parental = await NintendoParental.create(mock_authenticator)
79
+ assert len(parental.devices) == 1
80
+ assert device_id in parental.devices
@@ -0,0 +1,46 @@
1
+ """Tests for the Player class."""
2
+
3
+ from syrupy.assertion import SnapshotAssertion
4
+ import copy
5
+
6
+ from pynintendoparental.player import Player
7
+
8
+ from .helpers import load_fixture
9
+
10
+
11
+ async def test_player_parsing(snapshot: SnapshotAssertion):
12
+ """Test that the player class parsing works as expected."""
13
+ daily_summaries_response = await load_fixture("device_daily_summaries")
14
+ players = Player.from_device_daily_summary(
15
+ daily_summaries_response["dailySummaries"]
16
+ )
17
+ assert len(players) > 0
18
+ player = players[0]
19
+
20
+ assert player == snapshot
21
+
22
+
23
+ async def test_player_update_from_daily_summary(snapshot: SnapshotAssertion):
24
+ """Test that updating a player from a daily summary works."""
25
+ daily_summaries_response = await load_fixture("device_daily_summaries")
26
+ players = Player.from_device_daily_summary(
27
+ daily_summaries_response["dailySummaries"]
28
+ )
29
+ assert len(players) > 0
30
+ player = players[0]
31
+
32
+ # Create a deep copy to modify for the update
33
+ updated_summary = copy.deepcopy(daily_summaries_response)
34
+
35
+ # Find the corresponding player in the new summary and update their data
36
+ for p_summary in updated_summary["dailySummaries"][0]["players"]:
37
+ if p_summary["profile"]["playerId"] == player.player_id:
38
+ p_summary["playingTime"] = 54321
39
+ p_summary["playedGames"] = [{"app": "new_app"}]
40
+ break
41
+
42
+ player.update_from_daily_summary(updated_summary["dailySummaries"])
43
+
44
+ assert player.playing_time == 54321
45
+ assert player.apps == [{"app": "new_app"}]
46
+ assert player == snapshot
@@ -1 +0,0 @@
1
- __version__ = "2.0.0"