From 2506acc095ce85867a803c8c473e010566b91133 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 1 Jul 2024 07:41:47 -0700 Subject: [PATCH] Improve flume test coverage (#120851) * Add Flume init tests * Increase test coverage * Improve readability * Fix pydoc for tests * Use pytest.mark.usefixtures --- .coveragerc | 1 - tests/components/flume/conftest.py | 167 +++++++++++++++++++ tests/components/flume/test_config_flow.py | 177 +++++++++++---------- tests/components/flume/test_init.py | 135 ++++++++++++++++ 4 files changed, 392 insertions(+), 88 deletions(-) create mode 100644 tests/components/flume/conftest.py create mode 100644 tests/components/flume/test_init.py diff --git a/.coveragerc b/.coveragerc index 2bc76723445..c3ab7f1006f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -441,7 +441,6 @@ omit = homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py homeassistant/components/flock/notify.py - homeassistant/components/flume/__init__.py homeassistant/components/flume/binary_sensor.py homeassistant/components/flume/coordinator.py homeassistant/components/flume/entity.py diff --git a/tests/components/flume/conftest.py b/tests/components/flume/conftest.py new file mode 100644 index 00000000000..999bbd70ce8 --- /dev/null +++ b/tests/components/flume/conftest.py @@ -0,0 +1,167 @@ +"""Flume test fixtures.""" + +from collections.abc import Generator +import datetime +from http import HTTPStatus +import json +from unittest.mock import mock_open, patch + +import jwt +import pytest +import requests +from requests_mock.mocker import Mocker + +from homeassistant.components.flume.const import DOMAIN +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +USER_ID = "test-user-id" +REFRESH_TOKEN = "refresh-token" +TOKEN_URL = "https://api.flumetech.com/oauth/token" +DEVICE_LIST_URL = ( + "https://api.flumetech.com/users/test-user-id/devices?user=true&location=true" +) +BRIDGE_DEVICE = { + "id": "1234", + "type": 1, # Bridge + "location": { + "name": "Bridge Location", + }, + "name": "Flume Bridge", + "connected": True, +} +SENSOR_DEVICE = { + "id": "1234", + "type": 2, # Sensor + "location": { + "name": "Sensor Location", + }, + "name": "Flume Sensor", + "connected": True, +} +DEVICE_LIST = [BRIDGE_DEVICE, SENSOR_DEVICE] +NOTIFICATIONS_URL = "https://api.flumetech.com/users/test-user-id/notifications?limit=50&offset=0&sort_direction=ASC" +NOTIFICATION = { + "id": 111111, + "device_id": "6248148189204194987", + "user_id": USER_ID, + "type": 1, + "message": "Low Flow Leak triggered at Home. Water has been running for 2 hours averaging 0.43 gallons every minute.", + "created_datetime": "2020-01-15T16:33:39.000Z", + "title": "Potential Leak Detected!", + "read": True, + "extra": { + "query": { + "request_id": "SYSTEM_TRIGGERED_USAGE_ALERT", + "since_datetime": "2020-01-15 06:33:59", + "until_datetime": "2020-01-15 08:33:59", + "tz": "America/Los_Angeles", + "bucket": "MIN", + "raw": False, + "group_multiplier": 2, + "device_id": ["6248148189204194987"], + } + }, + "event_rule": "Low Flow Leak", +} + +NOTIFICATIONS_LIST = [NOTIFICATION] + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: + """Fixture to create a config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + unique_id="test-username", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +def encode_access_token() -> str: + """Encode the payload of the access token.""" + expiration_time = datetime.datetime.now() + datetime.timedelta(hours=12) + payload = { + "user_id": USER_ID, + "exp": int(expiration_time.timestamp()), + } + return jwt.encode(payload, key="secret") + + +@pytest.fixture(name="access_token") +def access_token_fixture(requests_mock: Mocker) -> Generator[None, None, None]: + """Fixture to setup the access token.""" + token_response = { + "refresh_token": REFRESH_TOKEN, + "access_token": encode_access_token(), + } + requests_mock.register_uri( + "POST", + TOKEN_URL, + status_code=HTTPStatus.OK, + json={"data": [token_response]}, + ) + with patch("builtins.open", mock_open(read_data=json.dumps(token_response))): + yield + + +@pytest.fixture(name="device_list") +def device_list_fixture(requests_mock: Mocker) -> None: + """Fixture to setup the device list API response access token.""" + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + status_code=HTTPStatus.OK, + json={ + "data": DEVICE_LIST, + }, + ) + + +@pytest.fixture(name="device_list_timeout") +def device_list_timeout_fixture(requests_mock: Mocker) -> None: + """Fixture to test a timeout when connecting to the device list url.""" + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + exc=requests.exceptions.ConnectTimeout, + ) + + +@pytest.fixture(name="device_list_unauthorized") +def device_list_unauthorized_fixture(requests_mock: Mocker) -> None: + """Fixture to test an authorized error from the device list url.""" + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + status_code=HTTPStatus.UNAUTHORIZED, + json={}, + ) + + +@pytest.fixture(name="notifications_list") +def notifications_list_fixture(requests_mock: Mocker) -> None: + """Fixture to setup the device list API response access token.""" + requests_mock.register_uri( + "GET", + NOTIFICATIONS_URL, + status_code=HTTPStatus.OK, + json={ + "data": NOTIFICATIONS_LIST, + }, + ) diff --git a/tests/components/flume/test_config_flow.py b/tests/components/flume/test_config_flow.py index 706cee44739..915299223e9 100644 --- a/tests/components/flume/test_config_flow.py +++ b/tests/components/flume/test_config_flow.py @@ -1,8 +1,11 @@ """Test the flume config flow.""" -from unittest.mock import MagicMock, patch +from http import HTTPStatus +from unittest.mock import patch +import pytest import requests.exceptions +from requests_mock.mocker import Mocker from homeassistant import config_entries from homeassistant.components.flume.const import DOMAIN @@ -15,15 +18,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import DEVICE_LIST, DEVICE_LIST_URL + from tests.common import MockConfigEntry -def _get_mocked_flume_device_list(): - flume_device_list_mock = MagicMock() - type(flume_device_list_mock).device_list = ["mock"] - return flume_device_list_mock - - +@pytest.mark.usefixtures("access_token", "device_list") async def test_form(hass: HomeAssistant) -> None: """Test we get the form and can setup from user input.""" @@ -33,17 +33,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_flume_device_list = _get_mocked_flume_device_list() - with ( - patch( - "homeassistant.components.flume.config_flow.FlumeAuth", - return_value=True, - ), - patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - return_value=mock_flume_device_list, - ), patch( "homeassistant.components.flume.async_setup_entry", return_value=True, @@ -71,66 +61,57 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("access_token") +async def test_form_invalid_auth(hass: HomeAssistant, requests_mock: Mocker) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.flume.config_flow.FlumeAuth", - return_value=True, - ), - patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - side_effect=Exception, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_CLIENT_ID: "client_id", - CONF_CLIENT_SECRET: "client_secret", - }, - ) + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + status_code=HTTPStatus.UNAUTHORIZED, + json={"message": "Failure"}, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"password": "invalid_auth"} +@pytest.mark.usefixtures("access_token", "device_list_timeout") async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.flume.config_flow.FlumeAuth", - return_value=True, - ), - patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - side_effect=requests.exceptions.ConnectionError(), - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_CLIENT_ID: "client_id", - CONF_CLIENT_SECRET: "client_secret", - }, - ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_reauth(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("access_token") +async def test_reauth(hass: HomeAssistant, requests_mock: Mocker) -> None: """Test we can reauth.""" entry = MockConfigEntry( domain=DOMAIN, @@ -151,35 +132,28 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.flume.config_flow.FlumeAuth", - return_value=True, - ), - patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - side_effect=Exception, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"password": "invalid_auth"} + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + exc=requests.exceptions.ConnectTimeout, + ) + with ( patch( - "homeassistant.components.flume.config_flow.FlumeAuth", + "homeassistant.components.flume.config_flow.os.path.exists", return_value=True, ), - patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - side_effect=requests.exceptions.ConnectionError(), - ), + patch("homeassistant.components.flume.config_flow.os.unlink") as mock_unlink, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -187,21 +161,22 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_PASSWORD: "test-password", }, ) + # The existing token file was removed + assert len(mock_unlink.mock_calls) == 1 assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "cannot_connect"} - mock_flume_device_list = _get_mocked_flume_device_list() + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + status_code=HTTPStatus.OK, + json={ + "data": DEVICE_LIST, + }, + ) with ( - patch( - "homeassistant.components.flume.config_flow.FlumeAuth", - return_value=True, - ), - patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - return_value=mock_flume_device_list, - ), patch( "homeassistant.components.flume.async_setup_entry", return_value=True, @@ -217,3 +192,31 @@ async def test_reauth(hass: HomeAssistant) -> None: assert mock_setup_entry.called assert result4["type"] is FlowResultType.ABORT assert result4["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("access_token") +async def test_form_no_devices(hass: HomeAssistant, requests_mock: Mocker) -> None: + """Test a device list response that contains no values will raise an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + requests_mock.register_uri( + "GET", + DEVICE_LIST_URL, + status_code=HTTPStatus.OK, + json={"data": []}, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/flume/test_init.py b/tests/components/flume/test_init.py new file mode 100644 index 00000000000..44a66425949 --- /dev/null +++ b/tests/components/flume/test_init.py @@ -0,0 +1,135 @@ +"""Test the flume init.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from requests_mock.mocker import Mocker + +from homeassistant import config_entries +from homeassistant.components.flume.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import USER_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms_fixture() -> Generator[list[str]]: + """Return the platforms to be loaded for this test.""" + # Arbitrary platform to ensure notifications are loaded + with patch("homeassistant.components.flume.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + +@pytest.mark.usefixtures("access_token", "device_list") +async def test_setup_config_entry( + hass: HomeAssistant, + requests_mock: Mocker, + config_entry: MockConfigEntry, +) -> None: + """Test load and unload of a ConfigEntry.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("access_token", "device_list_timeout") +async def test_device_list_timeout( + hass: HomeAssistant, + requests_mock: Mocker, + config_entry: MockConfigEntry, +) -> None: + """Test error handling for a timeout when listing devices.""" + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("access_token", "device_list_unauthorized") +async def test_reauth_when_unauthorized( + hass: HomeAssistant, + requests_mock: Mocker, + config_entry: MockConfigEntry, +) -> None: + """Test error handling for an authentication error when listing devices.""" + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.usefixtures("access_token", "device_list", "notifications_list") +async def test_list_notifications_service( + hass: HomeAssistant, + requests_mock: Mocker, + config_entry: MockConfigEntry, +) -> None: + """Test the list notifications service.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + response = await hass.services.async_call( + DOMAIN, + "list_notifications", + {}, + target={ + "config_entry": config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + notifications = response.get("notifications") + assert notifications + assert len(notifications) == 1 + assert notifications[0].get("user_id") == USER_ID + + +@pytest.mark.usefixtures("access_token", "device_list", "notifications_list") +async def test_list_notifications_service_config_entry_errors( + hass: HomeAssistant, + requests_mock: Mocker, + config_entry: MockConfigEntry, +) -> None: + """Test error handling for notification service with invalid config entries.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + with pytest.raises(ValueError, match="Config entry not loaded"): + await hass.services.async_call( + DOMAIN, + "list_notifications", + {}, + target={ + "config_entry": config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + + with pytest.raises(ValueError, match="Invalid config entry: does-not-exist"): + await hass.services.async_call( + DOMAIN, + "list_notifications", + {}, + target={ + "config_entry": "does-not-exist", + }, + blocking=True, + return_response=True, + )