diff --git a/.gitignore b/.gitignore index 43f7377..765e507 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ cython_debug/ .DS_Store test + +test_creds.py \ No newline at end of file diff --git a/custom_components/audiobookshelf/__init__.py b/custom_components/audiobookshelf/__init__.py index 28a04f9..83ae2a0 100644 --- a/custom_components/audiobookshelf/__init__.py +++ b/custom_components/audiobookshelf/__init__.py @@ -4,7 +4,7 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, Config +from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -17,6 +17,7 @@ CONF_HOST, DOMAIN, ISSUE_URL, + PLATFORMS, SCAN_INTERVAL, VERSION, ) @@ -55,7 +56,6 @@ async def _async_update_data(self) -> dict[str, None]: update["connectivity"] = "ConnectionError: Unable to connect." except (TimeoutError, Timeout): update["connectivity"] = "TimeoutError: Request timed out." - print("I ran\n\n\n\n\n") except HTTPError as http_error: update["connectivity"] = f"HTTPError: Generic HTTP Error happened {http_error}" try: @@ -88,7 +88,7 @@ async def _async_update_data(self) -> dict[str, None]: update["sessions"] = f"HTTPError: Generic HTTP Error happened {http_error}" return update -async def async_setup(hass: HomeAssistant, config: Config): +async def async_setup(hass: HomeAssistant, config: Config) -> bool: """Set up this integration using YAML is not supported.""" return True @@ -127,7 +127,7 @@ async def async_setup_entry( hass.data[DOMAIN][entry.entry_id] = coordinator - for platform in ["binary_sensor", "sensor"]: + for platform in PLATFORMS: if entry.options.get(platform, True): coordinator.platforms.append(platform) hass.async_add_job( @@ -144,7 +144,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in ["binary_sensor", "sensor"] + for platform in PLATFORMS if platform in coordinator.platforms ], ), @@ -152,7 +152,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unloaded: hass.data[DOMAIN].pop(entry.entry_id) - return unloaded + return unloaded async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" diff --git a/custom_components/audiobookshelf/api.py b/custom_components/audiobookshelf/api.py index f75d68b..ba7addd 100644 --- a/custom_components/audiobookshelf/api.py +++ b/custom_components/audiobookshelf/api.py @@ -72,7 +72,8 @@ async def api_wrapper( async with async_timeout.timeout(TIMEOUT): # loop=asyncio.get_event_loop() if method == "get": response = await self._session.get(url, headers=headers) - return await response.json() + if response.status >= 200 and response.status < 300: + return await response.json() if method == "put": await self._session.put(url, headers=headers, json=data) diff --git a/custom_components/audiobookshelf/config_flow.py b/custom_components/audiobookshelf/config_flow.py index f6b149a..8c38dd7 100644 --- a/custom_components/audiobookshelf/config_flow.py +++ b/custom_components/audiobookshelf/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any +import aiohttp import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry @@ -12,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .api import AudiobookshelfApiClient -from .const import CONF_ACCESS_TOKEN, CONF_HOST, DOMAIN +from .const import CONF_ACCESS_TOKEN, CONF_HOST, DOMAIN, PLATFORMS _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -88,14 +89,16 @@ async def _test_credentials( url=api.get_host() + "/api/users", ) _LOGGER.debug("""test_credentials response was: %s""", response) - return True - except (ConnectionError, TimeoutError) as connection_error: - _LOGGER.debug("Connection or Timeout error: %s", connection_error) + if response: + return True + return False + except (ConnectionError, TimeoutError) as connection_or_timeout_error: + _LOGGER.debug("Connection or Timeout error: %s", connection_or_timeout_error) return False - except Exception as exception: - _LOGGER.error("test_credentials failed due to: %s", exception) - raise + except aiohttp.ClientResponseError as client_response_error: + _LOGGER.debug("ClientResponse Error: %s - %s", client_response_error.status, client_response_error.message) + return False class AudiobookshelfOptionsFlowHandler(config_entries.OptionsFlow): @@ -127,7 +130,7 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required(x, default=self.options.get(x, True)): bool - for x in sorted(["binary_sensor", "sensor"]) + for x in sorted(PLATFORMS) }, ), ) diff --git a/custom_components/audiobookshelf/const.py b/custom_components/audiobookshelf/const.py index 9c14713..c51656f 100644 --- a/custom_components/audiobookshelf/const.py +++ b/custom_components/audiobookshelf/const.py @@ -15,3 +15,5 @@ CONF_ACCESS_TOKEN = "access_token" CONF_HOST = "host" + +PLATFORMS = ["binary_sensor", "sensor"] diff --git a/tests/conftest.py b/tests/conftest.py index c4fb32c..f3c4bf1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,14 @@ """pytest fixtures.""" -import pytest from unittest.mock import patch -from requests import HTTPError, Timeout +import aiohttp +import pytest +from _pytest.fixtures import FixtureRequest +from requests import HTTPError + @pytest.fixture(autouse=True) -def auto_enable_custom_integrations(enable_custom_integrations): +def auto_enable_custom_integrations(enable_custom_integrations: FixtureRequest) -> None: """Enable custom integrations defined in the test dir.""" yield @@ -45,4 +48,13 @@ def http_error_get_data_fixture() -> None: "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", side_effect=HTTPError, ): - yield None \ No newline at end of file + yield None + +@pytest.fixture(name="client_error_on_get_data") +def client_error_get_data_fixture() -> None: + """Simulate error when retrieving data from API.""" + with patch( + "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", + side_effect=aiohttp.ClientResponseError(request_info=None, history=None), + ): + yield None diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..b5e0f8e --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,173 @@ +"""Test Audiobookshelf config flow.""" +from unittest.mock import patch + +import pytest +from _pytest.fixtures import FixtureRequest +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker + +from custom_components.audiobookshelf.const import ( + DOMAIN, + PLATFORMS, +) + +from .const import MOCK_CONFIG + + +# This fixture bypasses the actual setup of the integration +# since we only want to test the config flow. We test the +# actual functionality of the integration in other test modules. +@pytest.fixture(autouse=True) +def bypass_setup_fixture() -> None: + """Prevent setup.""" + with patch("custom_components.audiobookshelf.async_setup", return_value=True), patch( + "custom_components.audiobookshelf.async_setup_entry", + return_value=True, + ): + yield + +# Here we simiulate a successful config flow from the backend. +# Note that we use the `bypass_get_data` fixture here because +# we want the config flow validation to succeed during the test. +async def test_successful_config_flow(hass:HomeAssistant, aioclient_mock: AiohttpClientMocker)-> None: + """Test a successful config flow.""" + + aioclient_mock.get("some_host/ping", json={"success": True}) + aioclient_mock.get("some_host/api/users", json={"users": []}) + aioclient_mock.get("some_host/api/users/online", json={"openSessions": []}) + + + # Initialize a config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, + ) + + # Check that the config flow shows the user form as the first step + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # If a user were to enter `some_host` for username and `test_password` + # for password, it would result in this function call + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG, + ) + + # Check that the config flow is complete and a new entry is created with + # the input data + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "some_host" + assert result["data"] == MOCK_CONFIG + assert result["result"] + + aioclient_mock.clear_requests() + + +# In this case, we want to simulate a failure during the config flow. +# We use the `error_on_get_data` mock instead of `bypass_get_data` +# (note the function parameters) to raise an Exception during +# validation of the input config. +async def test_failed_config_flow(hass:HomeAssistant, aioclient_mock: AiohttpClientMocker)-> None: + """Test a failed config flow due to credential validation failure.""" + aioclient_mock.get("some_host/ping", json={"success": True}) + aioclient_mock.get("some_host/api/users", status=404) + aioclient_mock.get("some_host/api/users/online", status=404) + + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth"} + + aioclient_mock.clear_requests() + + +async def test_timeout_error_config_flow(hass: HomeAssistant, timeout_error_on_get_data: FixtureRequest)-> None: + """Test a failed config flow due to credential validation failure.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth"} + +async def test_connectivity_error_config_flow(hass: HomeAssistant, connectivity_error_on_get_data:FixtureRequest)-> None: + """Test a failed config flow due to credential validation failure.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth"} + +async def test_client_error_config_flow(hass:HomeAssistant, client_error_on_get_data:FixtureRequest)-> None: + """Test a failed config flow due to credential validation failure.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth"} + +# Our config flow also has an options flow, so we must test it as well. +async def test_options_flow(hass:HomeAssistant)-> None: + """Test an options flow.""" + # Create a new MockConfigEntry and add to HASS (we're bypassing config + # flow entirely) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + entry.add_to_hass(hass) + + # Initialize an options flow + await hass.config_entries.async_setup(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) + + # Verify that the first options step is a user form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # Enter some fake data into the form + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={platform: platform != "sensor" for platform in PLATFORMS}, + ) + + # Verify that the flow finishes + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "some_host" + + # Verify that the options were updated + assert entry.options == {"binary_sensor": True, "sensor": False} diff --git a/tests/test_init.py b/tests/test_init.py index 4f27c76..f697ee5 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,18 +1,17 @@ """Test Audiobookshelf setup process.""" import pytest -from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker from custom_components.audiobookshelf import ( AudiobookshelfDataUpdateCoordinator, async_reload_entry, + async_setup, async_setup_entry, async_unload_entry, - async_setup, ) from custom_components.audiobookshelf.const import ( DOMAIN, @@ -31,9 +30,9 @@ source="some source", ) -async def test_setup(hass: HomeAssistant,): +async def test_setup(hass: HomeAssistant)->None: assert (await async_setup(hass, MOCK_CONFIG)) is True - + async def test_setup_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -44,7 +43,7 @@ async def test_setup_entry( assert await async_setup_entry(hass, config_entry) assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] assert isinstance( - hass.data['audiobookshelf']['test_entry_id_setup'], + hass.data["audiobookshelf"]["test_entry_id_setup"], AudiobookshelfDataUpdateCoordinator, ) aioclient_mock.clear_requests() @@ -178,4 +177,4 @@ async def test_setup_entry_http_exception( assert hass.data[DOMAIN][config_entry.entry_id].data.get("sessions", "") == "HTTPError: Generic HTTP Error happened " assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] \ No newline at end of file + assert config_entry.entry_id not in hass.data[DOMAIN]