diff --git a/.coveragerc b/.coveragerc index 263cfd76172..1a9221b3abb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1114,6 +1114,7 @@ omit = homeassistant/components/upcloud/switch.py homeassistant/components/upnp/* homeassistant/components/upc_connect/* + homeassistant/components/uptimerobot/__init__.py homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uptimerobot/const.py homeassistant/components/uptimerobot/entity.py diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 6bdd8a6e39c..7bab74fa03e 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -1,14 +1,19 @@ """Config flow for Uptime Robot integration.""" from __future__ import annotations -from pyuptimerobot import UptimeRobot, UptimeRobotAccount, UptimeRobotException +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAccount, + UptimeRobotApiError, + UptimeRobotApiResponse, + UptimeRobotAuthenticationException, + UptimeRobotException, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -17,41 +22,58 @@ from .const import API_ATTR_OK, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) -async def validate_input(hass: HomeAssistant, data: ConfigType) -> UptimeRobotAccount: - """Validate the user input allows us to connect.""" - uptime_robot_api = UptimeRobot(data[CONF_API_KEY], async_get_clientsession(hass)) - - try: - response = await uptime_robot_api.async_get_account_details() - except UptimeRobotException as exception: - raise CannotConnect(exception) from exception - else: - if response.status == API_ATTR_OK: - return response.data - raise CannotConnect(response.error.message) - - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Uptime Robot.""" VERSION = 1 + async def _validate_input( + self, data: ConfigType + ) -> tuple[dict[str, str], UptimeRobotAccount | None]: + """Validate the user input allows us to connect.""" + errors: dict[str, str] = {} + response: UptimeRobotApiResponse | UptimeRobotApiError | None = None + uptime_robot_api = UptimeRobot( + data[CONF_API_KEY], async_get_clientsession(self.hass) + ) + + try: + response = await uptime_robot_api.async_get_account_details() + except UptimeRobotAuthenticationException as exception: + LOGGER.error(exception) + errors["base"] = "invalid_api_key" + except UptimeRobotException as exception: + LOGGER.error(exception) + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + LOGGER.exception(exception) + errors["base"] = "unknown" + else: + if response.status != API_ATTR_OK: + errors["base"] = "unknown" + LOGGER.error(response.error.message) + + account: UptimeRobotAccount | None = ( + response.data + if response and response.data and response.data.email + else None + ) + if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() + + return errors, account + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - try: - account = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + errors, account = await self._validate_input(user_input) + if account: return self.async_create_entry(title=account.email, data=user_input) return self.async_show_form( @@ -69,9 +91,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): imported_config = {CONF_API_KEY: import_config[CONF_API_KEY]} - account = await validate_input(self.hass, imported_config) - return self.async_create_entry(title=account.email, data=imported_config) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" + _, account = await self._validate_input(imported_config) + if account: + return self.async_create_entry(title=account.email, data=imported_config) + return self.async_abort(reason="unknown") diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 9c91b32bdfd..22d9a6d9477 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -3,7 +3,7 @@ "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "requirements": [ - "pyuptimerobot==21.8.1" + "pyuptimerobot==21.8.2" ], "codeowners": [ "@ludeeus" diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index bae1e54c2b6..f51061eec33 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -9,10 +9,12 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index 7e35d8e9531..99ab9426006 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Account already configured" + "already_configured": "Account already configured", + "unknown": "Unexpected error" }, "error": { "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", "unknown": "Unexpected error" }, "step": { diff --git a/requirements_all.txt b/requirements_all.txt index ec370ad1dcd..6cd0e2cce17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1951,7 +1951,7 @@ pytradfri[async]==7.0.6 pytrafikverket==0.1.6.2 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.1 +pyuptimerobot==21.8.2 # homeassistant.components.keyboard # pyuserinput==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0eca53e6cdc..487d3d483d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1080,7 +1080,7 @@ pytraccar==0.9.0 pytradfri[async]==7.0.6 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.1 +pyuptimerobot==21.8.2 # homeassistant.components.vera pyvera==0.3.13 diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 0da20086cc4..41f0b6b639e 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -1,7 +1,12 @@ """Test the Uptime Robot config flow.""" from unittest.mock import patch +from pytest import LogCaptureFixture from pyuptimerobot import UptimeRobotApiResponse +from pyuptimerobot.exceptions import ( + UptimeRobotAuthenticationException, + UptimeRobotException, +) from homeassistant import config_entries, setup from homeassistant.components.uptimerobot.const import DOMAIN @@ -12,6 +17,8 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) +from tests.common import MockConfigEntry + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -20,14 +27,14 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {} + assert result["errors"] is None with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", return_value=UptimeRobotApiResponse.from_dict( { "stat": "ok", - "account": {"email": "test@test.test"}, + "account": {"email": "test@test.test", "user_id": 1234567890}, } ), ), patch( @@ -40,6 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() + assert result2["result"].unique_id == "1234567890" assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "test@test.test" assert result2["data"] == {"api_key": "1234"} @@ -54,7 +62,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict({"stat": "fail", "error": {}}), + side_effect=UptimeRobotException, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -65,6 +73,66 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_api_key_error(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + side_effect=UptimeRobotAuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["errors"] == {"base": "invalid_api_key"} + + +async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "fail", + "error": {"message": "test error from API."}, + } + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["errors"] == {"base": "unknown"} + assert "test error from API." in caplog.text + + async def test_flow_import(hass): """Test an import flow.""" with patch( @@ -72,7 +140,7 @@ async def test_flow_import(hass): return_value=UptimeRobotApiResponse.from_dict( { "stat": "ok", - "account": {"email": "test@test.test"}, + "account": {"email": "test@test.test", "user_id": 1234567890}, } ), ), patch( @@ -92,7 +160,12 @@ async def test_flow_import(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict({"stat": "ok", "monitors": []}), + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -104,5 +177,61 @@ async def test_flow_import(hass): ) await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict({"stat": "ok"}), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"platform": DOMAIN, "api_key": "12345"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_user_unique_id_already_exists(hass): + """Test creating an entry where the unique_id already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "12345"}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured"