diff --git a/.coveragerc b/.coveragerc index 95bf05eb8b0..872e707a864 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1139,9 +1139,6 @@ omit = homeassistant/components/tuya/switch.py homeassistant/components/tuya/util.py homeassistant/components/tuya/vacuum.py - homeassistant/components/twentemilieu/__init__.py - homeassistant/components/twentemilieu/const.py - homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py homeassistant/components/twitter/notify.py diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 7b4e4084364..60b7d07808b 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -46,7 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # For backwards compat, set unique ID if entry.unique_id is None: - hass.config_entries.async_update_entry(entry, unique_id=entry.data[CONF_ID]) + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.data[CONF_ID]) + ) hass.data.setdefault(DOMAIN, {})[entry.data[CONF_ID]] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -58,5 +60,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Twente Milieu config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - del hass.data[DOMAIN][entry.entry_id] + del hass.data[DOMAIN][entry.data[CONF_ID]] return unload_ok diff --git a/homeassistant/components/twentemilieu/const.py b/homeassistant/components/twentemilieu/const.py index 95ab903cc17..8717d26b0f3 100644 --- a/homeassistant/components/twentemilieu/const.py +++ b/homeassistant/components/twentemilieu/const.py @@ -11,3 +11,5 @@ SCAN_INTERVAL = timedelta(hours=1) CONF_POST_CODE = "post_code" CONF_HOUSE_NUMBER = "house_number" CONF_HOUSE_LETTER = "house_letter" + +ENTRY_TYPE_SERVICE: Final = "service" diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index 48aa908356a..b4f0a3730a9 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/twentemilieu", "requirements": ["twentemilieu==0.4.2"], "codeowners": ["@frenck"], + "quality_scale": "platinum", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 04aa635b4a4..bb3176e5834 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import DOMAIN, ENTRY_TYPE_SERVICE @dataclass @@ -97,7 +97,8 @@ class TwenteMilieuSensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{DOMAIN}_{entry.data[CONF_ID]}_{description.key}" self._attr_device_info = DeviceInfo( configuration_url="https://www.twentemilieu.nl", - identifiers={(DOMAIN, entry.data[CONF_ID])}, + entry_type=ENTRY_TYPE_SERVICE, + identifiers={(DOMAIN, str(entry.data[CONF_ID]))}, manufacturer="Twente Milieu", name="Twente Milieu", ) diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py new file mode 100644 index 00000000000..d540658787b --- /dev/null +++ b/tests/components/twentemilieu/conftest.py @@ -0,0 +1,88 @@ +"""Fixtures for the Twente Milieu integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest +from twentemilieu import WasteType + +from homeassistant.components.twentemilieu.const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DOMAIN, +) +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="1234AB 1", + domain=DOMAIN, + data={ + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.twentemilieu.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_twentemilieu_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Twente Milieu client.""" + with patch( + "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", autospec=True + ) as twentemilieu_mock: + twentemilieu = twentemilieu_mock.return_value + twentemilieu.unique_id.return_value = 12345 + yield twentemilieu + + +@pytest.fixture +def mock_twentemilieu() -> Generator[None, MagicMock, None]: + """Return a mocked Twente Milieu client.""" + with patch( + "homeassistant.components.twentemilieu.TwenteMilieu", autospec=True + ) as twentemilieu_mock: + twentemilieu = twentemilieu_mock.return_value + twentemilieu.unique_id.return_value = 12345 + twentemilieu.update.return_value = { + WasteType.NON_RECYCLABLE: date(2021, 11, 1), + WasteType.ORGANIC: date(2021, 11, 2), + WasteType.PACKAGES: date(2021, 11, 3), + WasteType.PAPER: None, + } + yield twentemilieu + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twentemilieu: MagicMock, +) -> MockConfigEntry: + """Set up the TwenteMilieu integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index ebded3cffdd..aec0f29e590 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -1,7 +1,9 @@ """Tests for the Twente Milieu config flow.""" -import aiohttp +from unittest.mock import MagicMock -from homeassistant import config_entries, data_entry_flow +from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError + +from homeassistant import config_entries from homeassistant.components.twentemilieu import config_flow from homeassistant.components.twentemilieu.const import ( CONF_HOUSE_LETTER, @@ -10,116 +12,139 @@ from homeassistant.components.twentemilieu.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ID, CONTENT_TYPE_JSON +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker - -FIXTURE_USER_INPUT = { - CONF_POST_CODE: "1234AB", - CONF_HOUSE_NUMBER: "1", - CONF_HOUSE_LETTER: "A", -} -async def test_show_set_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" +async def test_full_user_flow( + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test registering an integration and finishing flow works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result - -async def test_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we show user form on Twente Milieu connection error.""" - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", exc=aiohttp.ClientError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FIXTURE_USER_INPUT - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "12345" + assert result2.get("data") == { + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + } async def test_invalid_address( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, + mock_setup_entry: MagicMock, ) -> None: - """Test we show user form on Twente Milieu invalid address error.""" - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", - json={"dataList": []}, - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FIXTURE_USER_INPUT - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_address"} - - -async def test_address_already_set_up( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we abort if address has already been set up.""" - MockConfigEntry( - domain=DOMAIN, - data={**FIXTURE_USER_INPUT, CONF_ID: "12345"}, - title="12345", - unique_id="12345", - ).add_to_hass(hass) - - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", - json={"dataList": [{"UniqueId": "12345"}]}, - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_full_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test registering an integration and finishing flow works.""" - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", - json={"dataList": [{"UniqueId": "12345"}]}, - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) + """Test full user flow when the user enters an incorrect address. + This tests also tests if the user recovers from it by entering a valid + address in the second attempt. + """ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result - result = await hass.config_entries.flow.async_configure( + mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuAddressError + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - FIXTURE_USER_INPUT, + user_input={ + CONF_POST_CODE: "1234", + CONF_HOUSE_NUMBER: "1", + }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345" - assert result["data"][CONF_POST_CODE] == FIXTURE_USER_INPUT[CONF_POST_CODE] - assert result["data"][CONF_HOUSE_NUMBER] == FIXTURE_USER_INPUT[CONF_HOUSE_NUMBER] - assert result["data"][CONF_HOUSE_LETTER] == FIXTURE_USER_INPUT[CONF_HOUSE_LETTER] + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "invalid_address"} + assert "flow_id" in result2 + + mock_twentemilieu_config_flow.unique_id.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "12345" + assert result3.get("data") == { + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: None, + } + + +async def test_connection_error( + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, +) -> None: + """Test we show user form on Twente Milieu connection error.""" + mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert result.get("errors") == {"base": "cannot_connect"} + + +async def test_address_already_set_up( + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if address has already been set up.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py new file mode 100644 index 00000000000..d5fd108b67a --- /dev/null +++ b/tests/components/twentemilieu/test_init.py @@ -0,0 +1,60 @@ +"""Tests for the Twente Milieu integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.twentemilieu.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twentemilieu: AsyncMock, +) -> None: + """Test the Twente Milieu configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@patch( + "homeassistant.components.twentemilieu.TwenteMilieu.update", + side_effect=RuntimeError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Twente Milieu configuration entry not ready.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_update_config_entry_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twentemilieu: AsyncMock, +) -> None: + """Test the we update old config entries with an unique ID.""" + mock_config_entry.unique_id = None + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.unique_id == "12345" diff --git a/tests/components/twentemilieu/test_sensor.py b/tests/components/twentemilieu/test_sensor.py new file mode 100644 index 00000000000..11717d3e285 --- /dev/null +++ b/tests/components/twentemilieu/test_sensor.py @@ -0,0 +1,78 @@ +"""Tests for the Twente Milieu sensors.""" +from homeassistant.components.twentemilieu.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_DATE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_waste_pickup_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Twente Milieu waste pickup sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.non_recyclable_waste_pickup") + entry = entity_registry.async_get("sensor.non_recyclable_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Non-recyclable" + assert state.state == "2021-11-01" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Non-recyclable Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.organic_waste_pickup") + entry = entity_registry.async_get("sensor.organic_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Organic" + assert state.state == "2021-11-02" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Organic Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.packages_waste_pickup") + entry = entity_registry.async_get("sensor.packages_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Plastic" + assert state.state == "2021-11-03" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Packages Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.paper_waste_pickup") + entry = entity_registry.async_get("sensor.paper_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Paper" + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Paper Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "12345")} + assert device_entry.manufacturer == "Twente Milieu" + assert device_entry.name == "Twente Milieu" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.configuration_url == "https://www.twentemilieu.nl" + assert not device_entry.model + assert not device_entry.sw_version