diff --git a/.strict-typing b/.strict-typing index 07a96a3d692..348385f04d5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -362,6 +362,7 @@ homeassistant.components.openuv.* homeassistant.components.oralb.* homeassistant.components.otbr.* homeassistant.components.overkiz.* +homeassistant.components.overseerr.* homeassistant.components.p1_monitor.* homeassistant.components.panel_custom.* homeassistant.components.peblar.* diff --git a/CODEOWNERS b/CODEOWNERS index d83c796872a..80026e95a97 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1105,6 +1105,8 @@ build.json @home-assistant/supervisor /tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl +/homeassistant/components/overseerr/ @joostlek +/tests/components/overseerr/ @joostlek /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py new file mode 100644 index 00000000000..6d11dbc1fae --- /dev/null +++ b/homeassistant/components/overseerr/__init__.py @@ -0,0 +1,29 @@ +"""The Overseerr integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool: + """Set up Overseerr from a config entry.""" + + coordinator = OverseerrCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py new file mode 100644 index 00000000000..f63949070d6 --- /dev/null +++ b/homeassistant/components/overseerr/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Overseerr.""" + +from typing import Any + +from python_overseerr import OverseerrClient +from python_overseerr.exceptions import OverseerrError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): + """Overseerr config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + url = URL(user_input[CONF_URL]) + if (host := url.host) is None: + errors[CONF_URL] = "invalid_host" + else: + self._async_abort_entries_match({CONF_HOST: host}) + port = url.port + assert port + client = OverseerrClient( + host, + port, + user_input[CONF_API_KEY], + ssl=url.scheme == "https", + session=async_get_clientsession(self.hass), + ) + try: + await client.get_request_count() + except OverseerrError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Overseerr", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: url.scheme == "https", + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str} + ), + errors=errors, + ) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py new file mode 100644 index 00000000000..b07b64338c8 --- /dev/null +++ b/homeassistant/components/overseerr/const.py @@ -0,0 +1,8 @@ +"""Constants for the overseerr integration.""" + +import logging + +DOMAIN = "overseerr" +LOGGER = logging.getLogger(__package__) + +REQUESTS = "requests" diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py new file mode 100644 index 00000000000..79ad738c037 --- /dev/null +++ b/homeassistant/components/overseerr/coordinator.py @@ -0,0 +1,50 @@ +"""Define an object to coordinate fetching Overseerr data.""" + +from datetime import timedelta + +from python_overseerr import OverseerrClient, RequestCount +from python_overseerr.exceptions import OverseerrConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type OverseerrConfigEntry = ConfigEntry[OverseerrCoordinator] + + +class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): + """Class to manage fetching Overseerr data.""" + + config_entry: OverseerrConfigEntry + + def __init__(self, hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=timedelta(minutes=5), + ) + self.client = OverseerrClient( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_API_KEY], + ssl=entry.data[CONF_SSL], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> RequestCount: + """Fetch data from API endpoint.""" + try: + return await self.client.get_request_count() + except OverseerrConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/overseerr/entity.py b/homeassistant/components/overseerr/entity.py new file mode 100644 index 00000000000..6e835347736 --- /dev/null +++ b/homeassistant/components/overseerr/entity.py @@ -0,0 +1,22 @@ +"""Base entity for Overseerr.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OverseerrCoordinator + + +class OverseerrEntity(CoordinatorEntity[OverseerrCoordinator]): + """Defines a base Overseerr entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: OverseerrCoordinator, key: str) -> None: + """Initialize Overseerr entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json new file mode 100644 index 00000000000..0d71028ce5a --- /dev/null +++ b/homeassistant/components/overseerr/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "total_requests": { + "default": "mdi:forum" + }, + "movie_requests": { + "default": "mdi:movie-open" + }, + "tv_requests": { + "default": "mdi:television-box" + }, + "pending_requests": { + "default": "mdi:clock" + }, + "declined_requests": { + "default": "mdi:movie-open-off" + }, + "processing_requests": { + "default": "mdi:sync" + }, + "available_requests": { + "default": "mdi:message-bulleted" + } + } + } +} diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json new file mode 100644 index 00000000000..e47ed981b04 --- /dev/null +++ b/homeassistant/components/overseerr/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "overseerr", + "name": "Overseerr", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/overseerr", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["python-overseerr==0.2.0"] +} diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml new file mode 100644 index 00000000000..218e4355c96 --- /dev/null +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions or actionable entities. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by the coordinator + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by the coordinator + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py new file mode 100644 index 00000000000..2daaa3de0cb --- /dev/null +++ b/homeassistant/components/overseerr/sensor.py @@ -0,0 +1,107 @@ +"""Support for Overseerr sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from python_overseerr import RequestCount + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import REQUESTS +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .entity import OverseerrEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OverseerrSensorEntityDescription(SensorEntityDescription): + """Describes Overseerr config sensor entity.""" + + value_fn: Callable[[RequestCount], int] + + +SENSORS: tuple[OverseerrSensorEntityDescription, ...] = ( + OverseerrSensorEntityDescription( + key="total_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.total, + ), + OverseerrSensorEntityDescription( + key="movie_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.movie, + ), + OverseerrSensorEntityDescription( + key="tv_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.tv, + ), + OverseerrSensorEntityDescription( + key="pending_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.pending, + ), + OverseerrSensorEntityDescription( + key="declined_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.declined, + ), + OverseerrSensorEntityDescription( + key="processing_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.processing, + ), + OverseerrSensorEntityDescription( + key="available_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.available, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverseerrConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Overseerr sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + async_add_entities( + OverseerrSensor(coordinator, description) for description in SENSORS + ) + + +class OverseerrSensor(OverseerrEntity, SensorEntity): + """Defines an Overseerr sensor.""" + + entity_description: OverseerrSensorEntityDescription + + def __init__( + self, + coordinator: OverseerrCoordinator, + description: OverseerrSensorEntityDescription, + ) -> None: + """Initialize airgradient sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json new file mode 100644 index 00000000000..bc3b5ee30c5 --- /dev/null +++ b/homeassistant/components/overseerr/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "The URL of the Overseerr instance.", + "api_key": "The API key of the Overseerr instance." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "The provided URL is not a valid host." + } + }, + "entity": { + "sensor": { + "total_requests": { + "name": "Total requests" + }, + "movie_requests": { + "name": "Movie requests" + }, + "tv_requests": { + "name": "TV requests" + }, + "pending_requests": { + "name": "Pending requests" + }, + "declined_requests": { + "name": "Declined requests" + }, + "processing_requests": { + "name": "Processing requests" + }, + "available_requests": { + "name": "Available requests" + } + } + }, + "exceptions": { + "connection_error": { + "message": "Error connecting to the Overseerr instance: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 599cc43c08b..731978c0459 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -447,6 +447,7 @@ FLOWS = { "otp", "ourgroceries", "overkiz", + "overseerr", "ovo_energy", "owntracks", "p1_monitor", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 42b00a63bbe..d894327cb4b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4570,6 +4570,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "overseerr": { + "name": "Overseerr", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "ovo_energy": { "name": "OVO Energy", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index f0d024b6b68..f4a0a67a6c7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3376,6 +3376,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.overseerr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.p1_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index cec09a0a4dc..fe5656281db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2408,6 +2408,9 @@ python-opensky==1.0.1 # homeassistant.components.thread python-otbr-api==2.6.0 +# homeassistant.components.overseerr +python-overseerr==0.2.0 + # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a49d3f5530d..2e475ec0029 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1941,6 +1941,9 @@ python-opensky==1.0.1 # homeassistant.components.thread python-otbr-api==2.6.0 +# homeassistant.components.overseerr +python-overseerr==0.2.0 + # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/overseerr/__init__.py b/tests/components/overseerr/__init__.py new file mode 100644 index 00000000000..db96435ecc2 --- /dev/null +++ b/tests/components/overseerr/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Overseerr integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py new file mode 100644 index 00000000000..4a3e6e48441 --- /dev/null +++ b/tests/components/overseerr/conftest.py @@ -0,0 +1,58 @@ +"""Overseerr tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_overseerr import RequestCount + +from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.overseerr.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_overseerr_client() -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + with ( + patch( + "homeassistant.components.overseerr.coordinator.OverseerrClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.overseerr.config_flow.OverseerrClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_request_count.return_value = RequestCount.from_json( + load_fixture("request_count.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Overseerr", + data={ + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) diff --git a/tests/components/overseerr/fixtures/request_count.json b/tests/components/overseerr/fixtures/request_count.json new file mode 100644 index 00000000000..441429cfa00 --- /dev/null +++ b/tests/components/overseerr/fixtures/request_count.json @@ -0,0 +1,10 @@ +{ + "total": 11, + "movie": 9, + "tv": 2, + "pending": 0, + "approved": 11, + "declined": 0, + "processing": 3, + "available": 8 +} diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr new file mode 100644 index 00000000000..749a1aa445c --- /dev/null +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'overseerr', + '01JG00V55WEVTJ0CJHM0GAD7PC', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Overseerr', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_sensor.ambr b/tests/components/overseerr/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..53a9b3dd82a --- /dev/null +++ b/tests/components/overseerr/snapshots/test_sensor.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_all_entities[sensor.overseerr_available_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_available_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Available requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'available_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-available_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_available_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Available requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_available_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.overseerr_declined_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_declined_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Declined requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'declined_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-declined_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_declined_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Declined requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_declined_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.overseerr_movie_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_movie_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Movie requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'movie_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-movie_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_movie_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Movie requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_movie_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.overseerr_pending_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_pending_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pending requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pending_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-pending_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_pending_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Pending requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_pending_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.overseerr_processing_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_processing_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Processing requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'processing_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-processing_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_processing_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Processing requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_processing_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[sensor.overseerr_total_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_total_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-total_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_total_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Total requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_total_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_all_entities[sensor.overseerr_tv_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_tv_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TV requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tv_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-tv_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_tv_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr TV requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_tv_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py new file mode 100644 index 00000000000..7001ccd98a8 --- /dev/null +++ b/tests/components/overseerr/test_config_flow.py @@ -0,0 +1,124 @@ +"""Tests for the Overseerr config flow.""" + +from unittest.mock import AsyncMock + +from python_overseerr.exceptions import OverseerrConnectionError + +from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Overseerr" + assert result["data"] == { + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + } + + +async def test_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow errors.""" + mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_invalid_host( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow invalid host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://", CONF_API_KEY: "test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"url": "invalid_host"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py new file mode 100644 index 00000000000..f2755e4a61a --- /dev/null +++ b/tests/components/overseerr/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Overseerr integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/overseerr/test_sensor.py b/tests/components/overseerr/test_sensor.py new file mode 100644 index 00000000000..9c26ae54df8 --- /dev/null +++ b/tests/components/overseerr/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Overseerr sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)