From ad6ce5fa83ea32e9acd1b9fd932d950fc01a01ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2020 19:21:13 -0500 Subject: [PATCH] Convert rest sensors to async using httpx (#41973) --- .coveragerc | 1 - homeassistant/components/pvoutput/sensor.py | 14 +- .../components/rest/binary_sensor.py | 27 +- homeassistant/components/rest/manifest.json | 2 +- homeassistant/components/rest/sensor.py | 62 +- homeassistant/components/scrape/sensor.py | 16 +- requirements_all.txt | 3 + requirements_test.txt | 1 + requirements_test_all.txt | 3 + tests/components/rest/test_binary_sensor.py | 618 ++++---- tests/components/rest/test_sensor.py | 1311 ++++++++--------- tests/fixtures/rest/configuration.yaml | 6 + 12 files changed, 1094 insertions(+), 970 deletions(-) diff --git a/.coveragerc b/.coveragerc index 6f0a5e5e6b0..e45ee866613 100644 --- a/.coveragerc +++ b/.coveragerc @@ -721,7 +721,6 @@ omit = homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py homeassistant/components/remote_rpi_gpio/* - homeassistant/components/rest/binary_sensor.py homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py homeassistant/components/ring/camera.py diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 169086af3fc..188c49ca6ee 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -43,7 +43,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the PVOutput sensor.""" name = config.get(CONF_NAME) api_key = config.get(CONF_API_KEY) @@ -54,13 +54,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): headers = {"X-Pvoutput-Apikey": api_key, "X-Pvoutput-SystemId": system_id} rest = RestData(method, _ENDPOINT, auth, headers, payload, verify_ssl) - rest.update() + await rest.async_update() if rest.data is None: _LOGGER.error("Unable to fetch data from PVOutput") return False - add_entities([PvoutputSensor(rest, name)], True) + async_add_entities([PvoutputSensor(rest, name)], True) class PvoutputSensor(Entity): @@ -112,11 +112,15 @@ class PvoutputSensor(Entity): ATTR_VOLTAGE: self.pvcoutput.voltage, } - def update(self): + async def async_update(self): """Get the latest data from the PVOutput API and updates the state.""" try: - self.rest.update() + await self.rest.async_update() self.pvcoutput = self.status._make(self.rest.data.split(",")) except TypeError: self.pvcoutput = None _LOGGER.error("Unable to fetch data from PVOutput. %s", self.rest.data) + + async def async_will_remove_from_hass(self): + """Shutdown the session.""" + await self.rest.async_remove() diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index c5b8f16162a..82df088b01a 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,7 +1,7 @@ """Support for RESTful binary sensors.""" import logging -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import httpx import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from . import DOMAIN, PLATFORMS from .sensor import RestData @@ -68,10 +68,10 @@ PLATFORM_SCHEMA = vol.All( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the REST binary sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) name = config.get(CONF_NAME) resource = config.get(CONF_RESOURCE) @@ -96,18 +96,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(username, password) + auth = httpx.DigestAuth(username, password) else: - auth = HTTPBasicAuth(username, password) + auth = (username, password) else: auth = None rest = RestData(method, resource, auth, headers, payload, verify_ssl, timeout) - rest.update() + await rest.async_update() if rest.data is None: raise PlatformNotReady - add_entities( + async_add_entities( [ RestBinarySensor( hass, @@ -118,7 +118,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): force_update, resource_template, ) - ] + ], + True, ) @@ -186,9 +187,13 @@ class RestBinarySensor(BinarySensorEntity): """Force update.""" return self._force_update - def update(self): + async def async_will_remove_from_hass(self): + """Shutdown the session.""" + await self.rest.async_remove() + + async def async_update(self): """Get the latest data from REST API and updates the state.""" if self._resource_template is not None: self.rest.set_url(self._resource_template.render()) - self.rest.update() + await self.rest.async_update() diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index 3ab926a3b13..198bc971f21 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -2,6 +2,6 @@ "domain": "rest", "name": "RESTful", "documentation": "https://www.home-assistant.io/integrations/rest", - "requirements": ["jsonpath==0.82", "xmltodict==0.12.0"], + "requirements": ["jsonpath==0.82", "xmltodict==0.12.0", "httpx==0.16.1"], "codeowners": [] } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9925eb016cb..0a7fa6ef90f 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,10 +3,8 @@ import json import logging from xml.parsers.expat import ExpatError +import httpx from jsonpath import jsonpath -import requests -from requests import Session -from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol import xmltodict @@ -33,7 +31,7 @@ from homeassistant.const import ( from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from . import DOMAIN, PLATFORMS @@ -79,9 +77,9 @@ PLATFORM_SCHEMA = vol.All( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the RESTful sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) name = config.get(CONF_NAME) resource = config.get(CONF_RESOURCE) @@ -109,19 +107,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(username, password) + auth = httpx.DigestAuth(username, password) else: - auth = HTTPBasicAuth(username, password) + auth = (username, password) else: auth = None rest = RestData(method, resource, auth, headers, payload, verify_ssl, timeout) - rest.update() + await rest.async_update() + if rest.data is None: raise PlatformNotReady # Must update the sensor now (including fetching the rest resource) to # ensure it's updating its state. - add_entities( + async_add_entities( [ RestSensor( hass, @@ -200,12 +199,13 @@ class RestSensor(Entity): """Force update.""" return self._force_update - def update(self): + async def async_update(self): """Get the latest data from REST API and update the state.""" if self._resource_template is not None: self.rest.set_url(self._resource_template.render()) - self.rest.update() + await self.rest.async_update() + value = self.rest.data _LOGGER.debug("Data fetched from resource: %s", value) if self.rest.headers is not None: @@ -250,13 +250,21 @@ class RestSensor(Entity): except ValueError: _LOGGER.warning("REST result could not be parsed as JSON") _LOGGER.debug("Erroneous JSON: %s", value) + else: _LOGGER.warning("Empty reply found when expecting JSON data") + if value is not None and self._value_template is not None: - value = self._value_template.render_with_possible_json_value(value, None) + value = self._value_template.async_render_with_possible_json_value( + value, None + ) self._state = value + async def async_will_remove_from_hass(self): + """Shutdown the session.""" + await self.rest.async_remove() + @property def device_state_attributes(self): """Return the state attributes.""" @@ -267,7 +275,14 @@ class RestData: """Class for handling the data retrieval.""" def __init__( - self, method, resource, auth, headers, data, verify_ssl, timeout=DEFAULT_TIMEOUT + self, + method, + resource, + auth, + headers, + data, + verify_ssl, + timeout=DEFAULT_TIMEOUT, ): """Initialize the data object.""" self._method = method @@ -275,36 +290,39 @@ class RestData: self._auth = auth self._headers = headers self._request_data = data - self._verify_ssl = verify_ssl self._timeout = timeout - self._http_session = Session() + self._verify_ssl = verify_ssl + self._async_client = None self.data = None self.headers = None - def __del__(self): + async def async_remove(self): """Destroy the http session on destroy.""" - self._http_session.close() + if self._async_client: + await self._async_client.aclose() def set_url(self, url): """Set url.""" self._resource = url - def update(self): + async def async_update(self): """Get the latest data from REST service with provided method.""" + if not self._async_client: + self._async_client = httpx.AsyncClient(verify=self._verify_ssl) + _LOGGER.debug("Updating from %s", self._resource) try: - response = self._http_session.request( + response = await self._async_client.request( self._method, self._resource, headers=self._headers, auth=self._auth, data=self._request_data, timeout=self._timeout, - verify=self._verify_ssl, ) self.data = response.text self.headers = response.headers - except requests.exceptions.RequestException as ex: + except httpx.RequestError as ex: _LOGGER.error("Error fetching data: %s failed with %s", self._resource, ex) self.data = None self.headers = None diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 3d25e4a34ae..1979f6f744d 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -53,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Web scrape sensor.""" name = config.get(CONF_NAME) resource = config.get(CONF_RESOURCE) @@ -79,12 +79,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: auth = None rest = RestData(method, resource, auth, headers, payload, verify_ssl) - rest.update() + await rest.async_update() if rest.data is None: raise PlatformNotReady - add_entities( + async_add_entities( [ScrapeSensor(rest, name, select, attr, index, value_template, unit)], True ) @@ -118,9 +118,9 @@ class ScrapeSensor(Entity): """Return the state of the device.""" return self._state - def update(self): + async def async_update(self): """Get the latest data from the source and updates the state.""" - self.rest.update() + await self.rest.async_update() if self.rest.data is None: _LOGGER.error("Unable to retrieve data for %s", self.name) return @@ -143,8 +143,12 @@ class ScrapeSensor(Entity): return if self._value_template is not None: - self._state = self._value_template.render_with_possible_json_value( + self._state = self._value_template.async_render_with_possible_json_value( value, None ) else: self._state = value + + async def async_will_remove_from_hass(self): + """Shutdown the session.""" + await self.rest.async_remove() diff --git a/requirements_all.txt b/requirements_all.txt index 71250de125b..380b13d391f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,6 +777,9 @@ horimote==0.4.1 # homeassistant.components.remember_the_milk httplib2==0.10.3 +# homeassistant.components.rest +httpx==0.16.1 + # homeassistant.components.huawei_lte huawei-lte-api==1.4.12 diff --git a/requirements_test.txt b/requirements_test.txt index 3ec0782ca70..744765bec77 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -24,5 +24,6 @@ pytest-xdist==2.1.0 pytest==6.0.2 requests_mock==1.8.0 responses==0.12.0 +respx==0.14.0 stdlib-list==0.7.0 tqdm==4.49.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79e2525b4d9..c63473b4464 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -400,6 +400,9 @@ homematicip==0.11.0 # homeassistant.components.remember_the_milk httplib2==0.10.3 +# homeassistant.components.rest +httpx==0.16.1 + # homeassistant.components.huawei_lte huawei-lte-api==1.4.12 diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index b18d8f300cf..276dc293f87 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -1,267 +1,381 @@ """The tests for the REST binary sensor platform.""" -import unittest -import pytest -from pytest import raises -import requests -from requests.exceptions import Timeout -import requests_mock +import asyncio +from os import path +import httpx +import respx + +from homeassistant import config as hass_config import homeassistant.components.binary_sensor as binary_sensor -import homeassistant.components.rest.binary_sensor as rest -from homeassistant.const import CONTENT_TYPE_JSON, STATE_OFF, STATE_ON -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import template -from homeassistant.setup import setup_component +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONTENT_TYPE_JSON, + SERVICE_RELOAD, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.setup import async_setup_component from tests.async_mock import Mock, patch -from tests.common import assert_setup_component, get_test_home_assistant -class TestRestBinarySensorSetup(unittest.TestCase): - """Tests for setting up the REST binary sensor platform.""" - - DEVICES = [] - - def add_devices(self, devices, update_before_add=False): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # Reset for this test. - self.DEVICES = [] - self.addCleanup(self.hass.stop) - - def test_setup_missing_config(self): - """Test setup with configuration missing required entries.""" - with assert_setup_component(0): - assert setup_component( - self.hass, binary_sensor.DOMAIN, {"binary_sensor": {"platform": "rest"}} - ) - - def test_setup_missing_schema(self): - """Test setup with resource missing schema.""" - with pytest.raises(PlatformNotReady): - rest.setup_platform( - self.hass, - {"platform": "rest", "resource": "localhost", "method": "GET"}, - None, - ) - - @patch("requests.Session.send", side_effect=requests.exceptions.ConnectionError()) - def test_setup_failed_connect(self, mock_req): - """Test setup when connection error occurs.""" - with raises(PlatformNotReady): - rest.setup_platform( - self.hass, - {"platform": "rest", "resource": "http://localhost", "method": "GET"}, - self.add_devices, - None, - ) - assert len(self.DEVICES) == 0 - - @patch("requests.Session.send", side_effect=Timeout()) - def test_setup_timeout(self, mock_req): - """Test setup when connection timeout occurs.""" - with raises(PlatformNotReady): - rest.setup_platform( - self.hass, - {"platform": "rest", "resource": "http://localhost", "method": "GET"}, - self.add_devices, - None, - ) - assert len(self.DEVICES) == 0 - - @requests_mock.Mocker() - def test_setup_minimum(self, mock_req): - """Test setup with minimum configuration.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "binary_sensor"): - assert setup_component( - self.hass, - "binary_sensor", - {"binary_sensor": {"platform": "rest", "resource": "http://localhost"}}, - ) - self.hass.block_till_done() - assert 1 == mock_req.call_count - - @requests_mock.Mocker() - def test_setup_minimum_resource_template(self, mock_req): - """Test setup with minimum configuration (resource_template).""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "binary_sensor"): - assert setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "rest", - "resource_template": "http://localhost", - } - }, - ) - self.hass.block_till_done() - assert mock_req.call_count == 1 - - @requests_mock.Mocker() - def test_setup_duplicate_resource(self, mock_req): - """Test setup with duplicate resources.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(0, "binary_sensor"): - assert setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "rest", - "resource": "http://localhost", - "resource_template": "http://localhost", - } - }, - ) - self.hass.block_till_done() - - @requests_mock.Mocker() - def test_setup_get(self, mock_req): - """Test setup with valid configuration.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "binary_sensor"): - assert setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "rest", - "resource": "http://localhost", - "method": "GET", - "value_template": "{{ value_json.key }}", - "name": "foo", - "verify_ssl": "true", - "authentication": "basic", - "username": "my username", - "password": "my password", - "headers": {"Accept": CONTENT_TYPE_JSON}, - } - }, - ) - self.hass.block_till_done() - assert 1 == mock_req.call_count - - @requests_mock.Mocker() - def test_setup_post(self, mock_req): - """Test setup with valid configuration.""" - mock_req.post("http://localhost", status_code=200) - with assert_setup_component(1, "binary_sensor"): - assert setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "rest", - "resource": "http://localhost", - "method": "POST", - "value_template": "{{ value_json.key }}", - "payload": '{ "device": "toaster"}', - "name": "foo", - "verify_ssl": "true", - "authentication": "basic", - "username": "my username", - "password": "my password", - "headers": {"Accept": CONTENT_TYPE_JSON}, - } - }, - ) - self.hass.block_till_done() - assert 1 == mock_req.call_count +async def test_setup_missing_basic_config(hass): + """Test setup with configuration missing required entries.""" + assert await async_setup_component( + hass, binary_sensor.DOMAIN, {"binary_sensor": {"platform": "rest"}} + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 -class TestRestBinarySensor(unittest.TestCase): - """Tests for REST binary sensor platform.""" +async def test_setup_missing_config(hass): + """Test setup with configuration missing required entries.""" + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + "binary_sensor": { + "platform": "rest", + "resource": "localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.rest = Mock("RestData") - self.rest.update = Mock( - "RestData.update", side_effect=self.update_side_effect('{ "key": false }') + +@respx.mock +async def test_setup_failed_connect(hass): + """Test setup when connection error occurs.""" + respx.get( + "http://localhost", content=httpx.RequestError(message="any", request=Mock()) + ) + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +@respx.mock +async def test_setup_timeout(hass): + """Test setup when connection timeout occurs.""" + respx.get("http://localhost", content=asyncio.TimeoutError()) + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + "binary_sensor": { + "platform": "rest", + "resource": "localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +@respx.mock +async def test_setup_minimum(hass): + """Test setup with minimum configuration.""" + respx.get("http://localhost", status_code=200) + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_minimum_resource_template(hass): + """Test setup with minimum configuration (resource_template).""" + respx.get("http://localhost", status_code=200) + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + "binary_sensor": { + "platform": "rest", + "resource_template": "http://localhost", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_duplicate_resource_template(hass): + """Test setup with duplicate resources.""" + respx.get("http://localhost", status_code=200) + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "resource_template": "http://localhost", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +@respx.mock +async def test_setup_get(hass): + """Test setup with valid configuration.""" + respx.get("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "my username", + "password": "my password", + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_get_digest_auth(hass): + """Test setup with valid configuration.""" + respx.get("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "authentication": "digest", + "username": "my username", + "password": "my password", + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_post(hass): + """Test setup with valid configuration.""" + respx.post("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "POST", + "value_template": "{{ value_json.key }}", + "payload": '{ "device": "toaster"}', + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "my username", + "password": "my password", + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_get_off(hass): + """Test setup with valid off configuration.""" + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/json"}, + content='{"dog": false}', + ) + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.dog }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_OFF + + +@respx.mock +async def test_setup_get_on(hass): + """Test setup with valid on configuration.""" + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/json"}, + content='{"dog": true}', + ) + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.dog }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_ON + + +@respx.mock +async def test_setup_with_exception(hass): + """Test setup with exception.""" + respx.get("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.dog }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_OFF + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + respx.clear() + respx.get( + "http://localhost", content=httpx.RequestError(message="any", request=Mock()) + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["binary_sensor.foo"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_reload(hass): + """Verify we can reload reset sensors.""" + + respx.get("http://localhost", status_code=200) + + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "rest", + "method": "GET", + "name": "mockrest", + "resource": "http://localhost", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + assert hass.states.get("binary_sensor.mockrest") + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, ) - self.name = "foo" - self.device_class = "light" - self.value_template = template.Template("{{ value_json.key }}", self.hass) - self.force_update = False - self.resource_template = None + await hass.async_block_till_done() - self.binary_sensor = rest.RestBinarySensor( - self.hass, - self.rest, - self.name, - self.device_class, - self.value_template, - self.force_update, - self.resource_template, - ) - self.addCleanup(self.hass.stop) + assert hass.states.get("binary_sensor.mockreset") is None + assert hass.states.get("binary_sensor.rollout") - def update_side_effect(self, data): - """Side effect function for mocking RestData.update().""" - self.rest.data = data - def test_name(self): - """Test the name.""" - assert self.name == self.binary_sensor.name - - def test_device_class(self): - """Test the device class.""" - assert self.device_class == self.binary_sensor.device_class - - def test_initial_state(self): - """Test the initial state.""" - self.binary_sensor.update() - assert STATE_OFF == self.binary_sensor.state - - def test_update_when_value_is_none(self): - """Test state gets updated to unknown when sensor returns no data.""" - self.rest.update = Mock( - "RestData.update", side_effect=self.update_side_effect(None) - ) - self.binary_sensor.update() - assert not self.binary_sensor.available - - def test_update_when_value_changed(self): - """Test state gets updated when sensor returns a new status.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect('{ "key": true }'), - ) - self.binary_sensor.update() - assert STATE_ON == self.binary_sensor.state - assert self.binary_sensor.available - - def test_update_when_failed_request(self): - """Test state gets updated when sensor returns a new status.""" - self.rest.update = Mock( - "rest.RestData.update", side_effect=self.update_side_effect(None) - ) - self.binary_sensor.update() - assert not self.binary_sensor.available - - def test_update_with_no_template(self): - """Test update when there is no value template.""" - self.rest.update = Mock( - "rest.RestData.update", side_effect=self.update_side_effect("true") - ) - self.binary_sensor = rest.RestBinarySensor( - self.hass, - self.rest, - self.name, - self.device_class, - None, - self.force_update, - self.resource_template, - ) - self.binary_sensor.update() - assert STATE_ON == self.binary_sensor.state - assert self.binary_sensor.available +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 5ffa12c6167..6a7e444cedd 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1,696 +1,665 @@ """The tests for the REST sensor platform.""" +import asyncio from os import path -import unittest -import pytest -from pytest import raises -import requests -from requests.exceptions import RequestException, Timeout -from requests.structures import CaseInsensitiveDict -import requests_mock +import httpx +import respx from homeassistant import config as hass_config -import homeassistant.components.rest.sensor as rest import homeassistant.components.sensor as sensor from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_JSON, - CONTENT_TYPE_TEXT_PLAIN, DATA_MEGABYTES, SERVICE_RELOAD, + STATE_UNKNOWN, ) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.config_validation import template -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.setup import async_setup_component from tests.async_mock import Mock, patch -from tests.common import assert_setup_component, get_test_home_assistant -class TestRestSensorSetup(unittest.TestCase): - """Tests for setting up the REST sensor platform.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.addCleanup(self.hass.stop) - - def test_setup_missing_config(self): - """Test setup with configuration missing required entries.""" - with assert_setup_component(0): - assert setup_component( - self.hass, sensor.DOMAIN, {"sensor": {"platform": "rest"}} - ) - - def test_setup_missing_schema(self): - """Test setup with resource missing schema.""" - with pytest.raises(PlatformNotReady): - rest.setup_platform( - self.hass, - {"platform": "rest", "resource": "localhost", "method": "GET"}, - None, - ) - - @patch("requests.Session.send", side_effect=requests.exceptions.ConnectionError()) - def test_setup_failed_connect(self, mock_req): - """Test setup when connection error occurs.""" - with raises(PlatformNotReady): - rest.setup_platform( - self.hass, - {"platform": "rest", "resource": "http://localhost", "method": "GET"}, - lambda devices, update=True: None, - ) - - @patch("requests.Session.send", side_effect=Timeout()) - def test_setup_timeout(self, mock_req): - """Test setup when connection timeout occurs.""" - with raises(PlatformNotReady): - rest.setup_platform( - self.hass, - {"platform": "rest", "resource": "http://localhost", "method": "GET"}, - lambda devices, update=True: None, - ) - - @requests_mock.Mocker() - def test_setup_minimum(self, mock_req): - """Test setup with minimum configuration.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "sensor"): - assert setup_component( - self.hass, - "sensor", - {"sensor": {"platform": "rest", "resource": "http://localhost"}}, - ) - self.hass.block_till_done() - assert 2 == mock_req.call_count - - @requests_mock.Mocker() - def test_setup_minimum_resource_template(self, mock_req): - """Test setup with minimum configuration (resource_template).""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "sensor"): - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "rest", - "resource_template": "http://localhost", - } - }, - ) - self.hass.block_till_done() - assert mock_req.call_count == 2 - - @requests_mock.Mocker() - def test_setup_duplicate_resource(self, mock_req): - """Test setup with duplicate resources.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(0, "sensor"): - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "rest", - "resource": "http://localhost", - "resource_template": "http://localhost", - } - }, - ) - self.hass.block_till_done() - - @requests_mock.Mocker() - def test_setup_get(self, mock_req): - """Test setup with valid configuration.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "sensor"): - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "rest", - "resource": "http://localhost", - "method": "GET", - "value_template": "{{ value_json.key }}", - "name": "foo", - "unit_of_measurement": DATA_MEGABYTES, - "verify_ssl": "true", - "timeout": 30, - "authentication": "basic", - "username": "my username", - "password": "my password", - "headers": {"Accept": CONTENT_TYPE_JSON}, - } - }, - ) - self.hass.block_till_done() - assert 2 == mock_req.call_count - - @requests_mock.Mocker() - def test_setup_post(self, mock_req): - """Test setup with valid configuration.""" - mock_req.post("http://localhost", status_code=200) - with assert_setup_component(1, "sensor"): - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "rest", - "resource": "http://localhost", - "method": "POST", - "value_template": "{{ value_json.key }}", - "payload": '{ "device": "toaster"}', - "name": "foo", - "unit_of_measurement": DATA_MEGABYTES, - "verify_ssl": "true", - "timeout": 30, - "authentication": "basic", - "username": "my username", - "password": "my password", - "headers": {"Accept": CONTENT_TYPE_JSON}, - } - }, - ) - self.hass.block_till_done() - assert 2 == mock_req.call_count - - @requests_mock.Mocker() - def test_setup_get_xml(self, mock_req): - """Test setup with valid configuration.""" - mock_req.get("http://localhost", status_code=200) - with assert_setup_component(1, "sensor"): - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "rest", - "resource": "http://localhost", - "method": "GET", - "value_template": "{{ value_json.key }}", - "name": "foo", - "unit_of_measurement": DATA_MEGABYTES, - "verify_ssl": "true", - "timeout": 30, - "authentication": "basic", - "username": "my username", - "password": "my password", - "headers": {"Accept": "text/xml"}, - } - }, - ) - self.hass.block_till_done() - assert 2 == mock_req.call_count +async def test_setup_missing_config(hass): + """Test setup with configuration missing required entries.""" + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": {"platform": "rest"}} + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 -class TestRestSensor(unittest.TestCase): - """Tests for REST sensor platform.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.initial_state = "initial_state" - self.rest = Mock("rest.RestData") - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '{ "key": "' + self.initial_state + '" }', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.name = "foo" - self.unit_of_measurement = DATA_MEGABYTES - self.device_class = None - self.value_template = template("{{ value_json.key }}") - self.json_attrs_path = None - self.value_template.hass = self.hass - self.force_update = False - self.resource_template = None - - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - self.value_template, - [], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.addCleanup(self.hass.stop) - - def update_side_effect(self, data, headers): - """Side effect function for mocking RestData.update().""" - self.rest.data = data - self.rest.headers = headers - - def test_name(self): - """Test the name.""" - assert self.name == self.sensor.name - - def test_unit_of_measurement(self): - """Test the unit of measurement.""" - assert self.unit_of_measurement == self.sensor.unit_of_measurement - - def test_force_update(self): - """Test the unit of measurement.""" - assert self.force_update == self.sensor.force_update - - def test_state(self): - """Test the initial state.""" - self.sensor.update() - assert self.initial_state == self.sensor.state - - def test_update_when_value_is_none(self): - """Test state gets updated to unknown when sensor returns no data.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect(None, CaseInsensitiveDict()), - ) - self.sensor.update() - assert self.sensor.state is None - assert not self.sensor.available - - def test_update_when_value_changed(self): - """Test state gets updated when sensor returns a new status.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '{ "key": "updated_state" }', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.sensor.update() - assert "updated_state" == self.sensor.state - assert self.sensor.available - - def test_update_with_no_template(self): - """Test update when there is no value template.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - "plain_state", CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}) - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - None, - [], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - assert "plain_state" == self.sensor.state - assert self.sensor.available - - def test_update_with_json_attrs(self): - """Test attributes get extracted from a JSON result.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '{ "key": "some_json_value" }', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - None, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - assert "some_json_value" == self.sensor.device_state_attributes["key"] - - def test_update_with_json_attrs_list_dict(self): - """Test attributes get extracted from a JSON list[0] result.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '[{ "key": "another_value" }]', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - None, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - assert "another_value" == self.sensor.device_state_attributes["key"] - - @patch("homeassistant.components.rest.sensor._LOGGER") - def test_update_with_json_attrs_no_data(self, mock_logger): - """Test attributes when no JSON result fetched.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - None, CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}) - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - None, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - assert {} == self.sensor.device_state_attributes - assert mock_logger.warning.called - - @patch("homeassistant.components.rest.sensor._LOGGER") - def test_update_with_json_attrs_not_dict(self, mock_logger): - """Test attributes get extracted from a JSON result.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '["list", "of", "things"]', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - None, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - assert {} == self.sensor.device_state_attributes - assert mock_logger.warning.called - - @patch("homeassistant.components.rest.sensor._LOGGER") - def test_update_with_json_attrs_bad_JSON(self, mock_logger): - """Test attributes get extracted from a JSON result.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - "This is text rather than JSON data.", - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_TEXT_PLAIN}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - None, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - assert {} == self.sensor.device_state_attributes - assert mock_logger.warning.called - assert mock_logger.debug.called - - def test_update_with_json_attrs_and_template(self): - """Test attributes get extracted from a JSON result.""" - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '{ "key": "json_state_updated_value" }', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - self.value_template, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - self.sensor.update() - - assert "json_state_updated_value" == self.sensor.state - assert ( - "json_state_updated_value" == self.sensor.device_state_attributes["key"] - ), self.force_update - - def test_update_with_json_attrs_with_json_attrs_path(self): - """Test attributes get extracted from a JSON result with a template for the attributes.""" - json_attrs_path = "$.toplevel.second_level" - value_template = template("{{ value_json.toplevel.master_value }}") - value_template.hass = self.hass - - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '{ "toplevel": {"master_value": "master", "second_level": {"some_json_key": "some_json_value", "some_json_key2": "some_json_value2" } } }', - CaseInsensitiveDict({"Content-Type": CONTENT_TYPE_JSON}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - value_template, - ["some_json_key", "some_json_key2"], - self.force_update, - self.resource_template, - json_attrs_path, - ) - - self.sensor.update() - assert "some_json_value" == self.sensor.device_state_attributes["some_json_key"] - assert ( - "some_json_value2" == self.sensor.device_state_attributes["some_json_key2"] - ) - assert "master" == self.sensor.state - - def test_update_with_xml_convert_json_attrs_with_json_attrs_path(self): - """Test attributes get extracted from a JSON result that was converted from XML with a template for the attributes.""" - json_attrs_path = "$.toplevel.second_level" - value_template = template("{{ value_json.toplevel.master_value }}") - value_template.hass = self.hass - - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - "mastersome_json_valuesome_json_value2", - CaseInsensitiveDict({"Content-Type": "text/xml+svg"}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - value_template, - ["some_json_key", "some_json_key2"], - self.force_update, - self.resource_template, - json_attrs_path, - ) - - self.sensor.update() - assert "some_json_value" == self.sensor.device_state_attributes["some_json_key"] - assert ( - "some_json_value2" == self.sensor.device_state_attributes["some_json_key2"] - ) - assert "master" == self.sensor.state - - def test_update_with_xml_convert_json_attrs_with_jsonattr_template(self): - """Test attributes get extracted from a JSON result that was converted from XML.""" - json_attrs_path = "$.response" - value_template = template("{{ value_json.response.bss.wlan }}") - value_template.hass = self.hass - - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - '01255648alexander000bogus000000000upupupup000x0XF0x0XF 0', - CaseInsensitiveDict({"Content-Type": "text/xml"}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - value_template, - ["led0", "led1", "temp0", "time0", "ver"], - self.force_update, - self.resource_template, - json_attrs_path, - ) - - self.sensor.update() - assert "0" == self.sensor.device_state_attributes["led0"] - assert "0" == self.sensor.device_state_attributes["led1"] - assert "0x0XF0x0XF" == self.sensor.device_state_attributes["temp0"] - assert "0" == self.sensor.device_state_attributes["time0"] - assert "12556" == self.sensor.device_state_attributes["ver"] - assert "bogus" == self.sensor.state - - def test_update_with_application_xml_convert_json_attrs_with_jsonattr_template( - self, - ): - """Test attributes get extracted from a JSON result that was converted from XML with application/xml mime type.""" - json_attrs_path = "$.main" - value_template = template("{{ value_json.main.dog }}") - value_template.hass = self.hass - - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - "
13
", - CaseInsensitiveDict({"Content-Type": "application/xml"}), - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - value_template, - ["dog", "cat"], - self.force_update, - self.resource_template, - json_attrs_path, - ) - - self.sensor.update() - assert "3" == self.sensor.device_state_attributes["cat"] - assert "1" == self.sensor.device_state_attributes["dog"] - assert "1" == self.sensor.state - - @patch("homeassistant.components.rest.sensor._LOGGER") - def test_update_with_xml_convert_bad_xml(self, mock_logger): - """Test attributes get extracted from a XML result with bad xml.""" - value_template = template("{{ value_json.toplevel.master_value }}") - value_template.hass = self.hass - - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect( - "this is not xml", CaseInsensitiveDict({"Content-Type": "text/xml"}) - ), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - value_template, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - - self.sensor.update() - assert {} == self.sensor.device_state_attributes - assert mock_logger.warning.called - assert mock_logger.debug.called - - @patch("homeassistant.components.rest.sensor._LOGGER") - def test_update_with_failed_get(self, mock_logger): - """Test attributes get extracted from a XML result with bad xml.""" - value_template = template("{{ value_json.toplevel.master_value }}") - value_template.hass = self.hass - - self.rest.update = Mock( - "rest.RestData.update", - side_effect=self.update_side_effect(None, None), - ) - self.sensor = rest.RestSensor( - self.hass, - self.rest, - self.name, - self.unit_of_measurement, - self.device_class, - value_template, - ["key"], - self.force_update, - self.resource_template, - self.json_attrs_path, - ) - - self.sensor.update() - assert {} == self.sensor.device_state_attributes - assert mock_logger.warning.called - assert mock_logger.debug.called - assert self.sensor.state is None - assert self.sensor.available is False +async def test_setup_missing_schema(hass): + """Test setup with resource missing schema.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + {"sensor": {"platform": "rest", "resource": "localhost", "method": "GET"}}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 -class TestRestData(unittest.TestCase): - """Tests for RestData.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.method = "GET" - self.resource = "http://localhost" - self.verify_ssl = True - self.timeout = 10 - self.rest = rest.RestData( - self.method, self.resource, None, None, None, self.verify_ssl, self.timeout - ) - - @requests_mock.Mocker() - def test_update(self, mock_req): - """Test update.""" - mock_req.get("http://localhost", text="test data") - self.rest.update() - assert "test data" == self.rest.data - - @patch("requests.Session.request", side_effect=RequestException) - def test_update_request_exception(self, mock_req): - """Test update when a request exception occurs.""" - self.rest.update() - assert self.rest.data is None +@respx.mock +async def test_setup_failed_connect(hass): + """Test setup when connection error occurs.""" + respx.get( + "http://localhost", content=httpx.RequestError(message="any", request=Mock()) + ) + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 -async def test_reload(hass, requests_mock): +@respx.mock +async def test_setup_timeout(hass): + """Test setup when connection timeout occurs.""" + respx.get("http://localhost", content=asyncio.TimeoutError()) + assert await async_setup_component( + hass, + sensor.DOMAIN, + {"sensor": {"platform": "rest", "resource": "localhost", "method": "GET"}}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +@respx.mock +async def test_setup_minimum(hass): + """Test setup with minimum configuration.""" + respx.get("http://localhost", status_code=200) + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_minimum_resource_template(hass): + """Test setup with minimum configuration (resource_template).""" + respx.get("http://localhost", status_code=200) + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "rest", + "resource_template": "http://localhost", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_duplicate_resource_template(hass): + """Test setup with duplicate resources.""" + respx.get("http://localhost", status_code=200) + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "resource_template": "http://localhost", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +@respx.mock +async def test_setup_get(hass): + """Test setup with valid configuration.""" + respx.get("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "my username", + "password": "my password", + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_get_digest_auth(hass): + """Test setup with valid configuration.""" + respx.get("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "authentication": "digest", + "username": "my username", + "password": "my password", + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_post(hass): + """Test setup with valid configuration.""" + respx.post("http://localhost", status_code=200, content="{}") + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "POST", + "value_template": "{{ value_json.key }}", + "payload": '{ "device": "toaster"}', + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "my username", + "password": "my password", + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +@respx.mock +async def test_setup_get_xml(hass): + """Test setup with valid xml configuration.""" + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/xml"}, + content="abc", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.dog }}", + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.foo") + assert state.state == "abc" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == DATA_MEGABYTES + + +@respx.mock +async def test_update_with_json_attrs(hass): + """Test attributes get extracted from a JSON result.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": CONTENT_TYPE_JSON}, + content='{ "key": "some_json_value" }', + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.foo") + assert state.state == "some_json_value" + assert state.attributes["key"] == "some_json_value" + + +@respx.mock +async def test_update_with_no_template(hass): + """Test update when there is no value template.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": CONTENT_TYPE_JSON}, + content='{ "key": "some_json_value" }', + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "headers": {"Accept": "text/xml"}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.foo") + assert state.state == '{ "key": "some_json_value" }' + + +@respx.mock +async def test_update_with_json_attrs_no_data(hass, caplog): + """Test attributes when no JSON result fetched.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": CONTENT_TYPE_JSON}, + content="", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "headers": {"Accept": "text/xml"}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.foo") + assert state.state == STATE_UNKNOWN + assert state.attributes == {"unit_of_measurement": "MB", "friendly_name": "foo"} + assert "Empty reply" in caplog.text + + +@respx.mock +async def test_update_with_json_attrs_not_dict(hass, caplog): + """Test attributes get extracted from a JSON result.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": CONTENT_TYPE_JSON}, + content='["list", "of", "things"]', + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "headers": {"Accept": "text/xml"}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.foo") + assert state.state == "" + assert state.attributes == {"unit_of_measurement": "MB", "friendly_name": "foo"} + assert "not a dictionary or list" in caplog.text + + +@respx.mock +async def test_update_with_json_attrs_bad_JSON(hass, caplog): + """Test attributes get extracted from a JSON result.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": CONTENT_TYPE_JSON}, + content="This is text rather than JSON data.", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "headers": {"Accept": "text/xml"}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.foo") + assert state.state == STATE_UNKNOWN + assert state.attributes == {"unit_of_measurement": "MB", "friendly_name": "foo"} + assert "Erroneous JSON" in caplog.text + + +@respx.mock +async def test_update_with_json_attrs_with_json_attrs_path(hass): + """Test attributes get extracted from a JSON result with a template for the attributes.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": CONTENT_TYPE_JSON}, + content='{ "toplevel": {"master_value": "master", "second_level": {"some_json_key": "some_json_value", "some_json_key2": "some_json_value2" } } }', + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.toplevel.master_value }}", + "json_attributes_path": "$.toplevel.second_level", + "json_attributes": ["some_json_key", "some_json_key2"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "headers": {"Accept": "text/xml"}, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.foo") + + assert state.state == "master" + assert state.attributes["some_json_key"] == "some_json_value" + assert state.attributes["some_json_key2"] == "some_json_value2" + + +@respx.mock +async def test_update_with_xml_convert_json_attrs_with_json_attrs_path(hass): + """Test attributes get extracted from a JSON result that was converted from XML with a template for the attributes.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/xml"}, + content="mastersome_json_valuesome_json_value2", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.toplevel.master_value }}", + "json_attributes_path": "$.toplevel.second_level", + "json_attributes": ["some_json_key", "some_json_key2"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.foo") + + assert state.state == "master" + assert state.attributes["some_json_key"] == "some_json_value" + assert state.attributes["some_json_key2"] == "some_json_value2" + + +@respx.mock +async def test_update_with_xml_convert_json_attrs_with_jsonattr_template(hass): + """Test attributes get extracted from a JSON result that was converted from XML.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/xml"}, + content='01255648alexander000bogus000000000upupupup000x0XF0x0XF 0', + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.response.bss.wlan }}", + "json_attributes_path": "$.response", + "json_attributes": ["led0", "led1", "temp0", "time0", "ver"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.foo") + + assert state.state == "bogus" + assert state.attributes["led0"] == "0" + assert state.attributes["led1"] == "0" + assert state.attributes["temp0"] == "0x0XF0x0XF" + assert state.attributes["time0"] == "0" + assert state.attributes["ver"] == "12556" + + +@respx.mock +async def test_update_with_application_xml_convert_json_attrs_with_jsonattr_template( + hass, +): + """Test attributes get extracted from a JSON result that was converted from XML with application/xml mime type.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "application/xml"}, + content="
13
", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.main.dog }}", + "json_attributes_path": "$.main", + "json_attributes": ["dog", "cat"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.foo") + + assert state.state == "1" + assert state.attributes["dog"] == "1" + assert state.attributes["cat"] == "3" + + +@respx.mock +async def test_update_with_xml_convert_bad_xml(hass, caplog): + """Test attributes get extracted from a XML result with bad xml.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/xml"}, + content="", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.toplevel.master_value }}", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.foo") + + assert state.state == STATE_UNKNOWN + assert "Erroneous XML" in caplog.text + assert "Empty reply" in caplog.text + + +@respx.mock +async def test_update_with_failed_get(hass, caplog): + """Test attributes get extracted from a XML result with bad xml.""" + + respx.get( + "http://localhost", + status_code=200, + headers={"content-type": "text/xml"}, + content="", + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.toplevel.master_value }}", + "json_attributes": ["key"], + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.foo") + + assert state.state == STATE_UNKNOWN + assert "Erroneous XML" in caplog.text + assert "Empty reply" in caplog.text + + +@respx.mock +async def test_reload(hass): """Verify we can reload reset sensors.""" - requests_mock.get("http://localhost", text="test data") + respx.get("http://localhost", status_code=200) await async_setup_component( hass, @@ -726,8 +695,6 @@ async def test_reload(hass, requests_mock): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - assert hass.states.get("sensor.mockreset") is None assert hass.states.get("sensor.rollout") diff --git a/tests/fixtures/rest/configuration.yaml b/tests/fixtures/rest/configuration.yaml index 69a4e771ebf..a8a4081fcae 100644 --- a/tests/fixtures/rest/configuration.yaml +++ b/tests/fixtures/rest/configuration.yaml @@ -4,6 +4,12 @@ sensor: method: GET name: rollout +binary_sensor: + - platform: rest + resource: "http://localhost" + method: GET + name: rollout + notify: - name: rest_reloaded platform: rest