diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 35e04d20ecd..d1d35def76b 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -12,6 +12,7 @@ from pyicloud.exceptions import ( PyiCloudFailedLoginException, PyiCloudNoDevicesException, PyiCloudServiceNotActivatedException, + PyiCloudServiceUnavailable, ) from pyicloud.services.findmyiphone import AppleDevice @@ -130,15 +131,21 @@ class IcloudAccount: except ( PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException, + PyiCloudServiceUnavailable, ) as err: _LOGGER.error("No iCloud device found") raise ConfigEntryNotReady from err - self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" + if user_info is None: + raise ConfigEntryNotReady("No user info found in iCloud devices response") + + self._owner_fullname = ( + f"{user_info.get('firstName')} {user_info.get('lastName')}" + ) self._family_members_fullname = {} if user_info.get("membersInfo") is not None: - for prs_id, member in user_info["membersInfo"].items(): + for prs_id, member in user_info.get("membersInfo").items(): self._family_members_fullname[prs_id] = ( f"{member['firstName']} {member['lastName']}" ) diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 60a4063a079..0cf6b89d20c 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/icloud", "iot_class": "cloud_polling", "loggers": ["keyrings.alt", "pyicloud"], - "requirements": ["pyicloud==2.1.0"] + "requirements": ["pyicloud==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 107cdbac3f3..75ea9f3b08b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2063,7 +2063,7 @@ pyhomeworks==1.1.2 pyialarm==2.2.0 # homeassistant.components.icloud -pyicloud==2.1.0 +pyicloud==2.2.0 # homeassistant.components.insteon pyinsteon==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f64c9f8fb1..dd926ad4464 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1722,7 +1722,7 @@ pyhomeworks==1.1.2 pyialarm==2.2.0 # homeassistant.components.icloud -pyicloud==2.1.0 +pyicloud==2.2.0 # homeassistant.components.insteon pyinsteon==1.6.3 diff --git a/tests/components/icloud/const.py b/tests/components/icloud/const.py index 463ae6a7da2..d2bbfeb717c 100644 --- a/tests/components/icloud/const.py +++ b/tests/components/icloud/const.py @@ -10,6 +10,8 @@ from homeassistant.components.icloud.const import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +FIRST_NAME = "user" +LAST_NAME = "name" USERNAME = "username@me.com" USERNAME_2 = "second_username@icloud.com" PASSWORD = "password" @@ -18,6 +20,30 @@ WITH_FAMILY = True MAX_INTERVAL = 15 GPS_ACCURACY_THRESHOLD = 250 +MEMBER_1_FIRST_NAME = "John" +MEMBER_1_LAST_NAME = "TRAVOLTA" +MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME +MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower() +MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com" + +USER_INFO = { + "accountFormatter": 0, + "firstName": FIRST_NAME, + "lastName": LAST_NAME, + "membersInfo": { + MEMBER_1_PERSON_ID: { + "accountFormatter": 0, + "firstName": MEMBER_1_FIRST_NAME, + "lastName": MEMBER_1_LAST_NAME, + "deviceFetchStatus": "DONE", + "useAuthWidget": True, + "isHSA": True, + "appleId": MEMBER_1_APPLE_ID, + } + }, + "hasMembers": True, +} + MOCK_CONFIG = { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, @@ -29,3 +55,17 @@ MOCK_CONFIG = { TRUSTED_DEVICES = [ {"deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1"} ] + +DEVICE = { + "id": "device1", + "name": "iPhone", + "deviceStatus": "200", + "batteryStatus": "NotCharging", + "batteryLevel": 0.8, + "rawDeviceModel": "iPhone14,2", + "deviceClass": "iPhone", + "deviceDisplayName": "iPhone", + "prsId": None, + "lowPowerMode": False, + "location": None, +} diff --git a/tests/components/icloud/test_account.py b/tests/components/icloud/test_account.py new file mode 100644 index 00000000000..52e6799f417 --- /dev/null +++ b/tests/components/icloud/test_account.py @@ -0,0 +1,167 @@ +"""Tests for the iCloud account.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.components.icloud.account import IcloudAccount +from homeassistant.components.icloud.const import ( + CONF_GPS_ACCURACY_THRESHOLD, + CONF_MAX_INTERVAL, + CONF_WITH_FAMILY, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.storage import Store + +from .const import DEVICE, MOCK_CONFIG, USER_INFO, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_store") +def mock_store_fixture(): + """Mock the storage.""" + with patch("homeassistant.components.icloud.account.Store") as store_mock: + store_instance = Mock(spec=Store) + store_instance.path = "/mock/path" + store_mock.return_value = store_instance + yield store_instance + + +@pytest.fixture(name="mock_icloud_service_no_userinfo") +def mock_icloud_service_no_userinfo_fixture(): + """Mock PyiCloudService with devices as dict but no userInfo.""" + with patch( + "homeassistant.components.icloud.account.PyiCloudService" + ) as service_mock: + service_instance = MagicMock() + service_instance.requires_2fa = False + mock_device = MagicMock() + mock_device.status = iter(DEVICE) + mock_device.user_info = None + service_instance.devices = mock_device + service_mock.return_value = service_instance + yield service_instance + + +async def test_setup_fails_when_userinfo_missing( + hass: HomeAssistant, + mock_store: Mock, + mock_icloud_service_no_userinfo: MagicMock, +) -> None: + """Test setup fails when userInfo is missing from devices dict.""" + + assert mock_icloud_service_no_userinfo is not None + + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME + ) + config_entry.add_to_hass(hass) + + account = IcloudAccount( + hass, + MOCK_CONFIG[CONF_USERNAME], + MOCK_CONFIG[CONF_PASSWORD], + mock_store, + MOCK_CONFIG[CONF_WITH_FAMILY], + MOCK_CONFIG[CONF_MAX_INTERVAL], + MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD], + config_entry, + ) + + with pytest.raises(ConfigEntryNotReady, match="No user info found"): + account.setup() + + +class MockAppleDevice: + """Mock "Apple device" which implements the .status(...) method used by the account.""" + + def __init__(self, status_dict) -> None: + """Set status.""" + self._status = status_dict + + def status(self, key): + """Return current status.""" + return self._status + + def __getitem__(self, key): + """Allow indexing the device itself (device[KEY]) to proxy into the raw status dict.""" + return self._status.get(key) + + +class MockDevicesContainer: + """Mock devices container which is iterable and indexable returning device status dicts.""" + + def __init__(self, userinfo, devices) -> None: + """Initialize with userinfo and list of device objects.""" + self.user_info = userinfo + self._devices = devices + + def __iter__(self): + """Iterate returns device objects (each must have .status(...)).""" + return iter(self._devices) + + def __len__(self): + """Return number of devices.""" + return len(self._devices) + + def __getitem__(self, idx): + """Indexing returns device object (which must have .status(...)).""" + dev = self._devices[idx] + if hasattr(dev, "status"): + return dev.status(None) + return dev + + +@pytest.fixture(name="mock_icloud_service") +def mock_icloud_service_fixture(): + """Mock PyiCloudService with devices container that is iterable and indexable returning status dict.""" + with patch( + "homeassistant.components.icloud.account.PyiCloudService", + ) as service_mock: + service_instance = MagicMock() + device_obj = MockAppleDevice(DEVICE) + devices_container = MockDevicesContainer(USER_INFO, [device_obj]) + + service_instance.devices = devices_container + service_instance.requires_2fa = False + + service_mock.return_value = service_instance + yield service_instance + + +async def test_setup_success_with_devices( + hass: HomeAssistant, + mock_store: Mock, + mock_icloud_service: MagicMock, +) -> None: + """Test successful setup with devices.""" + + assert mock_icloud_service is not None + + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME + ) + config_entry.add_to_hass(hass) + + account = IcloudAccount( + hass, + MOCK_CONFIG[CONF_USERNAME], + MOCK_CONFIG[CONF_PASSWORD], + mock_store, + MOCK_CONFIG[CONF_WITH_FAMILY], + MOCK_CONFIG[CONF_MAX_INTERVAL], + MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD], + config_entry, + ) + + with patch.object(account, "_schedule_next_fetch"): + account.setup() + + assert account.api is not None + assert account.owner_fullname == "user name" + assert "johntravolta" in account.family_members_fullname + assert account.family_members_fullname["johntravolta"] == "John TRAVOLTA"