Skip to content

Commit 3a69534

Browse files
authored
Bump pyiCloud to 2.2.0 (home-assistant#156485)
1 parent 8f2cedc commit 3a69534

File tree

6 files changed

+219
-5
lines changed

6 files changed

+219
-5
lines changed

homeassistant/components/icloud/account.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
PyiCloudFailedLoginException,
1313
PyiCloudNoDevicesException,
1414
PyiCloudServiceNotActivatedException,
15+
PyiCloudServiceUnavailable,
1516
)
1617
from pyicloud.services.findmyiphone import AppleDevice
1718

@@ -130,15 +131,21 @@ def setup(self) -> None:
130131
except (
131132
PyiCloudServiceNotActivatedException,
132133
PyiCloudNoDevicesException,
134+
PyiCloudServiceUnavailable,
133135
) as err:
134136
_LOGGER.error("No iCloud device found")
135137
raise ConfigEntryNotReady from err
136138

137-
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
139+
if user_info is None:
140+
raise ConfigEntryNotReady("No user info found in iCloud devices response")
141+
142+
self._owner_fullname = (
143+
f"{user_info.get('firstName')} {user_info.get('lastName')}"
144+
)
138145

139146
self._family_members_fullname = {}
140147
if user_info.get("membersInfo") is not None:
141-
for prs_id, member in user_info["membersInfo"].items():
148+
for prs_id, member in user_info.get("membersInfo").items():
142149
self._family_members_fullname[prs_id] = (
143150
f"{member['firstName']} {member['lastName']}"
144151
)

homeassistant/components/icloud/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"documentation": "https://www.home-assistant.io/integrations/icloud",
77
"iot_class": "cloud_polling",
88
"loggers": ["keyrings.alt", "pyicloud"],
9-
"requirements": ["pyicloud==2.1.0"]
9+
"requirements": ["pyicloud==2.2.0"]
1010
}

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/components/icloud/const.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
)
1111
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
1212

13+
FIRST_NAME = "user"
14+
LAST_NAME = "name"
1315
USERNAME = "[email protected]"
1416
USERNAME_2 = "[email protected]"
1517
PASSWORD = "password"
@@ -18,6 +20,30 @@
1820
MAX_INTERVAL = 15
1921
GPS_ACCURACY_THRESHOLD = 250
2022

23+
MEMBER_1_FIRST_NAME = "John"
24+
MEMBER_1_LAST_NAME = "TRAVOLTA"
25+
MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME
26+
MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower()
27+
MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com"
28+
29+
USER_INFO = {
30+
"accountFormatter": 0,
31+
"firstName": FIRST_NAME,
32+
"lastName": LAST_NAME,
33+
"membersInfo": {
34+
MEMBER_1_PERSON_ID: {
35+
"accountFormatter": 0,
36+
"firstName": MEMBER_1_FIRST_NAME,
37+
"lastName": MEMBER_1_LAST_NAME,
38+
"deviceFetchStatus": "DONE",
39+
"useAuthWidget": True,
40+
"isHSA": True,
41+
"appleId": MEMBER_1_APPLE_ID,
42+
}
43+
},
44+
"hasMembers": True,
45+
}
46+
2147
MOCK_CONFIG = {
2248
CONF_USERNAME: USERNAME,
2349
CONF_PASSWORD: PASSWORD,
@@ -29,3 +55,17 @@
2955
TRUSTED_DEVICES = [
3056
{"deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1"}
3157
]
58+
59+
DEVICE = {
60+
"id": "device1",
61+
"name": "iPhone",
62+
"deviceStatus": "200",
63+
"batteryStatus": "NotCharging",
64+
"batteryLevel": 0.8,
65+
"rawDeviceModel": "iPhone14,2",
66+
"deviceClass": "iPhone",
67+
"deviceDisplayName": "iPhone",
68+
"prsId": None,
69+
"lowPowerMode": False,
70+
"location": None,
71+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Tests for the iCloud account."""
2+
3+
from unittest.mock import MagicMock, Mock, patch
4+
5+
import pytest
6+
7+
from homeassistant.components.icloud.account import IcloudAccount
8+
from homeassistant.components.icloud.const import (
9+
CONF_GPS_ACCURACY_THRESHOLD,
10+
CONF_MAX_INTERVAL,
11+
CONF_WITH_FAMILY,
12+
DOMAIN,
13+
)
14+
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
15+
from homeassistant.core import HomeAssistant
16+
from homeassistant.exceptions import ConfigEntryNotReady
17+
from homeassistant.helpers.storage import Store
18+
19+
from .const import DEVICE, MOCK_CONFIG, USER_INFO, USERNAME
20+
21+
from tests.common import MockConfigEntry
22+
23+
24+
@pytest.fixture(name="mock_store")
25+
def mock_store_fixture():
26+
"""Mock the storage."""
27+
with patch("homeassistant.components.icloud.account.Store") as store_mock:
28+
store_instance = Mock(spec=Store)
29+
store_instance.path = "/mock/path"
30+
store_mock.return_value = store_instance
31+
yield store_instance
32+
33+
34+
@pytest.fixture(name="mock_icloud_service_no_userinfo")
35+
def mock_icloud_service_no_userinfo_fixture():
36+
"""Mock PyiCloudService with devices as dict but no userInfo."""
37+
with patch(
38+
"homeassistant.components.icloud.account.PyiCloudService"
39+
) as service_mock:
40+
service_instance = MagicMock()
41+
service_instance.requires_2fa = False
42+
mock_device = MagicMock()
43+
mock_device.status = iter(DEVICE)
44+
mock_device.user_info = None
45+
service_instance.devices = mock_device
46+
service_mock.return_value = service_instance
47+
yield service_instance
48+
49+
50+
async def test_setup_fails_when_userinfo_missing(
51+
hass: HomeAssistant,
52+
mock_store: Mock,
53+
mock_icloud_service_no_userinfo: MagicMock,
54+
) -> None:
55+
"""Test setup fails when userInfo is missing from devices dict."""
56+
57+
assert mock_icloud_service_no_userinfo is not None
58+
59+
config_entry = MockConfigEntry(
60+
domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME
61+
)
62+
config_entry.add_to_hass(hass)
63+
64+
account = IcloudAccount(
65+
hass,
66+
MOCK_CONFIG[CONF_USERNAME],
67+
MOCK_CONFIG[CONF_PASSWORD],
68+
mock_store,
69+
MOCK_CONFIG[CONF_WITH_FAMILY],
70+
MOCK_CONFIG[CONF_MAX_INTERVAL],
71+
MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD],
72+
config_entry,
73+
)
74+
75+
with pytest.raises(ConfigEntryNotReady, match="No user info found"):
76+
account.setup()
77+
78+
79+
class MockAppleDevice:
80+
"""Mock "Apple device" which implements the .status(...) method used by the account."""
81+
82+
def __init__(self, status_dict) -> None:
83+
"""Set status."""
84+
self._status = status_dict
85+
86+
def status(self, key):
87+
"""Return current status."""
88+
return self._status
89+
90+
def __getitem__(self, key):
91+
"""Allow indexing the device itself (device[KEY]) to proxy into the raw status dict."""
92+
return self._status.get(key)
93+
94+
95+
class MockDevicesContainer:
96+
"""Mock devices container which is iterable and indexable returning device status dicts."""
97+
98+
def __init__(self, userinfo, devices) -> None:
99+
"""Initialize with userinfo and list of device objects."""
100+
self.user_info = userinfo
101+
self._devices = devices
102+
103+
def __iter__(self):
104+
"""Iterate returns device objects (each must have .status(...))."""
105+
return iter(self._devices)
106+
107+
def __len__(self):
108+
"""Return number of devices."""
109+
return len(self._devices)
110+
111+
def __getitem__(self, idx):
112+
"""Indexing returns device object (which must have .status(...))."""
113+
dev = self._devices[idx]
114+
if hasattr(dev, "status"):
115+
return dev.status(None)
116+
return dev
117+
118+
119+
@pytest.fixture(name="mock_icloud_service")
120+
def mock_icloud_service_fixture():
121+
"""Mock PyiCloudService with devices container that is iterable and indexable returning status dict."""
122+
with patch(
123+
"homeassistant.components.icloud.account.PyiCloudService",
124+
) as service_mock:
125+
service_instance = MagicMock()
126+
device_obj = MockAppleDevice(DEVICE)
127+
devices_container = MockDevicesContainer(USER_INFO, [device_obj])
128+
129+
service_instance.devices = devices_container
130+
service_instance.requires_2fa = False
131+
132+
service_mock.return_value = service_instance
133+
yield service_instance
134+
135+
136+
async def test_setup_success_with_devices(
137+
hass: HomeAssistant,
138+
mock_store: Mock,
139+
mock_icloud_service: MagicMock,
140+
) -> None:
141+
"""Test successful setup with devices."""
142+
143+
assert mock_icloud_service is not None
144+
145+
config_entry = MockConfigEntry(
146+
domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME
147+
)
148+
config_entry.add_to_hass(hass)
149+
150+
account = IcloudAccount(
151+
hass,
152+
MOCK_CONFIG[CONF_USERNAME],
153+
MOCK_CONFIG[CONF_PASSWORD],
154+
mock_store,
155+
MOCK_CONFIG[CONF_WITH_FAMILY],
156+
MOCK_CONFIG[CONF_MAX_INTERVAL],
157+
MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD],
158+
config_entry,
159+
)
160+
161+
with patch.object(account, "_schedule_next_fetch"):
162+
account.setup()
163+
164+
assert account.api is not None
165+
assert account.owner_fullname == "user name"
166+
assert "johntravolta" in account.family_members_fullname
167+
assert account.family_members_fullname["johntravolta"] == "John TRAVOLTA"

0 commit comments

Comments
 (0)