Add Overseerr integration (#133981)

* Add Overseerr integration

* Add Overseerr integration

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix
This commit is contained in:
Joost Lekkerkerker 2024-12-28 11:50:36 +01:00 committed by GitHub
parent 565fa4ea1f
commit 268c21addd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1130 additions and 0 deletions

View File

@ -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.*

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
"""Constants for the overseerr integration."""
import logging
DOMAIN = "overseerr"
LOGGER = logging.getLogger(__package__)
REQUESTS = "requests"

View File

@ -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

View File

@ -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}"

View File

@ -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"
}
}
}
}

View File

@ -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"]
}

View File

@ -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

View File

@ -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)

View File

@ -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}"
}
}
}

View File

@ -447,6 +447,7 @@ FLOWS = {
"otp",
"ourgroceries",
"overkiz",
"overseerr",
"ovo_energy",
"owntracks",
"p1_monitor",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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",
)

View File

@ -0,0 +1,10 @@
{
"total": 11,
"movie": 9,
"tv": 2,
"pending": 0,
"approved": 11,
"declined": 0,
"processing": 3,
"available": 8
}

View File

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@ -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': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_available_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_available_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '8',
})
# ---
# name: test_all_entities[sensor.overseerr_declined_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_declined_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_declined_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_all_entities[sensor.overseerr_movie_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_movie_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_movie_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9',
})
# ---
# name: test_all_entities[sensor.overseerr_pending_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_pending_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_pending_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_all_entities[sensor.overseerr_processing_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_processing_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_processing_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3',
})
# ---
# name: test_all_entities[sensor.overseerr_total_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_total_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_total_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '11',
})
# ---
# name: test_all_entities[sensor.overseerr_tv_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overseerr_tv_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'requests',
}),
'context': <ANY>,
'entity_id': 'sensor.overseerr_tv_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2',
})
# ---

View File

@ -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"

View File

@ -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

View File

@ -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)