From 075030f15a046708ae544e17b3bce7740e0eed9c Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 14 Apr 2020 17:26:53 -0500 Subject: [PATCH] Update SmartThings config flow to be entirely UI based (#34163) * bump pysmartthings 0.7.1 * Update config flow to use UI * Code review comments and fix for resetting oauth client * Replace html with markdown --- .../smartthings/.translations/en.json | 63 +-- .../components/smartthings/config_flow.py | 219 +++++---- homeassistant/components/smartthings/const.py | 1 - .../components/smartthings/manifest.json | 2 +- .../components/smartthings/smartapp.py | 88 ++-- .../components/smartthings/strings.json | 27 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../smartthings/test_config_flow.py | 426 +++++++++++------- tests/components/smartthings/test_smartapp.py | 95 ++-- 10 files changed, 543 insertions(+), 382 deletions(-) diff --git a/homeassistant/components/smartthings/.translations/en.json b/homeassistant/components/smartthings/.translations/en.json index e35035b8fa0..94a8f0c8bc4 100644 --- a/homeassistant/components/smartthings/.translations/en.json +++ b/homeassistant/components/smartthings/.translations/en.json @@ -1,28 +1,39 @@ { - "config": { - "error": { - "app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", - "app_setup_error": "Unable to setup the SmartApp. Please try again.", - "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.", - "token_already_setup": "The token has already been setup.", - "token_forbidden": "The token does not have the required OAuth scopes.", - "token_invalid_format": "The token must be in the UID/GUID format", - "token_unauthorized": "The token is invalid or no longer authorized.", - "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements." - }, - "step": { - "user": { - "data": { - "access_token": "Access Token" - }, - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", - "title": "Enter Personal Access Token" - }, - "wait_install": { - "description": "Please install the Home Assistant SmartApp in at least one location and click submit.", - "title": "Install SmartApp" - } - }, - "title": "SmartThings" + "config": { + "title": "SmartThings", + "step": { + "user": { + "title": "Confirm Callback URL", + "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." + }, + "pat": { + "title": "Enter Personal Access Token", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "data": { + "access_token": "Access Token" + } + }, + "select_location": { + "title": "Select Location", + "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", + "data": { + "location_id": "Location" + } + }, + "authorize": { + "title": "Authorize Home Assistant" + } + }, + "abort": { + "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", + "no_available_locations": "There are no available SmartThings Locations to setup in Home Assistant." + }, + "error": { + "token_invalid_format": "The token must be in the UID/GUID format", + "token_unauthorized": "The token is invalid or no longer authorized.", + "token_forbidden": "The token does not have the required OAuth scopes.", + "app_setup_error": "Unable to setup the SmartApp. Please try again.", + "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." } -} \ No newline at end of file + } +} diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 249635f9b2a..cb4623cea1c 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -3,26 +3,30 @@ import logging from aiohttp import ClientResponseError from pysmartthings import APIResponseError, AppOAuth, SmartThings +from pysmartthings.installedapp import format_install_url import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN from homeassistant.helpers.aiohttp_client import async_get_clientsession +# pylint: disable=unused-import from .const import ( APP_OAUTH_CLIENT_NAME, APP_OAUTH_SCOPES, CONF_APP_ID, - CONF_INSTALLED_APPS, + CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET, + CONF_REFRESH_TOKEN, DOMAIN, VAL_UID_MATCHER, ) from .smartapp import ( create_app, find_app, + get_webhook_url, setup_smartapp, setup_smartapp_endpoint, update_app, @@ -32,23 +36,8 @@ from .smartapp import ( _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register(DOMAIN) -class SmartThingsFlowHandler(config_entries.ConfigFlow): - """ - Handle configuration of SmartThings integrations. - - Any number of integrations are supported. The high level flow follows: - 1) Flow initiated - a) User initiates through the UI - b) Re-configuration of a failed entry setup - 2) Enter access token - a) Check not already setup - b) Validate format - c) Setup SmartApp - 3) Wait for Installation - a) Check user installed into one or more locations - b) Config entries setup for all installations - """ +class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle configuration of SmartThings integrations.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH @@ -60,55 +49,84 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): self.api = None self.oauth_client_secret = None self.oauth_client_id = None + self.installed_app_id = None + self.refresh_token = None + self.location_id = None async def async_step_import(self, user_input=None): """Occurs when a previously entry setup fails and is re-initiated.""" return await self.async_step_user(user_input) async def async_step_user(self, user_input=None): - """Get access token and validate it.""" + """Validate and confirm webhook setup.""" + await setup_smartapp_endpoint(self.hass) + webhook_url = get_webhook_url(self.hass) + + # Abort if the webhook is invalid + if not validate_webhook_requirements(self.hass): + return self.async_abort( + reason="invalid_webhook_url", + description_placeholders={ + "webhook_url": webhook_url, + "component_url": "https://www.home-assistant.io/integrations/smartthings/", + }, + ) + + # Show the confirmation + if user_input is None: + return self.async_show_form( + step_id="user", description_placeholders={"webhook_url": webhook_url}, + ) + + # Show the next screen + return await self.async_step_pat() + + async def async_step_pat(self, user_input=None): + """Get the Personal Access Token and validate it.""" errors = {} if user_input is None or CONF_ACCESS_TOKEN not in user_input: - return self._show_step_user(errors) + return self._show_step_pat(errors) - self.access_token = user_input.get(CONF_ACCESS_TOKEN, "") - self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) + self.access_token = user_input[CONF_ACCESS_TOKEN] # Ensure token is a UUID if not VAL_UID_MATCHER.match(self.access_token): errors[CONF_ACCESS_TOKEN] = "token_invalid_format" - return self._show_step_user(errors) - # Check not already setup in another entry - if any( - entry.data.get(CONF_ACCESS_TOKEN) == self.access_token - for entry in self.hass.config_entries.async_entries(DOMAIN) - ): - errors[CONF_ACCESS_TOKEN] = "token_already_setup" - return self._show_step_user(errors) + return self._show_step_pat(errors) # Setup end-point - await setup_smartapp_endpoint(self.hass) - - if not validate_webhook_requirements(self.hass): - errors["base"] = "base_url_not_https" - return self._show_step_user(errors) - + self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) try: app = await find_app(self.hass, self.api) if app: await app.refresh() # load all attributes await update_app(self.hass, app) - # Get oauth client id/secret by regenerating it - app_oauth = AppOAuth(app.app_id) - app_oauth.client_name = APP_OAUTH_CLIENT_NAME - app_oauth.scope.extend(APP_OAUTH_SCOPES) - client = await self.api.generate_app_oauth(app_oauth) + # Find an existing entry to copy the oauth client + existing = next( + ( + entry + for entry in self._async_current_entries() + if entry.data[CONF_APP_ID] == app.app_id + ), + None, + ) + if existing: + self.oauth_client_id = existing.data[CONF_OAUTH_CLIENT_ID] + self.oauth_client_secret = existing.data[CONF_OAUTH_CLIENT_SECRET] + else: + # Get oauth client id/secret by regenerating it + app_oauth = AppOAuth(app.app_id) + app_oauth.client_name = APP_OAUTH_CLIENT_NAME + app_oauth.scope.extend(APP_OAUTH_SCOPES) + client = await self.api.generate_app_oauth(app_oauth) + self.oauth_client_secret = client.client_secret + self.oauth_client_id = client.client_id else: app, client = await create_app(self.hass, self.api) + self.oauth_client_secret = client.client_secret + self.oauth_client_id = client.client_id setup_smartapp(self.hass, app) self.app_id = app.app_id - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id except APIResponseError as ex: if ex.is_target_error(): @@ -118,58 +136,80 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): _LOGGER.exception( "API error setting up the SmartApp: %s", ex.raw_error_response ) - return self._show_step_user(errors) + return self._show_step_pat(errors) except ClientResponseError as ex: if ex.status == 401: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" + _LOGGER.debug( + "Unauthorized error received setting up SmartApp", exc_info=True + ) elif ex.status == HTTP_FORBIDDEN: errors[CONF_ACCESS_TOKEN] = "token_forbidden" + _LOGGER.debug( + "Forbidden error received setting up SmartApp", exc_info=True + ) else: errors["base"] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_user(errors) + return self._show_step_pat(errors) except Exception: # pylint:disable=broad-except errors["base"] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_user(errors) + return self._show_step_pat(errors) - return await self.async_step_wait_install() + return await self.async_step_select_location() - async def async_step_wait_install(self, user_input=None): - """Wait for SmartApp installation.""" - errors = {} - if user_input is None: - return self._show_step_wait_install(errors) + async def async_step_select_location(self, user_input=None): + """Ask user to select the location to setup.""" + if user_input is None or CONF_LOCATION_ID not in user_input: + # Get available locations + existing_locations = [ + entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries() + ] + locations = await self.api.locations() + locations_options = { + location.location_id: location.name + for location in locations + if location.location_id not in existing_locations + } + if not locations_options: + return self.async_abort(reason="no_available_locations") - # Find installed apps that were authorized - installed_apps = self.hass.data[DOMAIN][CONF_INSTALLED_APPS].copy() - if not installed_apps: - errors["base"] = "app_not_installed" - return self._show_step_wait_install(errors) - self.hass.data[DOMAIN][CONF_INSTALLED_APPS].clear() - - # Enrich the data - for installed_app in installed_apps: - installed_app[CONF_APP_ID] = self.app_id - installed_app[CONF_ACCESS_TOKEN] = self.access_token - installed_app[CONF_OAUTH_CLIENT_ID] = self.oauth_client_id - installed_app[CONF_OAUTH_CLIENT_SECRET] = self.oauth_client_secret - - # User may have installed the SmartApp in more than one SmartThings - # location. Config flows are created for the additional installations - for installed_app in installed_apps[1:]: - self.hass.async_create_task( - self.hass.config_entries.flow.async_init( - DOMAIN, context={"source": "install"}, data=installed_app - ) + return self.async_show_form( + step_id="select_location", + data_schema=vol.Schema( + {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)} + ), ) - # Create config entity for the first one. - return await self.async_step_install(installed_apps[0]) + self.location_id = user_input[CONF_LOCATION_ID] + return await self.async_step_authorize() + + async def async_step_authorize(self, user_input=None): + """Wait for the user to authorize the app installation.""" + user_input = {} if user_input is None else user_input + self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) + self.refresh_token = user_input.get(CONF_REFRESH_TOKEN) + if self.installed_app_id is None: + # Launch the external setup URL + url = format_install_url(self.app_id, self.location_id) + return self.async_external_step(step_id="authorize", url=url) + + return self.async_external_step_done(next_step_id="install") + + def _show_step_pat(self, errors): + if self.access_token is None: + # Get the token from an existing entry to make it easier to setup multiple locations. + self.access_token = next( + ( + entry.data.get(CONF_ACCESS_TOKEN) + for entry in self._async_current_entries() + ), + None, + ) - def _show_step_user(self, errors): return self.async_show_form( - step_id="user", + step_id="pat", data_schema=vol.Schema( {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} ), @@ -180,21 +220,18 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): }, ) - def _show_step_wait_install(self, errors): - return self.async_show_form(step_id="wait_install", errors=errors) - async def async_step_install(self, data=None): - """ - Create a config entry at completion of a flow. - - Launched when the user completes the flow or when the SmartApp - is installed into an additional location. - """ - if not self.api: - # Launched from the SmartApp install event handler - self.api = SmartThings( - async_get_clientsession(self.hass), data[CONF_ACCESS_TOKEN] - ) + """Create a config entry at completion of a flow and authorization of the app.""" + data = { + CONF_ACCESS_TOKEN: self.access_token, + CONF_REFRESH_TOKEN: self.refresh_token, + CONF_OAUTH_CLIENT_ID: self.oauth_client_id, + CONF_OAUTH_CLIENT_SECRET: self.oauth_client_secret, + CONF_LOCATION_ID: self.location_id, + CONF_APP_ID: self.app_id, + CONF_INSTALLED_APP_ID: self.installed_app_id, + } location = await self.api.location(data[CONF_LOCATION_ID]) + return self.async_create_entry(title=location.name, data=data) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index c258101da70..8e323c0a715 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -8,7 +8,6 @@ APP_NAME_PREFIX = "homeassistant." CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_INSTALLED_APP_ID = "installed_app_id" -CONF_INSTALLED_APPS = "installed_apps" CONF_INSTANCE_ID = "instance_id" CONF_LOCATION_ID = "location_id" CONF_OAUTH_CLIENT_ID = "client_id" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9fa156e0b28..4c78bbb23df 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -3,7 +3,7 @@ "name": "Smartthings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smartthings", - "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.0"], + "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.1"], "dependencies": ["webhook"], "after_dependencies": ["cloud"], "codeowners": ["@andrewsayre"] diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 402fdbd0715..0b86a430d89 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -36,10 +36,8 @@ from .const import ( APP_NAME_PREFIX, APP_OAUTH_CLIENT_NAME, APP_OAUTH_SCOPES, - CONF_APP_ID, CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, - CONF_INSTALLED_APPS, CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, @@ -258,7 +256,6 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType): CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], # Will not be present if not enabled CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), - CONF_INSTALLED_APPS: [], } _LOGGER.debug( "Setup endpoint for %s", @@ -370,40 +367,30 @@ async def smartapp_sync_subscriptions( async def smartapp_install(hass: HomeAssistantType, req, resp, app): - """ - Handle when a SmartApp is installed by the user into a location. - - Create a config entry representing the installation if this is not - the first installation under the account, otherwise store the data - for the config flow. - """ - install_data = { - CONF_INSTALLED_APP_ID: req.installed_app_id, - CONF_LOCATION_ID: req.location_id, - CONF_REFRESH_TOKEN: req.refresh_token, - } - # App attributes (client id/secret, etc...) are copied from another entry - # with the same parent app_id. If one is not found, the install data is - # stored for the config flow to retrieve during the wait step. - entry = next( + """Handle a SmartApp installation and continue the config flow.""" + flow = next( ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_APP_ID] == app.app_id + flow + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN ), None, ) - if entry: - data = entry.data.copy() - data.update(install_data) - # Add as job not needed because the current coroutine was invoked - # from the dispatcher and is not being awaited. - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "install"}, data=data + if flow is not None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: req.installed_app_id, + CONF_LOCATION_ID: req.location_id, + CONF_REFRESH_TOKEN: req.refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, ) - else: - # Store the data where the flow can find it - hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data) _LOGGER.debug( "Installed SmartApp '%s' under parent app '%s'", @@ -413,12 +400,7 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): async def smartapp_update(hass: HomeAssistantType, req, resp, app): - """ - Handle when a SmartApp is updated (reconfigured) by the user. - - Store the refresh token in the config entry. - """ - # Update refresh token in config entry + """Handle a SmartApp update and either update the entry or continue the flow.""" entry = next( ( entry @@ -431,6 +413,36 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app): hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} ) + _LOGGER.debug( + "Updated config entry '%s' for SmartApp '%s' under parent app '%s'", + entry.entry_id, + req.installed_app_id, + app.app_id, + ) + + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN + ), + None, + ) + if flow is not None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: req.installed_app_id, + CONF_LOCATION_ID: req.location_id, + CONF_REFRESH_TOKEN: req.refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) _LOGGER.debug( "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 99173c830a0..94a8f0c8bc4 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -3,26 +3,37 @@ "title": "SmartThings", "step": { "user": { + "title": "Confirm Callback URL", + "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." + }, + "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", "data": { "access_token": "Access Token" } }, - "wait_install": { - "title": "Install SmartApp", - "description": "Please install the Home Assistant SmartApp in at least one location and click submit." + "select_location": { + "title": "Select Location", + "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", + "data": { + "location_id": "Location" + } + }, + "authorize": { + "title": "Authorize Home Assistant" } }, + "abort": { + "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", + "no_available_locations": "There are no available SmartThings Locations to setup in Home Assistant." + }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", "token_unauthorized": "The token is invalid or no longer authorized.", "token_forbidden": "The token does not have the required OAuth scopes.", - "token_already_setup": "The token has already been setup.", "app_setup_error": "Unable to setup the SmartApp. Please try again.", - "app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", - "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.", - "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements." + "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." } } } diff --git a/requirements_all.txt b/requirements_all.txt index 928fefa510c..e6192d55345 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1551,7 +1551,7 @@ pysma==0.3.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.0 +pysmartthings==0.7.1 # homeassistant.components.smarty pysmarty==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8718f38528..079cbc896c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -611,7 +611,7 @@ pysma==0.3.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.0 +pysmartthings==0.7.1 # homeassistant.components.soma pysoma==0.0.10 diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index cd43659fccb..dc046f718a8 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -4,207 +4,266 @@ from uuid import uuid4 from aiohttp import ClientResponseError from asynctest import Mock, patch from pysmartthings import APIResponseError +from pysmartthings.installedapp import format_install_url from homeassistant import data_entry_flow from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.config_flow import SmartThingsFlowHandler from homeassistant.components.smartthings.const import ( + CONF_APP_ID, CONF_INSTALLED_APP_ID, - CONF_INSTALLED_APPS, CONF_LOCATION_ID, + CONF_OAUTH_CLIENT_ID, + CONF_OAUTH_CLIENT_SECRET, CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND -from homeassistant.setup import async_setup_component +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_NOT_FOUND from tests.common import MockConfigEntry, mock_coro -async def test_step_user(hass): - """Test the access token form is shown for a user initiated flow.""" +async def test_step_import(hass): + """Test import returns user.""" flow = SmartThingsFlowHandler() flow.hass = hass - result = await flow.async_step_user() - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - -async def test_step_init(hass): - """Test the access token form is shown for an init flow.""" - flow = SmartThingsFlowHandler() - flow.hass = hass result = await flow.async_step_import() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) -async def test_base_url_not_https(hass): - """Test the base_url parameter starts with https://.""" +async def test_step_user(hass): + """Test the webhook confirmation is shown.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + +async def test_step_user_aborts_invalid_webhook(hass): + """Test flow aborts if webhook is invalid.""" hass.config.api.base_url = "http://0.0.0.0" flow = SmartThingsFlowHandler() flow.hass = hass - result = await flow.async_step_user({"access_token": str(uuid4())}) + + result = await flow.async_step_user() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "invalid_webhook_url" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + assert "component_url" in result["description_placeholders"] + + +async def test_step_user_advances_to_pat(hass): + """Test user step advances to the pat step.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + result = await flow.async_step_user({}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "base_url_not_https"} + assert result["step_id"] == "pat" -async def test_invalid_token_format(hass): +async def test_step_pat(hass): + """Test pat step shows the input form.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + result = await flow.async_step_pat() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["errors"] == {} + assert result["data_schema"]({CONF_ACCESS_TOKEN: ""}) == {CONF_ACCESS_TOKEN: ""} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_step_pat_defaults_token(hass): + """Test pat form defaults the token from another entry.""" + token = str(uuid4()) + entry = MockConfigEntry(domain=DOMAIN, data={CONF_ACCESS_TOKEN: token}) + entry.add_to_hass(hass) + flow = SmartThingsFlowHandler() + flow.hass = hass + + result = await flow.async_step_pat() + + assert flow.access_token == token + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["errors"] == {} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_step_pat_invalid_token(hass): """Test an error is shown for invalid token formats.""" flow = SmartThingsFlowHandler() flow.hass = hass - result = await flow.async_step_user({"access_token": "123456789"}) + token = "123456789" + + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} assert result["errors"] == {"access_token": "token_invalid_format"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_token_already_setup(hass): - """Test an error is shown when the token is already setup.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - token = str(uuid4()) - entry = MockConfigEntry(domain=DOMAIN, data={"access_token": token}) - entry.add_to_hass(hass) - - result = await flow.async_step_user({"access_token": token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"access_token": "token_already_setup"} - - -async def test_token_unauthorized(hass, smartthings_mock): +async def test_step_pat_unauthorized(hass, smartthings_mock): """Test an error is shown when the token is not authorized.""" flow = SmartThingsFlowHandler() flow.hass = hass - request_info = Mock(real_url="http://example.com") smartthings_mock.apps.side_effect = ClientResponseError( request_info=request_info, history=None, status=401 ) + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"access_token": "token_unauthorized"} + assert result["step_id"] == "pat" + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} -async def test_token_forbidden(hass, smartthings_mock): +async def test_step_pat_forbidden(hass, smartthings_mock): """Test an error is shown when the token is forbidden.""" flow = SmartThingsFlowHandler() flow.hass = hass - request_info = Mock(real_url="http://example.com") smartthings_mock.apps.side_effect = ClientResponseError( request_info=request_info, history=None, status=HTTP_FORBIDDEN ) + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"access_token": "token_forbidden"} + assert result["step_id"] == "pat" + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} -async def test_webhook_error(hass, smartthings_mock): - """Test an error is when there's an error with the webhook endpoint.""" +async def test_step_pat_webhook_error(hass, smartthings_mock): + """Test an error is shown when there's an problem with the webhook endpoint.""" flow = SmartThingsFlowHandler() flow.hass = hass - data = {"error": {}} request_info = Mock(real_url="http://example.com") error = APIResponseError( request_info=request_info, history=None, data=data, status=422 ) error.is_target_error = Mock(return_value=True) - smartthings_mock.apps.side_effect = error + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["step_id"] == "pat" assert result["errors"] == {"base": "webhook_error"} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} -async def test_api_error(hass, smartthings_mock): +async def test_step_pat_api_error(hass, smartthings_mock): """Test an error is shown when other API errors occur.""" flow = SmartThingsFlowHandler() flow.hass = hass - data = {"error": {}} request_info = Mock(real_url="http://example.com") error = APIResponseError( request_info=request_info, history=None, data=data, status=400 ) - smartthings_mock.apps.side_effect = error + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["step_id"] == "pat" assert result["errors"] == {"base": "app_setup_error"} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} -async def test_unknown_api_error(hass, smartthings_mock): +async def test_step_pat_unknown_api_error(hass, smartthings_mock): """Test an error is shown when there is an unknown API error.""" flow = SmartThingsFlowHandler() flow.hass = hass - request_info = Mock(real_url="http://example.com") smartthings_mock.apps.side_effect = ClientResponseError( request_info=request_info, history=None, status=HTTP_NOT_FOUND ) + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["step_id"] == "pat" assert result["errors"] == {"base": "app_setup_error"} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} -async def test_unknown_error(hass, smartthings_mock): +async def test_step_pat_unknown_error(hass, smartthings_mock): """Test an error is shown when there is an unknown API error.""" flow = SmartThingsFlowHandler() flow.hass = hass - smartthings_mock.apps.side_effect = Exception("Unknown error") + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["step_id"] == "pat" assert result["errors"] == {"base": "app_setup_error"} + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} -async def test_app_created_then_show_wait_form( - hass, app, app_oauth_client, smartthings_mock +async def test_step_pat_app_created_webhook( + hass, app, app_oauth_client, location, smartthings_mock ): - """Test SmartApp is created when one does not exist and shows wait form.""" + """Test SmartApp is created when one does not exist and shows location form.""" flow = SmartThingsFlowHandler() flow.hass = hass smartthings_mock.apps.return_value = [] smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + assert flow.access_token == token + assert flow.app_id == app.app_id + assert flow.oauth_client_secret == app_oauth_client.client_secret + assert flow.oauth_client_id == app_oauth_client.client_id assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "wait_install" + assert result["step_id"] == "select_location" -async def test_cloudhook_app_created_then_show_wait_form( - hass, app, app_oauth_client, smartthings_mock +async def test_step_pat_app_created_cloudhook( + hass, app, app_oauth_client, location, smartthings_mock ): - """Test SmartApp is created with a cloudhoko and shows wait form.""" + """Test SmartApp is created with a cloudhook and shows location form.""" hass.config.components.add("cloud") # Unload the endpoint so we can reload it under the cloud. @@ -224,128 +283,159 @@ async def test_cloudhook_app_created_then_show_wait_form( flow.hass = hass smartthings_mock.apps.return_value = [] smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + assert flow.access_token == token + assert flow.app_id == app.app_id + assert flow.oauth_client_secret == app_oauth_client.client_secret + assert flow.oauth_client_id == app_oauth_client.client_id assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "wait_install" + assert result["step_id"] == "select_location" assert mock_create_cloudhook.call_count == 1 -async def test_app_updated_then_show_wait_form( - hass, app, app_oauth_client, smartthings_mock +async def test_step_pat_app_updated_webhook( + hass, app, app_oauth_client, location, smartthings_mock ): - """Test SmartApp is updated when an existing is already created.""" + """Test SmartApp is updated then show location form.""" flow = SmartThingsFlowHandler() flow.hass = hass smartthings_mock.apps.return_value = [app] smartthings_mock.generate_app_oauth.return_value = app_oauth_client + smartthings_mock.locations.return_value = [location] + token = str(uuid4()) - result = await flow.async_step_user({"access_token": str(uuid4())}) + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + assert flow.access_token == token + assert flow.app_id == app.app_id + assert flow.oauth_client_secret == app_oauth_client.client_secret + assert flow.oauth_client_id == app_oauth_client.client_id assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "wait_install" + assert result["step_id"] == "select_location" -async def test_wait_form_displayed(hass): - """Test the wait for installation form is displayed.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_wait_install(None) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "wait_install" - - -async def test_wait_form_displayed_after_checking(hass, smartthings_mock): - """Test error is shown when the user has not installed the app.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - flow.access_token = str(uuid4()) - - result = await flow.async_step_wait_install({}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "wait_install" - assert result["errors"] == {"base": "app_not_installed"} - - -async def test_config_entry_created_when_installed( - hass, location, installed_app, smartthings_mock +async def test_step_pat_app_updated_webhook_from_existing_oauth_client( + hass, app, location, smartthings_mock ): + """Test SmartApp is updated from existing then show location form.""" + oauth_client_id = str(uuid4()) + oauth_client_secret = str(uuid4()) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_OAUTH_CLIENT_ID: oauth_client_id, + CONF_OAUTH_CLIENT_SECRET: oauth_client_secret, + CONF_LOCATION_ID: str(uuid4()), + }, + ) + entry.add_to_hass(hass) + flow = SmartThingsFlowHandler() + flow.hass = hass + smartthings_mock.apps.return_value = [app] + smartthings_mock.locations.return_value = [location] + token = str(uuid4()) + + result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + + assert flow.access_token == token + assert flow.app_id == app.app_id + assert flow.oauth_client_secret == oauth_client_secret + assert flow.oauth_client_id == oauth_client_id + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + +async def test_step_select_location(hass, location, smartthings_mock): + """Test select location shows form with available locations.""" + smartthings_mock.locations.return_value = [location] + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.api = smartthings_mock + + result = await flow.async_step_select_location() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + assert result["data_schema"]({CONF_LOCATION_ID: location.location_id}) == { + CONF_LOCATION_ID: location.location_id + } + + +async def test_step_select_location_aborts(hass, location, smartthings_mock): + """Test select location aborts if no available locations.""" + smartthings_mock.locations.return_value = [location] + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} + ) + entry.add_to_hass(hass) + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.api = smartthings_mock + + result = await flow.async_step_select_location() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_available_locations" + + +async def test_step_select_location_advances(hass): + """Test select location aborts if no available locations.""" + location_id = str(uuid4()) + app_id = str(uuid4()) + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.app_id = app_id + + result = await flow.async_step_select_location({CONF_LOCATION_ID: location_id}) + + assert flow.location_id == location_id + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app_id, location_id) + + +async def test_step_authorize_advances(hass): + """Test authorize step advances when completed.""" + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + flow = SmartThingsFlowHandler() + flow.hass = hass + + result = await flow.async_step_authorize( + {CONF_INSTALLED_APP_ID: installed_app_id, CONF_REFRESH_TOKEN: refresh_token} + ) + + assert flow.installed_app_id == installed_app_id + assert flow.refresh_token == refresh_token + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE + assert result["step_id"] == "install" + + +async def test_step_install_creates_entry(hass, location, smartthings_mock): """Test a config entry is created once the app is installed.""" flow = SmartThingsFlowHandler() flow.hass = hass - flow.access_token = str(uuid4()) - flow.app_id = installed_app.app_id flow.api = smartthings_mock + flow.access_token = str(uuid4()) + flow.app_id = str(uuid4()) + flow.installed_app_id = str(uuid4()) + flow.location_id = location.location_id flow.oauth_client_id = str(uuid4()) flow.oauth_client_secret = str(uuid4()) - data = { - CONF_REFRESH_TOKEN: str(uuid4()), - CONF_LOCATION_ID: installed_app.location_id, - CONF_INSTALLED_APP_ID: installed_app.installed_app_id, - } - hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data) + flow.refresh_token = str(uuid4()) - result = await flow.async_step_wait_install({}) + result = await flow.async_step_install() - assert not hass.data[DOMAIN][CONF_INSTALLED_APPS] assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"]["app_id"] == installed_app.app_id - assert result["data"]["installed_app_id"] == installed_app.installed_app_id - assert result["data"]["location_id"] == installed_app.location_id + assert result["data"]["app_id"] == flow.app_id + assert result["data"]["installed_app_id"] == flow.installed_app_id + assert result["data"]["location_id"] == flow.location_id assert result["data"]["access_token"] == flow.access_token - assert result["data"]["refresh_token"] == data[CONF_REFRESH_TOKEN] + assert result["data"]["refresh_token"] == flow.refresh_token assert result["data"]["client_secret"] == flow.oauth_client_secret assert result["data"]["client_id"] == flow.oauth_client_id assert result["title"] == location.name - - -async def test_multiple_config_entry_created_when_installed( - hass, app, locations, installed_apps, smartthings_mock -): - """Test a config entries are created for multiple installs.""" - assert await async_setup_component(hass, "persistent_notification", {}) - flow = SmartThingsFlowHandler() - flow.hass = hass - flow.access_token = str(uuid4()) - flow.app_id = app.app_id - flow.api = smartthings_mock - flow.oauth_client_id = str(uuid4()) - flow.oauth_client_secret = str(uuid4()) - for installed_app in installed_apps: - data = { - CONF_REFRESH_TOKEN: str(uuid4()), - CONF_LOCATION_ID: installed_app.location_id, - CONF_INSTALLED_APP_ID: installed_app.installed_app_id, - } - hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data) - install_data = hass.data[DOMAIN][CONF_INSTALLED_APPS].copy() - - result = await flow.async_step_wait_install({}) - - assert not hass.data[DOMAIN][CONF_INSTALLED_APPS] - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"]["app_id"] == installed_apps[0].app_id - assert result["data"]["installed_app_id"] == installed_apps[0].installed_app_id - assert result["data"]["location_id"] == installed_apps[0].location_id - assert result["data"]["access_token"] == flow.access_token - assert result["data"]["refresh_token"] == install_data[0][CONF_REFRESH_TOKEN] - assert result["data"]["client_secret"] == flow.oauth_client_secret - assert result["data"]["client_id"] == flow.oauth_client_id - assert result["title"] == locations[0].name - - await hass.async_block_till_done() - entries = hass.config_entries.async_entries("smartthings") - assert len(entries) == 1 - assert entries[0].data["app_id"] == installed_apps[1].app_id - assert entries[0].data["installed_app_id"] == installed_apps[1].installed_app_id - assert entries[0].data["location_id"] == installed_apps[1].location_id - assert entries[0].data["access_token"] == flow.access_token - assert entries[0].data["client_secret"] == flow.oauth_client_secret - assert entries[0].data["client_id"] == flow.oauth_client_id - assert entries[0].title == locations[1].name diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py index aff294beec1..4d7280a6a9e 100644 --- a/tests/components/smartthings/test_smartapp.py +++ b/tests/components/smartthings/test_smartapp.py @@ -7,7 +7,6 @@ from pysmartthings import AppEntity, Capability from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.const import ( CONF_INSTALLED_APP_ID, - CONF_INSTALLED_APPS, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_MANAGER, @@ -40,11 +39,11 @@ async def test_update_app_updated_needed(hass, app): assert mock_app.classifications == app.classifications -async def test_smartapp_install_store_if_no_other( - hass, smartthings_mock, device_factory -): - """Test aborts if no other app was configured already.""" +async def test_smartapp_install_configures_flow(hass): + """Test install event continues an existing flow.""" # Arrange + flow_id = str(uuid4()) + flows = [{"flow_id": flow_id, "handler": DOMAIN}] app = Mock() app.app_id = uuid4() request = Mock() @@ -52,50 +51,22 @@ async def test_smartapp_install_store_if_no_other( request.auth_token = str(uuid4()) request.location_id = str(uuid4()) request.refresh_token = str(uuid4()) - # Act - await smartapp.smartapp_install(hass, request, None, app) - # Assert - entries = hass.config_entries.async_entries("smartthings") - assert not entries - data = hass.data[DOMAIN][CONF_INSTALLED_APPS][0] - assert data[CONF_REFRESH_TOKEN] == request.refresh_token - assert data[CONF_LOCATION_ID] == request.location_id - assert data[CONF_INSTALLED_APP_ID] == request.installed_app_id - -async def test_smartapp_install_creates_flow( - hass, smartthings_mock, config_entry, location, device_factory -): - """Test installation creates flow.""" - # Arrange - config_entry.add_to_hass(hass) - app = Mock() - app.app_id = config_entry.data["app_id"] - request = Mock() - request.installed_app_id = str(uuid4()) - request.auth_token = str(uuid4()) - request.refresh_token = str(uuid4()) - request.location_id = location.location_id - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - smartthings_mock.devices.return_value = devices # Act - await smartapp.smartapp_install(hass, request, None, app) - # Assert - await hass.async_block_till_done() - entries = hass.config_entries.async_entries("smartthings") - assert len(entries) == 2 - assert entries[1].data["app_id"] == app.app_id - assert entries[1].data["installed_app_id"] == request.installed_app_id - assert entries[1].data["location_id"] == request.location_id - assert entries[1].data["access_token"] == config_entry.data["access_token"] - assert entries[1].data["refresh_token"] == request.refresh_token - assert entries[1].data["client_secret"] == config_entry.data["client_secret"] - assert entries[1].data["client_id"] == config_entry.data["client_id"] - assert entries[1].title == location.name + with patch.object( + hass.config_entries.flow, "async_progress", return_value=flows + ), patch.object(hass.config_entries.flow, "async_configure") as configure_mock: + + await smartapp.smartapp_install(hass, request, None, app) + + configure_mock.assert_called_once_with( + flow_id, + { + CONF_INSTALLED_APP_ID: request.installed_app_id, + CONF_LOCATION_ID: request.location_id, + CONF_REFRESH_TOKEN: request.refresh_token, + }, + ) async def test_smartapp_update_saves_token( @@ -121,6 +92,36 @@ async def test_smartapp_update_saves_token( assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token +async def test_smartapp_update_configures_flow(hass): + """Test update event continues an existing flow.""" + # Arrange + flow_id = str(uuid4()) + flows = [{"flow_id": flow_id, "handler": DOMAIN}] + app = Mock() + app.app_id = uuid4() + request = Mock() + request.installed_app_id = str(uuid4()) + request.auth_token = str(uuid4()) + request.location_id = str(uuid4()) + request.refresh_token = str(uuid4()) + + # Act + with patch.object( + hass.config_entries.flow, "async_progress", return_value=flows + ), patch.object(hass.config_entries.flow, "async_configure") as configure_mock: + + await smartapp.smartapp_update(hass, request, None, app) + + configure_mock.assert_called_once_with( + flow_id, + { + CONF_INSTALLED_APP_ID: request.installed_app_id, + CONF_LOCATION_ID: request.location_id, + CONF_REFRESH_TOKEN: request.refresh_token, + }, + ) + + async def test_smartapp_uninstall(hass, config_entry): """Test the config entry is unloaded when the app is uninstalled.""" config_entry.add_to_hass(hass)