core/tests/components/withings/common.py

323 lines
11 KiB
Python

"""Common data for for the withings component tests."""
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from unittest.mock import MagicMock
from urllib.parse import urlparse
from aiohttp.test_utils import TestClient
import arrow
from withings_api.common import (
MeasureGetMeasResponse,
NotifyAppli,
NotifyListResponse,
SleepGetSummaryResponse,
UserGetDeviceResponse,
)
from homeassistant import data_entry_flow
import homeassistant.components.api as api
from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN
import homeassistant.components.webhook as webhook
from homeassistant.components.withings import async_unload_entry
from homeassistant.components.withings.common import (
ConfigEntryWithingsApi,
DataManager,
get_all_data_managers,
)
import homeassistant.components.withings.const as const
from homeassistant.config import async_process_ha_core_config
from homeassistant.config_entries import SOURCE_USER, ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_EXTERNAL_URL,
CONF_UNIT_SYSTEM,
CONF_UNIT_SYSTEM_METRIC,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@dataclass
class ProfileConfig:
"""Data representing a user profile."""
profile: str
user_id: int
api_response_user_get_device: UserGetDeviceResponse | Exception
api_response_measure_get_meas: MeasureGetMeasResponse | Exception
api_response_sleep_get_summary: SleepGetSummaryResponse | Exception
api_response_notify_list: NotifyListResponse | Exception
api_response_notify_revoke: Exception | None
def new_profile_config(
profile: str,
user_id: int,
api_response_user_get_device: UserGetDeviceResponse | Exception | None = None,
api_response_measure_get_meas: MeasureGetMeasResponse | Exception | None = None,
api_response_sleep_get_summary: SleepGetSummaryResponse | Exception | None = None,
api_response_notify_list: NotifyListResponse | Exception | None = None,
api_response_notify_revoke: Exception | None = None,
) -> ProfileConfig:
"""Create a new profile config immutable object."""
return ProfileConfig(
profile=profile,
user_id=user_id,
api_response_user_get_device=api_response_user_get_device
or UserGetDeviceResponse(devices=[]),
api_response_measure_get_meas=api_response_measure_get_meas
or MeasureGetMeasResponse(
measuregrps=[],
more=False,
offset=0,
timezone=dt_util.UTC,
updatetime=arrow.get(12345),
),
api_response_sleep_get_summary=api_response_sleep_get_summary
or SleepGetSummaryResponse(more=False, offset=0, series=[]),
api_response_notify_list=api_response_notify_list
or NotifyListResponse(profiles=[]),
api_response_notify_revoke=api_response_notify_revoke,
)
@dataclass
class WebhookResponse:
"""Response data from a webhook."""
message: str
message_code: int
class ComponentFactory:
"""Manages the setup and unloading of the withing component and profiles."""
def __init__(
self,
hass: HomeAssistant,
api_class_mock: MagicMock,
hass_client_no_auth,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Initialize the object."""
self._hass = hass
self._api_class_mock = api_class_mock
self._hass_client = hass_client_no_auth
self._aioclient_mock = aioclient_mock
self._client_id = None
self._client_secret = None
self._profile_configs: tuple[ProfileConfig, ...] = ()
async def configure_component(
self,
client_id: str = "my_client_id",
client_secret: str = "my_client_secret",
profile_configs: tuple[ProfileConfig, ...] = (),
) -> None:
"""Configure the wihings component."""
self._client_id = client_id
self._client_secret = client_secret
self._profile_configs = profile_configs
hass_config = {
"homeassistant": {
CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
CONF_EXTERNAL_URL: "http://127.0.0.1:8080/",
},
api.DOMAIN: {},
const.DOMAIN: {
CONF_CLIENT_ID: self._client_id,
CONF_CLIENT_SECRET: self._client_secret,
const.CONF_USE_WEBHOOK: True,
},
}
await async_process_ha_core_config(self._hass, hass_config.get("homeassistant"))
assert await async_setup_component(self._hass, HA_DOMAIN, {})
assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config)
assert await async_setup_component(self._hass, const.DOMAIN, hass_config)
await self._hass.async_block_till_done()
@staticmethod
def _setup_api_method(api_method, value) -> None:
if isinstance(value, Exception):
api_method.side_effect = value
else:
api_method.return_value = value
async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi:
"""Set up a user profile through config flows."""
profile_config = next(
iter(
[
profile_config
for profile_config in self._profile_configs
if profile_config.user_id == user_id
]
)
)
api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi)
api_mock.config_entry = MockConfigEntry(
domain=const.DOMAIN,
data={"profile": profile_config.profile},
)
ComponentFactory._setup_api_method(
api_mock.user_get_device, profile_config.api_response_user_get_device
)
ComponentFactory._setup_api_method(
api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary
)
ComponentFactory._setup_api_method(
api_mock.measure_get_meas, profile_config.api_response_measure_get_meas
)
ComponentFactory._setup_api_method(
api_mock.notify_list, profile_config.api_response_notify_list
)
ComponentFactory._setup_api_method(
api_mock.notify_revoke, profile_config.api_response_notify_revoke
)
self._api_class_mock.reset_mocks()
self._api_class_mock.return_value = api_mock
# Get the withings config flow.
result = await self._hass.config_entries.flow.async_init(
const.DOMAIN, context={"source": SOURCE_USER}
)
assert result
# pylint: disable=protected-access
state = config_entry_oauth2_flow._encode_jwt(
self._hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP
assert result["url"] == (
"https://account.withings.com/oauth2_user/authorize2?"
f"response_type=code&client_id={self._client_id}&"
"redirect_uri=https://example.com/auth/external/callback&"
f"state={state}"
"&scope=user.info,user.metrics,user.activity,user.sleepevents"
)
# Simulate user being redirected from withings site.
client: TestClient = await self._hass_client()
resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
self._aioclient_mock.clear_requests()
self._aioclient_mock.post(
"https://wbsapi.withings.net/v2/oauth2",
json={
"body": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"userid": profile_config.user_id,
},
},
)
# Present user with a list of profiles to choose from.
result = await self._hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") == "form"
assert result.get("step_id") == "profile"
assert "profile" in result.get("data_schema").schema
# Provide the user profile.
result = await self._hass.config_entries.flow.async_configure(
result["flow_id"], {const.PROFILE: profile_config.profile}
)
# Finish the config flow by calling it again.
assert result.get("type") == "create_entry"
assert result.get("result")
config_data = result.get("result").data
assert config_data.get(const.PROFILE) == profile_config.profile
assert config_data.get("auth_implementation") == const.DOMAIN
assert config_data.get("token")
# Wait for remaining tasks to complete.
await self._hass.async_block_till_done()
# Mock the webhook.
data_manager = get_data_manager_by_user_id(self._hass, user_id)
self._aioclient_mock.clear_requests()
self._aioclient_mock.request(
"HEAD",
data_manager.webhook_config.url,
)
return self._api_class_mock.return_value
async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse:
"""Call the webhook to notify of data changes."""
client: TestClient = await self._hass_client()
data_manager = get_data_manager_by_user_id(self._hass, user_id)
resp = await client.post(
urlparse(data_manager.webhook_config.url).path,
data={"userid": user_id, "appli": appli.value},
)
# Wait for remaining tasks to complete.
await self._hass.async_block_till_done()
data = await resp.json()
resp.close()
return WebhookResponse(message=data["message"], message_code=data["code"])
async def unload(self, profile: ProfileConfig) -> None:
"""Unload the component for a specific user."""
config_entries = get_config_entries_for_user_id(self._hass, profile.user_id)
for config_entry in config_entries:
await async_unload_entry(self._hass, config_entry)
await self._hass.async_block_till_done()
assert not get_data_manager_by_user_id(self._hass, profile.user_id)
def get_config_entries_for_user_id(
hass: HomeAssistant, user_id: int
) -> tuple[ConfigEntry]:
"""Get a list of config entries that apply to a specific withings user."""
return tuple(
config_entry
for config_entry in hass.config_entries.async_entries(const.DOMAIN)
if config_entry.data.get("token", {}).get("userid") == user_id
)
def get_data_manager_by_user_id(
hass: HomeAssistant, user_id: int
) -> DataManager | None:
"""Get a data manager by the user id."""
return next(
iter(
[
data_manager
for data_manager in get_all_data_managers(hass)
if data_manager.user_id == user_id
]
),
None,
)