Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions homeassistant/components/icloud/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PyiCloudFailedLoginException,
PyiCloudNoDevicesException,
PyiCloudServiceNotActivatedException,
PyiCloudServiceUnavailable,
)
from pyicloud.services.findmyiphone import AppleDevice

Expand Down Expand Up @@ -130,15 +131,21 @@ def setup(self) -> None:
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']}"
)
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/icloud/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions tests/components/icloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
)
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"
Expand All @@ -18,6 +20,30 @@
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,
Expand All @@ -29,3 +55,17 @@
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,
}
167 changes: 167 additions & 0 deletions tests/components/icloud/test_account.py
Original file line number Diff line number Diff line change
@@ -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"