From 92c79d531d658a2ff227590538bf85d13450515e Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Fri, 3 Nov 2023 11:17:19 +0000 Subject: [PATCH 1/4] tests: add coverage for ConfigFlow exceptions --- tests/test_config_flow.py | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 8cf5ebf..4591566 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -212,3 +212,53 @@ async def test_form_server_errors(mock_setup_entry, mock_setup, hass): assert result["type"] == "form" assert result["errors"]["base"] == "server_error" + + +@patch("custom_components.econnect_metronet.async_setup", return_value=True) +@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) +async def test_form_unknown_errors(mock_setup_entry, mock_setup, hass): + # Ensure we catch unexpected status codes + form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + + # Check non-error status codes + r = Response() + r.status_code = 300 + err = HTTPError(response=r) + + with patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=err): + result = await hass.config_entries.flow.async_configure( + form["flow_id"], + { + "username": "test-username", + "password": "test-password", + "domain": "test-domain", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["errors"]["base"] == "unknown" + + +@patch("custom_components.econnect_metronet.async_setup", return_value=True) +@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) +async def test_form_generic_exception(mock_setup_entry, mock_setup, hass): + # Ensure we catch unexpected exceptions + form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + + # Check exceptions + err = Exception("Random Exception") + + with patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=err): + result = await hass.config_entries.flow.async_configure( + form["flow_id"], + { + "username": "test-username", + "password": "test-password", + "domain": "test-domain", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["errors"]["base"] == "unknown" From a1e402e1c3975871e0f866faba855f88055a8b3f Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Fri, 3 Nov 2023 13:41:32 +0000 Subject: [PATCH 2/4] refactor: remove `validate_credentials`; ConfigFlow uses directly the client --- .../econnect_metronet/config_flow.py | 11 ++++--- .../econnect_metronet/helpers.py | 29 ++----------------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/custom_components/econnect_metronet/config_flow.py b/custom_components/econnect_metronet/config_flow.py index f90afe7..63685e1 100644 --- a/custom_components/econnect_metronet/config_flow.py +++ b/custom_components/econnect_metronet/config_flow.py @@ -1,7 +1,7 @@ -"""Config flow for E-connect Alarm integration.""" import logging import voluptuous as vol +from elmo.api.client import ElmoClient from elmo.api.exceptions import CredentialError from elmo.systems import ELMO_E_CONNECT as E_CONNECT_DEFAULT from homeassistant import config_entries @@ -21,7 +21,7 @@ SUPPORTED_SYSTEMS, ) from .exceptions import InvalidAreas -from .helpers import parse_areas_config, validate_credentials +from .helpers import parse_areas_config _LOGGER = logging.getLogger(__name__) @@ -43,8 +43,11 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: try: - # Validate submitted configuration - await validate_credentials(self.hass, user_input) + # Validate credentials + client = ElmoClient(user_input.get(CONF_SYSTEM_URL), domain=user_input.get(CONF_DOMAIN)) + await self.hass.async_add_executor_job( + client.auth, user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD) + ) except ConnectionError: errors["base"] = "cannot_connect" except CredentialError: diff --git a/custom_components/econnect_metronet/helpers.py b/custom_components/econnect_metronet/helpers.py index 92862bd..68f273d 100644 --- a/custom_components/econnect_metronet/helpers.py +++ b/custom_components/econnect_metronet/helpers.py @@ -1,12 +1,10 @@ from typing import Union -from elmo.api.client import ElmoClient -from homeassistant import core from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.util import slugify -from .const import CONF_DOMAIN, CONF_SYSTEM_NAME, CONF_SYSTEM_URL, DOMAIN +from .const import CONF_SYSTEM_NAME, DOMAIN from .exceptions import InvalidAreas @@ -49,29 +47,6 @@ def parse_areas_config(config: str, raises: bool = False): return [] -async def validate_credentials(hass: core.HomeAssistant, config: dict): - """Validate if user input includes valid credentials to connect. - - Initialize the client with an API endpoint and a vendor and authenticate - your connection to retrieve the access token. - - Args: - hass: HomeAssistant instance. - data: data that needs validation (configured username/password). - Raises: - ConnectionError: if there is a connection error. - CredentialError: if given credentials are incorrect. - HTTPError: if the API backend answers with errors. - Returns: - `True` if given `data` includes valid credential checked with - e-connect backend. - """ - # Check Credentials - client = ElmoClient(config.get(CONF_SYSTEM_URL), domain=config.get(CONF_DOMAIN)) - await hass.async_add_executor_job(client.auth, config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) - return True - - def generate_entity_id(config: ConfigEntry, name: Union[str, None] = None) -> str: """Generate an entity ID based on system configuration or username. From eeb3d41732ece031cd85afb475a8ad7fd7ebdad0 Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Fri, 3 Nov 2023 13:42:42 +0000 Subject: [PATCH 3/4] tests: add _() helper to generate mock path --- tests/helpers.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/helpers.py diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..f7175e8 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,14 @@ +def _(mock_path: str) -> str: + """Helper to simplify Mock path strings. + + Args: + mock_path (str): The partial path to be appended to the standard prefix for mock paths. + + Returns: + str: The full mock path combined with the standard prefix. + + Example: + >>> _("module.Class.method") + "custom_components.econnect_metronet.module.Class.method" + """ + return f"custom_components.econnect_metronet.{mock_path}" From 2672a0b440cf4a2a353f0cbe810113c0364c2a9c Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Fri, 3 Nov 2023 13:42:19 +0000 Subject: [PATCH 4/4] tests: refactor ConfigFlow tests --- tests/test_config_flow.py | 242 ++++++++++++++++++-------------------- 1 file changed, 112 insertions(+), 130 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 4591566..0c2fd50 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,6 +1,3 @@ -"""Test the E-connect Alarm config flow.""" -from unittest.mock import patch - import pytest from elmo.api.exceptions import CredentialError from homeassistant import config_entries @@ -10,9 +7,11 @@ from custom_components.econnect_metronet.const import DOMAIN +from .helpers import _ + async def test_form_fields(hass): - """Test the form is properly generated with fields we expect.""" + # Ensure the form is properly generated with fields we expect form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) assert form["type"] == "form" assert form["step_id"] == "user" @@ -22,50 +21,48 @@ async def test_form_fields(hass): assert form["data_schema"].schema["domain"] == str -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -@patch("custom_components.econnect_metronet.helpers.ElmoClient") -async def test_form_submit_successful(mock_client, mock_setup_entry, mock_setup, hass): - """Test a properly submitted form initializes an ElmoClient.""" +async def test_form_submit_successful_with_input(hass, mocker): + # Ensure a properly submitted form initializes an ElmoClient + m_client = mocker.patch(_("config_flow.ElmoClient")) + m_setup = mocker.patch(_("async_setup"), return_value=True) + m_setup_entry = mocker.patch(_("async_setup_entry"), return_value=True) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - + # Test result = await hass.config_entries.flow.async_configure( form["flow_id"], { "username": "test-username", "password": "test-password", "domain": "test-domain", - "system_base_url": "https://connect.elmospa.com", + "system_base_url": "https://metronet.iessonline.com", }, ) await hass.async_block_till_done() - + # Check Client Authentication + assert m_client.call_args.args == ("https://metronet.iessonline.com",) + assert m_client.call_args.kwargs == {"domain": "test-domain"} + assert m_client().auth.call_count == 1 + assert m_client().auth.call_args.args == ("test-username", "test-password") + # Check HA setup + assert len(m_setup.mock_calls) == 1 + assert len(m_setup_entry.mock_calls) == 1 assert result["type"] == "create_entry" assert result["title"] == "e-Connect/Metronet Alarm" assert result["data"] == { "username": "test-username", "password": "test-password", "domain": "test-domain", - "system_base_url": "https://connect.elmospa.com", + "system_base_url": "https://metronet.iessonline.com", } - # Check HA setup - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - # Check Client initialization during validation - assert ("https://connect.elmospa.com",) == mock_client.call_args.args - assert {"domain": "test-domain"} == mock_client.call_args.kwargs - client = mock_client() - assert client.auth.call_count == 1 - assert ("test-username", "test-password") == client.auth.call_args.args -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -@patch("custom_components.econnect_metronet.helpers.ElmoClient") -async def test_form_submit_with_defaults(mock_client, mock_setup_entry, mock_setup, hass): - """Test a properly submitted form with defaults.""" +async def test_form_submit_with_defaults(hass, mocker): + # Ensure a properly submitted form with defaults + m_client = mocker.patch(_("config_flow.ElmoClient")) + m_setup = mocker.patch(_("async_setup"), return_value=True) + m_setup_entry = mocker.patch(_("async_setup_entry"), return_value=True) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - + # Test result = await hass.config_entries.flow.async_configure( form["flow_id"], { @@ -74,7 +71,6 @@ async def test_form_submit_with_defaults(mock_client, mock_setup_entry, mock_set }, ) await hass.async_block_till_done() - assert result["type"] == "create_entry" assert result["title"] == "e-Connect/Metronet Alarm" assert result["data"] == { @@ -82,11 +78,14 @@ async def test_form_submit_with_defaults(mock_client, mock_setup_entry, mock_set "password": "test-password", "system_base_url": "https://connect.elmospa.com", } + # Check Client Authentication + assert m_client.call_args.args == ("https://connect.elmospa.com",) + assert m_client.call_args.kwargs == {"domain": None} + assert m_client().auth.call_count == 1 + assert m_client().auth.call_args.args == ("test-username", "test-password") # Check HA setup - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - # Check Client defaults initialization during validation - assert ("https://connect.elmospa.com",) == mock_client.call_args.args + assert len(m_setup.mock_calls) == 1 + assert len(m_setup_entry.mock_calls) == 1 async def test_form_supported_systems(hass): @@ -100,16 +99,15 @@ async def test_form_supported_systems(hass): } -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -async def test_form_submit_required_fields(mock_setup_entry, mock_setup, hass): - """Test the form has the expected required fields.""" +async def test_form_submit_required_fields(hass, mocker): + # Ensure the form has the expected required fields + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - + # Test with pytest.raises(MultipleInvalid) as excinfo: await hass.config_entries.flow.async_configure(form["flow_id"], {}) await hass.async_block_till_done() - assert len(excinfo.value.errors) == 2 errors = [] errors.append(str(excinfo.value.errors[0])) @@ -118,13 +116,13 @@ async def test_form_submit_required_fields(mock_setup_entry, mock_setup, hass): assert "required key not provided @ data['password']" in errors -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -@patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=CredentialError) -async def test_form_submit_wrong_credential(mock_client, mock_setup_entry, mock_setup, hass): - """Test the right error is raised for CredentialError exception.""" +async def test_form_submit_wrong_credential(hass, mocker): + # Ensure the right error is raised for CredentialError exception + mocker.patch(_("config_flow.ElmoClient"), side_effect=CredentialError) + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - + # Test result = await hass.config_entries.flow.async_configure( form["flow_id"], { @@ -134,18 +132,17 @@ async def test_form_submit_wrong_credential(mock_client, mock_setup_entry, mock_ }, ) await hass.async_block_till_done() - assert result["type"] == "form" assert result["errors"]["base"] == "invalid_auth" -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -@patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=ConnectionError) -async def test_form_submit_connection_error(mock_client, mock_setup_entry, mock_setup, hass): - """Test the right error is raised for connection errors.""" +async def test_form_submit_connection_error(hass, mocker): + # Ensure the right error is raised for connection errors + mocker.patch(_("config_flow.ElmoClient"), side_effect=ConnectionError) + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - + # Test result = await hass.config_entries.flow.async_configure( form["flow_id"], { @@ -155,77 +152,21 @@ async def test_form_submit_connection_error(mock_client, mock_setup_entry, mock_ }, ) await hass.async_block_till_done() - assert result["type"] == "form" assert result["errors"]["base"] == "cannot_connect" -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -async def test_form_client_errors(mock_setup_entry, mock_setup, hass): - """Test the right error is raised for 4xx API errors.""" +async def test_form_client_errors(hass, mocker): + # Ensure the right error is raised for 4xx API errors + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) + m_client = mocker.patch(_("config_flow.ElmoClient.auth")) + err = HTTPError(response=Response()) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - - # Check all 4xx errors - r = Response() + # Test 400-499 status codes for code in range(400, 500): - r.status_code = code - err = HTTPError(response=r) - - with patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=err): - result = await hass.config_entries.flow.async_configure( - form["flow_id"], - { - "username": "test-username", - "password": "test-password", - "domain": "test-domain", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "form" - assert result["errors"]["base"] == "client_error" - - -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -async def test_form_server_errors(mock_setup_entry, mock_setup, hass): - """Test the right error is raised for 5xx API errors.""" - form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - - # Check all 5xx errors - r = Response() - for code in range(500, 600): - r.status_code = code - err = HTTPError(response=r) - - with patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=err): - result = await hass.config_entries.flow.async_configure( - form["flow_id"], - { - "username": "test-username", - "password": "test-password", - "domain": "test-domain", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "form" - assert result["errors"]["base"] == "server_error" - - -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -async def test_form_unknown_errors(mock_setup_entry, mock_setup, hass): - # Ensure we catch unexpected status codes - form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - - # Check non-error status codes - r = Response() - r.status_code = 300 - err = HTTPError(response=r) - - with patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=err): + err.response.status_code = code + m_client.side_effect = err result = await hass.config_entries.flow.async_configure( form["flow_id"], { @@ -235,21 +176,21 @@ async def test_form_unknown_errors(mock_setup_entry, mock_setup, hass): }, ) await hass.async_block_till_done() - assert result["type"] == "form" - assert result["errors"]["base"] == "unknown" + assert result["errors"]["base"] == "client_error" -@patch("custom_components.econnect_metronet.async_setup", return_value=True) -@patch("custom_components.econnect_metronet.async_setup_entry", return_value=True) -async def test_form_generic_exception(mock_setup_entry, mock_setup, hass): - # Ensure we catch unexpected exceptions +async def test_form_server_errors(hass, mocker): + # Ensure the right error is raised for 5xx API errors + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) + m_client = mocker.patch(_("config_flow.ElmoClient.auth")) + err = HTTPError(response=Response()) form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) - - # Check exceptions - err = Exception("Random Exception") - - with patch("custom_components.econnect_metronet.helpers.ElmoClient.auth", side_effect=err): + # Test 500-599 status codes + for code in range(500, 600): + err.response.status_code = code + m_client.side_effect = err result = await hass.config_entries.flow.async_configure( form["flow_id"], { @@ -259,6 +200,47 @@ async def test_form_generic_exception(mock_setup_entry, mock_setup, hass): }, ) await hass.async_block_till_done() - assert result["type"] == "form" - assert result["errors"]["base"] == "unknown" + assert result["errors"]["base"] == "server_error" + + +async def test_form_unknown_errors(hass, mocker): + # Ensure we catch unexpected status codes + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) + err = HTTPError(response=Response()) + err.response.status_code = 999 + mocker.patch(_("config_flow.ElmoClient.auth"), side_effect=err) + form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + # Test non-error status codes + result = await hass.config_entries.flow.async_configure( + form["flow_id"], + { + "username": "test-username", + "password": "test-password", + "domain": "test-domain", + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["errors"]["base"] == "unknown" + + +async def test_form_generic_exception(hass, mocker): + # Ensure we catch unexpected exceptions + mocker.patch(_("async_setup"), return_value=True) + mocker.patch(_("async_setup_entry"), return_value=True) + mocker.patch(_("config_flow.ElmoClient.auth"), side_effect=Exception("Random Exception")) + form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + # Test + result = await hass.config_entries.flow.async_configure( + form["flow_id"], + { + "username": "test-username", + "password": "test-password", + "domain": "test-domain", + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["errors"]["base"] == "unknown"