diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 704bf99c147..e4ac712e053 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from typing import cast from aiohttp.hdrs import METH_POST from aiohttp.web_request import Request from aiohttp.web_response import Response from python_overseerr import OverseerrConnectionError +from homeassistant.components import cloud from homeassistant.components.webhook import ( async_generate_url, async_register, @@ -26,6 +28,7 @@ from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .services import setup_services PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] +CONF_CLOUDHOOK_URL = "cloudhook_url" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -64,6 +67,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + class OverseerrWebhookManager: """Overseerr webhook manager.""" @@ -86,6 +101,8 @@ class OverseerrWebhookManager: for url in urls: if url not in res: res.append(url) + if CONF_CLOUDHOOK_URL in self.entry.data: + res.append(self.entry.data[CONF_CLOUDHOOK_URL]) return res async def register_webhook(self) -> None: @@ -101,16 +118,18 @@ class OverseerrWebhookManager: if not await self.check_need_change(): return for url in self.webhook_urls: - if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): - LOGGER.debug("Setting Overseerr webhook to %s", url) - await self.client.set_webhook_notification_config( - enabled=True, - types=REGISTERED_NOTIFICATIONS, - webhook_url=url, - json_payload=JSON_PAYLOAD, - ) + if await self.test_and_set_webhook(url): return - LOGGER.error("Failed to set Overseerr webhook") + LOGGER.info("Failed to register Overseerr webhook") + if ( + cloud.async_active_subscription(self.hass) + and CONF_CLOUDHOOK_URL not in self.entry.data + ): + LOGGER.info("Trying to register a cloudhook URL") + url = await _async_cloudhook_generate_url(self.hass, self.entry) + if await self.test_and_set_webhook(url): + return + LOGGER.error("Failed to register Overseerr cloudhook") async def check_need_change(self) -> bool: """Check if webhook needs to be changed.""" @@ -122,6 +141,19 @@ class OverseerrWebhookManager: or current_config.types != REGISTERED_NOTIFICATIONS ) + async def test_and_set_webhook(self, url: str) -> bool: + """Test and set webhook.""" + if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): + LOGGER.debug("Setting Overseerr webhook to %s", url) + await self.client.set_webhook_notification_config( + enabled=True, + types=REGISTERED_NOTIFICATIONS, + webhook_url=url, + json_payload=JSON_PAYLOAD, + ) + return True + return False + async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> Response: @@ -136,3 +168,16 @@ class OverseerrWebhookManager: async def unregister_webhook(self) -> None: """Unregister webhook.""" async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + + +async def _async_cloudhook_generate_url( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_id = entry.data[CONF_WEBHOOK_ID] + webhook_url = await cloud.async_create_cloudhook(hass, webhook_id) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return cast(str, entry.data[CONF_CLOUDHOOK_URL]) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index ddcf9ccce5e..26dfd6d73e3 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -1,6 +1,7 @@ { "domain": "overseerr", "name": "Overseerr", + "after_dependencies": ["cloud"], "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["http", "webhook"], diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index b05d1d0cb87..9ae6be407ec 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -7,6 +7,7 @@ import pytest from python_overseerr import MovieDetails, RequestCount, RequestResponse from python_overseerr.models import TVDetails, WebhookNotificationConfig +from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL from homeassistant.components.overseerr.const import DOMAIN from homeassistant.const import ( CONF_API_KEY, @@ -66,6 +67,24 @@ def mock_overseerr_client() -> Generator[AsyncMock]: yield client +@pytest.fixture +def mock_overseerr_client_needs_change( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.types = 0 + return mock_overseerr_client + + +@pytest.fixture +def mock_overseerr_client_cloudhook( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.options.webhook_url = "https://hooks.nabu.casa/ABCD" + return mock_overseerr_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" @@ -81,3 +100,21 @@ def mock_config_entry() -> MockConfigEntry: }, entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", ) + + +@pytest.fixture +def mock_cloudhook_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", + CONF_WEBHOOK_ID: WEBHOOK_ID, + CONF_CLOUDHOOK_URL: "https://hooks.nabu.casa/ABCD", + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 0962cd2c2f1..4c6897ed316 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -1,13 +1,18 @@ """Tests for the Overseerr integration.""" from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from python_overseerr.models import WebhookNotificationOptions from syrupy import SnapshotAssertion -from homeassistant.components.overseerr import JSON_PAYLOAD, REGISTERED_NOTIFICATIONS +from homeassistant.components import cloud +from homeassistant.components.overseerr import ( + CONF_CLOUDHOOK_URL, + JSON_PAYLOAD, + REGISTERED_NOTIFICATIONS, +) from homeassistant.components.overseerr.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -15,6 +20,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry +from tests.components.cloud import mock_cloud async def test_device_info( @@ -150,3 +156,192 @@ async def test_prefer_internal_ip( mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0] == "https://www.example.com/api/webhook/test-webhook-id" ) + + +async def test_cloudhook_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_config_entry.data[CONF_CLOUDHOOK_URL] == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_cloudhook_consistent( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_cloudhook_needs_no_change( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_cloudhook: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + len(mock_overseerr_client_cloudhook.test_webhook_notification_config.mock_calls) + == 0 + ) + + +async def test_cloudhook_not_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we prefer local webhook over cloudhook.""" + + await hass.async_block_till_done() + + with ( + patch.object(cloud, "async_active_subscription", return_value=True), + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert CONF_CLOUDHOOK_URL not in mock_config_entry.data + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 1 + ) + assert ( + mock_overseerr_client_needs_change.test_webhook_notification_config.call_args_list[ + 0 + ][0][0] + == "http://10.10.10.10:8123/api/webhook/test-webhook-id" + ) + + +async def test_cloudhook_not_connecting( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test the cloudhook is not registered if Overseerr cannot connect to it.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called() + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called()