From b3dd59f20219f058053a82504948e80040eca731 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Nov 2022 21:39:39 +0100 Subject: [PATCH] Add config flow to Scrape (#81193) * Scrape take 2 * cleanup * new entity name * Fix name, add tests * Use FlowResultType * Add test abort * hassfest * Remove not needed test * clean * Remove config entry and implement datacoordinator * fix codeowners * fix codeowners * codeowners reset * Fix coordinator * Remove test config_flow * Fix tests * hassfest * reset config flow * reset strings * reset sensor * Reconfig * Fix tests * coverage * Remove coverage * Remove print * Add config flow * Fix config flow * Add back init * Add entry to sensor * float to int * Fix SelectSelector * Add tests for config entry to existing * Test config flow * Fix test reload * Fix rebase * Fix strings * clean init * Clean test_sensor * Align sensor setup entry * Add error to strings * review changes * clean tests * Add back options flow * review changes * update_listener * Add tests * Remove duplicate abort * strings * sensors to sensor * review changes * more review changes * clarify test payload * fixture name --- homeassistant/components/scrape/__init__.py | 37 +++- .../components/scrape/config_flow.py | 90 +++++--- homeassistant/components/scrape/manifest.json | 3 +- homeassistant/components/scrape/sensor.py | 40 ++++ homeassistant/components/scrape/strings.json | 62 +++--- .../components/scrape/translations/en.json | 56 ++--- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/scrape/__init__.py | 9 + tests/components/scrape/conftest.py | 80 +++++++ tests/components/scrape/test_config_flow.py | 206 ++++++++++++++++++ tests/components/scrape/test_init.py | 18 +- tests/components/scrape/test_sensor.py | 11 +- 13 files changed, 528 insertions(+), 87 deletions(-) create mode 100644 tests/components/scrape/conftest.py create mode 100644 tests/components/scrape/test_config_flow.py diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 5bc865dbb6c..cb5657a9649 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, CONF_SCAN_INTERVAL, @@ -22,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import ScrapeCoordinator SENSOR_SCHEMA = vol.Schema( @@ -79,3 +80,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await asyncio.gather(*load_coroutines) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Scrape from a config entry.""" + + rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options)) + rest = create_rest_data_from_config(hass, rest_config) + + coordinator = ScrapeCoordinator( + hass, + rest, + DEFAULT_SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Scrape config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index a32e371a487..034667a4a76 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -6,6 +6,9 @@ from typing import Any import voluptuous as vol +from homeassistant.components.rest import create_rest_data_from_config +from homeassistant.components.rest.data import DEFAULT_TIMEOUT +from homeassistant.components.rest.schema import DEFAULT_METHOD, METHODS from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -16,18 +19,23 @@ from homeassistant.const import ( CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_HEADERS, + CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, + CONF_TIMEOUT, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, + UnitOfTemperature, ) +from homeassistant.core import async_get_hass from homeassistant.helpers.schema_config_entry_flow import ( SchemaConfigFlowHandler, + SchemaFlowError, SchemaFlowFormStep, SchemaFlowMenuStep, SchemaOptionsFlowHandler, @@ -47,20 +55,15 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) +from . import COMBINED_SCHEMA from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN -SCHEMA_SETUP = { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), +RESOURCE_SETUP = { vol.Required(CONF_RESOURCE): TextSelector( TextSelectorConfig(type=TextSelectorType.URL) ), - vol.Required(CONF_SELECT): TextSelector(), -} - -SCHEMA_OPT = { - vol.Optional(CONF_ATTRIBUTE): TextSelector(), - vol.Optional(CONF_INDEX, default=0): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( + SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) ), vol.Optional(CONF_AUTHENTICATION): SelectSelector( SelectSelectorConfig( @@ -73,32 +76,74 @@ SCHEMA_OPT = { TextSelectorConfig(type=TextSelectorType.PASSWORD) ), vol.Optional(CONF_HEADERS): ObjectSelector(), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), +} + +SENSOR_SETUP = { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_SELECT): TextSelector(), + vol.Optional(CONF_INDEX, default=0): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_ATTRIBUTE): TextSelector(), + vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), vol.Optional(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[e.value for e in SensorDeviceClass], + options=[cls.value for cls in SensorDeviceClass], mode=SelectSelectorMode.DROPDOWN, ) ), vol.Optional(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[e.value for e in SensorStateClass], + options=[cls.value for cls in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in UnitOfTemperature], + custom_value=True, mode=SelectSelectorMode.DROPDOWN, ) ), - vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), } -DATA_SCHEMA = vol.Schema({**SCHEMA_SETUP, **SCHEMA_OPT}) -DATA_SCHEMA_OPT = vol.Schema({**SCHEMA_OPT}) + +def validate_rest_setup(user_input: dict[str, Any]) -> dict[str, Any]: + """Validate rest setup.""" + hass = async_get_hass() + rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input) + try: + create_rest_data_from_config(hass, rest_config) + except Exception as err: + raise SchemaFlowError("resource_error") from err + return user_input + + +def validate_sensor_setup(user_input: dict[str, Any]) -> dict[str, Any]: + """Validate sensor setup.""" + return {"sensor": [{**user_input, CONF_INDEX: int(user_input[CONF_INDEX])}]} + + +DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP) +DATA_SCHEMA_SENSOR = vol.Schema(SENSOR_SETUP) CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { - "user": SchemaFlowFormStep(DATA_SCHEMA), - "import": SchemaFlowFormStep(DATA_SCHEMA), + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_RESOURCE, + next_step=lambda _: "sensor", + validate_user_input=validate_rest_setup, + ), + "sensor": SchemaFlowFormStep( + schema=DATA_SCHEMA_SENSOR, + validate_user_input=validate_sensor_setup, + ), } OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { - "init": SchemaFlowFormStep(DATA_SCHEMA_OPT), + "init": SchemaFlowFormStep(DATA_SCHEMA_RESOURCE), } @@ -110,12 +155,7 @@ class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return options[CONF_NAME] - - def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: - """Check for duplicate records.""" - data: dict[str, Any] = dict(options) - self._async_abort_entries_match(data) + return options[CONF_RESOURCE] class ScrapeOptionsFlowHandler(SchemaOptionsFlowHandler): diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index b7e5660e381..86319ce3744 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -5,5 +5,6 @@ "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.1"], "after_dependencies": ["rest"], "codeowners": ["@fabaff", "@gjohansson-ST", "@epenet"], - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "config_flow": true } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 61f0b9711b1..10b96716929 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, CONF_AUTHENTICATION, @@ -144,6 +145,45 @@ async def async_setup_platform( async_add_entities(entities) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Scrape sensor entry.""" + entities: list = [] + + coordinator: ScrapeCoordinator = hass.data[DOMAIN][entry.entry_id] + config = dict(entry.options) + for sensor in config["sensor"]: + sensor_config: ConfigType = vol.Schema( + TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA + )(sensor) + + name: str = sensor_config[CONF_NAME] + select: str = sensor_config[CONF_SELECT] + attr: str | None = sensor_config.get(CONF_ATTRIBUTE) + index: int = int(sensor_config[CONF_INDEX]) + value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) + + value_template: Template | None = ( + Template(value_string, hass) if value_string is not None else None + ) + entities.append( + ScrapeSensor( + hass, + coordinator, + sensor_config, + name, + None, + select, + attr, + index, + value_template, + ) + ) + + async_add_entities(entities) + + class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], TemplateSensor): """Representation of a web scrape sensor.""" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 11c17a176a3..391bfe5ad9f 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -3,35 +3,48 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, + "error": { + "resource_error": "Could not update rest data. Verify your configuration" + }, "step": { "user": { "data": { - "name": "[%key:common::config_flow::data::name%]", "resource": "Resource", - "select": "Select", - "attribute": "Attribute", - "index": "Index", - "value_template": "Value Template", - "unit_of_measurement": "Unit of Measurement", - "device_class": "Device Class", - "state_class": "State Class", - "authentication": "Authentication", + "authentication": "Select authentication method", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "headers": "Headers" + "headers": "Headers", + "method": "Method", + "timeout": "Timeout" }, "data_description": { "resource": "The URL to the website that contains the value", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", + "headers": "Headers to use for the web request", + "timeout": "Timeout for connection to website" + } + }, + "sensor": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "attribute": "Attribute", + "index": "Index", + "select": "Select", + "value_template": "Value Template", + "device_class": "Device Class", + "state_class": "State Class", + "unit_of_measurement": "Unit of Measurement" + }, + "data_description": { "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", "attribute": "Get value of an attribute on the selected tag", "index": "Defines which of the elements returned by the CSS selector to use", "value_template": "Defines a template to get the state of the sensor", "device_class": "The type/class of the sensor to set the icon in the frontend", "state_class": "The state_class of the sensor", - "authentication": "Type of the HTTP authentication. Either basic or digest", - "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", - "headers": "Headers to use for the web request" + "unit_of_measurement": "Choose temperature measurement or create your own" } } } @@ -40,32 +53,21 @@ "step": { "init": { "data": { - "name": "[%key:component::scrape::config::step::user::data::name%]", "resource": "[%key:component::scrape::config::step::user::data::resource%]", - "select": "[%key:component::scrape::config::step::user::data::select%]", - "attribute": "[%key:component::scrape::config::step::user::data::attribute%]", - "index": "[%key:component::scrape::config::step::user::data::index%]", - "value_template": "[%key:component::scrape::config::step::user::data::value_template%]", - "unit_of_measurement": "[%key:component::scrape::config::step::user::data::unit_of_measurement%]", - "device_class": "[%key:component::scrape::config::step::user::data::device_class%]", - "state_class": "[%key:component::scrape::config::step::user::data::state_class%]", + "method": "[%key:component::scrape::config::step::user::data::method%]", "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", - "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", "username": "[%key:component::scrape::config::step::user::data::username%]", "password": "[%key:component::scrape::config::step::user::data::password%]", - "headers": "[%key:component::scrape::config::step::user::data::headers%]" + "headers": "[%key:component::scrape::config::step::user::data::headers%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", + "timeout": "[%key:component::scrape::config::step::user::data::timeout%]" }, "data_description": { "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", - "select": "[%key:component::scrape::config::step::user::data_description::select%]", - "attribute": "[%key:component::scrape::config::step::user::data_description::attribute%]", - "index": "[%key:component::scrape::config::step::user::data_description::index%]", - "value_template": "[%key:component::scrape::config::step::user::data_description::value_template%]", - "device_class": "[%key:component::scrape::config::step::user::data_description::device_class%]", - "state_class": "[%key:component::scrape::config::step::user::data_description::state_class%]", "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", + "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", - "headers": "[%key:component::scrape::config::step::user::data_description::headers%]" + "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]" } } } diff --git a/homeassistant/components/scrape/translations/en.json b/homeassistant/components/scrape/translations/en.json index b64310f0251..8cc642c12a2 100644 --- a/homeassistant/components/scrape/translations/en.json +++ b/homeassistant/components/scrape/translations/en.json @@ -3,34 +3,47 @@ "abort": { "already_configured": "Account is already configured" }, + "error": { + "resource_error": "Could not update rest data. Verify your configuration" + }, "step": { - "user": { + "sensor": { "data": { "attribute": "Attribute", - "authentication": "Authentication", "device_class": "Device Class", - "headers": "Headers", "index": "Index", "name": "Name", - "password": "Password", - "resource": "Resource", "select": "Select", "state_class": "State Class", "unit_of_measurement": "Unit of Measurement", - "username": "Username", - "value_template": "Value Template", - "verify_ssl": "Verify SSL certificate" + "value_template": "Value Template" }, "data_description": { "attribute": "Get value of an attribute on the selected tag", - "authentication": "Type of the HTTP authentication. Either basic or digest", "device_class": "The type/class of the sensor to set the icon in the frontend", - "headers": "Headers to use for the web request", "index": "Defines which of the elements returned by the CSS selector to use", - "resource": "The URL to the website that contains the value", "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", "state_class": "The state_class of the sensor", - "value_template": "Defines a template to get the state of the sensor", + "unit_of_measurement": "Choose temperature measurement or create your own", + "value_template": "Defines a template to get the state of the sensor" + } + }, + "user": { + "data": { + "authentication": "Select authentication method", + "headers": "Headers", + "method": "Method", + "password": "Password", + "resource": "Resource", + "timeout": "Timeout", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "authentication": "Type of the HTTP authentication. Either basic or digest", + "headers": "Headers to use for the web request", + "resource": "The URL to the website that contains the value", + "timeout": "Timeout for connection to website", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" } } @@ -46,31 +59,20 @@ "step": { "init": { "data": { - "attribute": "Attribute", - "authentication": "Authentication", - "device_class": "Device Class", + "authentication": "Select authentication method", "headers": "Headers", - "index": "Index", - "name": "Name", + "method": "Method", "password": "Password", "resource": "Resource", - "select": "Select", - "state_class": "State Class", - "unit_of_measurement": "Unit of Measurement", + "timeout": "Timeout", "username": "Username", - "value_template": "Value Template", "verify_ssl": "Verify SSL certificate" }, "data_description": { - "attribute": "Get value of an attribute on the selected tag", "authentication": "Type of the HTTP authentication. Either basic or digest", - "device_class": "The type/class of the sensor to set the icon in the frontend", "headers": "Headers to use for the web request", - "index": "Defines which of the elements returned by the CSS selector to use", "resource": "The URL to the website that contains the value", - "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", - "state_class": "The state_class of the sensor", - "value_template": "Defines a template to get the state of the sensor", + "timeout": "Timeout for connection to website", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e3519ac8773..33579c6b5ee 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -347,6 +347,7 @@ FLOWS = { "ruuvitag_ble", "sabnzbd", "samsungtv", + "scrape", "screenlogic", "season", "sense", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 69911965eb4..0eb5ed5fc6f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4597,7 +4597,7 @@ "scrape": { "name": "Scrape", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "screenaway": { diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index d4eac856d7d..b13cc4a7326 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -98,11 +98,20 @@ class MockRestData: self.count += 1 if self.payload == "test_scrape_sensor": self.data = ( + # Default "
" "

Current Version: 2021.12.10

Released: January 17, 2022" "
" "" ) + if self.payload == "test_scrape_sensor2": + self.data = ( + # Hidden version + "
" + "

Hidden Version: 2021.12.10

Released: January 17, 2022" + "
" + "" + ) if self.payload == "test_scrape_uom_and_classes": self.data = ( "
" diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py new file mode 100644 index 00000000000..c3b63cbb6c1 --- /dev/null +++ b/tests/components/scrape/conftest.py @@ -0,0 +1,80 @@ +"""Fixtures for the Scrape integration.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.rest.data import DEFAULT_TIMEOUT +from homeassistant.components.rest.schema import DEFAULT_METHOD, DEFAULT_VERIFY_SSL +from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_METHOD, + CONF_NAME, + CONF_RESOURCE, + CONF_TIMEOUT, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from . import MockRestData + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return default minimal configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + "sensor": [ + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0, + } + ], + } + + +@pytest.fixture(name="get_data") +async def get_data_to_integration_load() -> MockRestData: + """Return RestData. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_data", [{...}]) + """ + return MockRestData("test_scrape_sensor") + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any], get_data: MockRestData +) -> MockConfigEntry: + """Set up the Scrape integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.rest.RestData", + return_value=get_data, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py new file mode 100644 index 00000000000..b8a84fd73e9 --- /dev/null +++ b/tests/components/scrape/test_config_flow.py @@ -0,0 +1,206 @@ +"""Test the Scrape config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.rest.data import DEFAULT_TIMEOUT +from homeassistant.components.rest.schema import DEFAULT_METHOD +from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.const import ( + CONF_INDEX, + CONF_SELECT, + DEFAULT_VERIFY_SSL, +) +from homeassistant.const import ( + CONF_METHOD, + CONF_NAME, + CONF_PASSWORD, + CONF_RESOURCE, + CONF_TIMEOUT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError + +from . import MockRestData + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.rest.RestData", + return_value=get_data, + ) as mock_data, patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["version"] == 1 + assert result3["options"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + "sensor": [ + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + } + ], + } + + assert len(mock_data.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: + """Test config flow error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.rest.RestData", + side_effect=HomeAssistantError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + }, + ) + + assert result2["errors"] == {"base": "resource_error"} + + with patch("homeassistant.components.rest.RestData", return_value=get_data,), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + }, + ) + await hass.async_block_till_done() + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + }, + ) + await hass.async_block_till_done() + + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["title"] == "https://www.home-assistant.io" + assert result4["options"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + "sensor": [ + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + } + ], + } + + +async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test options config flow.""" + + state = hass.states.get("sensor.current_version") + assert state.state == "Current Version: 2021.12.10" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + mocker = MockRestData("test_scrape_sensor2") + with patch("homeassistant.components.rest.RestData", return_value=mocker): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_USERNAME: "secret_username", + CONF_PASSWORD: "secret_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + CONF_USERNAME: "secret_username", + CONF_PASSWORD: "secret_password", + "sensor": [ + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + } + ], + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 1 + + # Check the state of the entity has changed as expected + state = hass.states.get("sensor.current_version") + assert state.state == "Hidden Version: 2021.12.10" diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index 17803cc30c2..9b6122d6010 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -13,7 +14,7 @@ from homeassistant.setup import async_setup_component from . import MockRestData, return_integration_config -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_config(hass: HomeAssistant) -> None: @@ -109,3 +110,18 @@ async def test_setup_config_no_sensors( ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() + + +async def test_setup_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test setup entry.""" + + assert loaded_entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test unload an entry.""" + + assert loaded_entry.state == config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 23c558a72af..2f83cdef239 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -26,7 +26,7 @@ from homeassistant.setup import async_setup_component from . import MockRestData, return_config, return_integration_config -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed DOMAIN = "scrape" @@ -405,3 +405,12 @@ async def test_scrape_sensor_unique_id(hass: HomeAssistant) -> None: entity = entity_reg.async_get("sensor.ha_version") assert entity.unique_id == "ha_version_unique_id" + + +async def test_setup_config_entry( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test setup from config entry.""" + + state = hass.states.get("sensor.current_version") + assert state.state == "Current Version: 2021.12.10"