From 3c174ce329bf618f8f7f0043059aa810858ac338 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:52:13 +0200 Subject: [PATCH] Add ntfy (ntfy.sh) integration (#135152) Co-authored-by: Robert Resch --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/ntfy/__init__.py | 78 ++++ homeassistant/components/ntfy/config_flow.py | 216 +++++++++++ homeassistant/components/ntfy/const.py | 9 + homeassistant/components/ntfy/icons.json | 9 + homeassistant/components/ntfy/manifest.json | 11 + homeassistant/components/ntfy/notify.py | 86 +++++ .../components/ntfy/quality_scale.yaml | 84 +++++ homeassistant/components/ntfy/strings.json | 101 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ntfy/__init__.py | 1 + tests/components/ntfy/conftest.py | 75 ++++ .../ntfy/snapshots/test_notify.ambr | 49 +++ tests/components/ntfy/test_config_flow.py | 355 ++++++++++++++++++ tests/components/ntfy/test_init.py | 60 +++ tests/components/ntfy/test_notify.py | 137 +++++++ 21 files changed, 1297 insertions(+) create mode 100644 homeassistant/components/ntfy/__init__.py create mode 100644 homeassistant/components/ntfy/config_flow.py create mode 100644 homeassistant/components/ntfy/const.py create mode 100644 homeassistant/components/ntfy/icons.json create mode 100644 homeassistant/components/ntfy/manifest.json create mode 100644 homeassistant/components/ntfy/notify.py create mode 100644 homeassistant/components/ntfy/quality_scale.yaml create mode 100644 homeassistant/components/ntfy/strings.json create mode 100644 tests/components/ntfy/__init__.py create mode 100644 tests/components/ntfy/conftest.py create mode 100644 tests/components/ntfy/snapshots/test_notify.ambr create mode 100644 tests/components/ntfy/test_config_flow.py create mode 100644 tests/components/ntfy/test_init.py create mode 100644 tests/components/ntfy/test_notify.py diff --git a/.strict-typing b/.strict-typing index 69d46958882..be6f540e633 100644 --- a/.strict-typing +++ b/.strict-typing @@ -363,6 +363,7 @@ homeassistant.components.no_ip.* homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* +homeassistant.components.ntfy.* homeassistant.components.number.* homeassistant.components.nut.* homeassistant.components.ohme.* diff --git a/CODEOWNERS b/CODEOWNERS index 1ac564a6991..5896972959e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1051,6 +1051,8 @@ build.json @home-assistant/supervisor /tests/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /tests/components/nsw_rural_fire_service_feed/ @exxamalte +/homeassistant/components/ntfy/ @tr4nt0r +/tests/components/ntfy/ @tr4nt0r /homeassistant/components/nuheat/ @tstabrawa /tests/components/nuheat/ @tstabrawa /homeassistant/components/nuki/ @pschmitt @pvizeli @pree diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py new file mode 100644 index 00000000000..76f09497c8d --- /dev/null +++ b/homeassistant/components/ntfy/__init__.py @@ -0,0 +1,78 @@ +"""The ntfy integration.""" + +from __future__ import annotations + +import logging + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.NOTIFY] + + +type NtfyConfigEntry = ConfigEntry[Ntfy] + + +async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Set up ntfy from a config entry.""" + + session = async_get_clientsession(hass) + ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN)) + + try: + await ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="server_error", + translation_placeholders={"error_msg": str(e.error)}, + ) from e + except NtfyConnectionError as e: + _LOGGER.debug("Error", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from e + except NtfyTimeoutError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from e + + entry.runtime_data = ntfy + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py new file mode 100644 index 00000000000..81ae688f847 --- /dev/null +++ b/homeassistant/components/ntfy/config_flow.py @@ -0,0 +1,216 @@ +"""Config flow for the ntfy integration.""" + +from __future__ import annotations + +import logging +import random +import re +import string +from typing import Any + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant import data_entry_flow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(SECTION_AUTH): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } + ), + {"collapsed": True}, + ), + } +) + +STEP_USER_TOPIC_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOPIC): str, + vol.Optional(CONF_NAME): str, + } +) + +RE_TOPIC = re.compile("^[-_a-zA-Z0-9]{1,64}$") + + +class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ntfy.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"topic": TopicSubentryFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + username = user_input[SECTION_AUTH].get(CONF_USERNAME) + self._async_abort_entries_match( + { + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + } + ) + session = async_get_clientsession(self.hass) + if username: + ntfy = Ntfy( + user_input[CONF_URL], + session, + username, + user_input[SECTION_AUTH].get(CONF_PASSWORD, ""), + ) + else: + ntfy = Ntfy(user_input[CONF_URL], session) + + try: + account = await ntfy.account() + token = ( + (await ntfy.generate_token("Home Assistant")).token + if account.username != "*" + else None + ) + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=url.host or "", + data={ + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + CONF_TOKEN: token, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + +class TopicSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying a topic.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + + return self.async_show_menu( + step_id="user", + menu_options=["add_topic", "generate_topic"], + ) + + async def async_step_generate_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + topic = "".join( + random.choices( + string.ascii_lowercase + string.ascii_uppercase + string.digits, + k=16, + ) + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, + suggested_values={CONF_TOPIC: topic}, + ), + ) + + async def async_step_add_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + config_entry = self._get_entry() + errors: dict[str, str] = {} + + if user_input is not None: + if not RE_TOPIC.match(user_input[CONF_TOPIC]): + errors["base"] = "invalid_topic" + else: + for existing_subentry in config_entry.subentries.values(): + if existing_subentry.unique_id == user_input[CONF_TOPIC]: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]), + data=user_input, + unique_id=user_input[CONF_TOPIC], + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py new file mode 100644 index 00000000000..78355f7e828 --- /dev/null +++ b/homeassistant/components/ntfy/const.py @@ -0,0 +1,9 @@ +"""Constants for the ntfy integration.""" + +from typing import Final + +DOMAIN = "ntfy" +DEFAULT_URL: Final = "https://ntfy.sh" + +CONF_TOPIC = "topic" +SECTION_AUTH = "auth" diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json new file mode 100644 index 00000000000..9fe617880af --- /dev/null +++ b/homeassistant/components/ntfy/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "notify": { + "publish": { + "default": "mdi:console-line" + } + } + } +} diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json new file mode 100644 index 00000000000..95204444fbb --- /dev/null +++ b/homeassistant/components/ntfy/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ntfy", + "name": "ntfy", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ntfy", + "iot_class": "cloud_push", + "loggers": ["aionfty"], + "quality_scale": "bronze", + "requirements": ["aiontfy==0.5.1"] +} diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py new file mode 100644 index 00000000000..ad47b8016e8 --- /dev/null +++ b/homeassistant/components/ntfy/notify.py @@ -0,0 +1,86 @@ +"""ntfy notification entity.""" + +from __future__ import annotations + +from aiontfy import Message +from aiontfy.exceptions import NtfyException, NtfyHTTPError +from yarl import URL + +from homeassistant.components.notify import ( + NotifyEntity, + NotifyEntityDescription, + NotifyEntityFeature, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NtfyConfigEntry +from .const import CONF_TOPIC, DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the ntfy notification entity platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id + ) + + +class NtfyNotifyEntity(NotifyEntity): + """Representation of a ntfy notification entity.""" + + entity_description = NotifyEntityDescription( + key="publish", + translation_key="publish", + name=None, + has_entity_name=True, + ) + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + self.topic = subentry.data[CONF_TOPIC] + + self._attr_supported_features = NotifyEntityFeature.TITLE + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + name=subentry.data.get(CONF_NAME, self.topic), + configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, + identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + ) + self.ntfy = config_entry.runtime_data + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Publish a message to a topic.""" + msg = Message(topic=self.topic, message=message, title=title) + try: + await self.ntfy.publish(msg) + except NtfyHTTPError as e: + raise HomeAssistantError( + translation_key="publish_failed_request_error", + translation_domain=DOMAIN, + translation_placeholders={"error_msg": e.error}, + ) from e + except NtfyException as e: + raise HomeAssistantError( + translation_key="publish_failed_exception", + translation_domain=DOMAIN, + ) from e diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml new file mode 100644 index 00000000000..1b52f91d539 --- /dev/null +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not 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: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless notify entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-category: done + entity-device-class: + status: exempt + comment: no suitable device class for the notify entity + entity-disabled-by-default: + status: exempt + comment: only one entity + entity-translations: + status: exempt + comment: the notify entity uses the topic as name, no translation required + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repeairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json new file mode 100644 index 00000000000..f50777d87ee --- /dev/null +++ b/homeassistant/components/ntfy/strings.json @@ -0,0 +1,101 @@ +{ + "common": { + "topic": "Topic", + "add_topic_description": "Set up a topic for notifications." + }, + "config": { + "step": { + "user": { + "description": "Set up **ntfy** push notification service", + "data": { + "url": "Service URL" + }, + "data_description": { + "url": "Address of the ntfy service. Modify this if you want to use a different server" + }, + "sections": { + "auth": { + "name": "Authentication", + "description": "Depending on whether the server is configured to support access control, some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. To publish/subscribe to protected topics, you can provide a username and password.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Enter the username required to authenticate with protected ntfy topics", + "password": "Enter the password corresponding to the provided username for authentication" + } + } + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "config_subentries": { + "topic": { + "step": { + "user": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "menu_options": { + "add_topic": "Enter topic", + "generate_topic": "Generate topic name" + } + }, + "add_topic": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "data": { + "topic": "[%key:component::ntfy::common::topic%]", + "name": "Display name" + }, + "data_description": { + "topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.", + "name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily." + } + } + }, + "initiate_flow": { + "user": "Add topic" + }, + "entry_type": "[%key:component::ntfy::common::topic%]", + "error": { + "publish_forbidden": "Publishing to this topic is forbidden", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "Topic is already configured", + "invalid_topic": "Invalid topic. Only letters, numbers, underscores, or dashes allowed." + } + } + }, + "exceptions": { + "publish_failed_request_error": { + "message": "Failed to publish notification: {error_msg}" + }, + + "publish_failed_exception": { + "message": "Failed to publish notification due to a connection error" + }, + "authentication_error": { + "message": "Failed to authenticate with ntfy service. Please verify your credentials" + }, + "server_error": { + "message": "Failed to connect to ntfy service due to a server error: {error_msg}" + }, + "connection_error": { + "message": "Failed to connect to ntfy service due to a connection error" + }, + "timeout_error": { + "message": "Failed to connect to ntfy service due to a connection timeout" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c53c83bad38..f6c658b396a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -428,6 +428,7 @@ FLOWS = { "nobo_hub", "nordpool", "notion", + "ntfy", "nuheat", "nuki", "nut", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8dda9de3705..642271aeff3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4430,6 +4430,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "ntfy": { + "name": "ntfy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "nuheat": { "name": "NuHeat", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 0e42a6c3594..5c6db87590f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3386,6 +3386,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ntfy.*] +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.number.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 6661908bb46..f60dc61c6d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -314,6 +314,9 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.1 + # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e59f7de7cc9..43aab60a0e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -296,6 +296,9 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.1 + # homeassistant.components.nut aionut==4.3.4 diff --git a/tests/components/ntfy/__init__.py b/tests/components/ntfy/__init__.py new file mode 100644 index 00000000000..e059dc61ae9 --- /dev/null +++ b/tests/components/ntfy/__init__.py @@ -0,0 +1 @@ +"""Tests for ntfy integration.""" diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py new file mode 100644 index 00000000000..b0279dff2ad --- /dev/null +++ b/tests/components/ntfy/conftest.py @@ -0,0 +1,75 @@ +"""Common fixtures for the ntfy tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from aiontfy import AccountTokenResponse +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ntfy.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aiontfy() -> Generator[AsyncMock]: + """Mock aiontfy.""" + + with ( + patch("homeassistant.components.ntfy.Ntfy", autospec=True) as mock_client, + patch("homeassistant.components.ntfy.config_flow.Ntfy", new=mock_client), + ): + client = mock_client.return_value + + client.publish.return_value = {} + client.generate_token.return_value = AccountTokenResponse( + token="token", last_access=datetime.now() + ) + yield client + + +@pytest.fixture(autouse=True) +def mock_random() -> Generator[MagicMock]: + """Mock random.""" + + with patch( + "homeassistant.components.ntfy.config_flow.random.choices", + return_value=["randomtopic"], + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock ntfy configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={CONF_TOPIC: "mytopic"}, + subentry_id="ABCDEF", + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + ], + ) diff --git a/tests/components/ntfy/snapshots/test_notify.ambr b/tests/components/ntfy/snapshots/test_notify.ambr new file mode 100644 index 00000000000..619ae59cc2f --- /dev/null +++ b/tests/components/ntfy/snapshots/test_notify.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_notify_platform[notify.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.mytopic', + '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': None, + 'platform': 'ntfy', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'publish', + 'unique_id': '123456789_ABCDEF_publish', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mytopic', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py new file mode 100644 index 00000000000..27e5bd18720 --- /dev/null +++ b/tests/components/ntfy/test_config_flow.py @@ -0,0 +1,355 @@ +"""Test the ntfy config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "entry_data"), + [ + ( + { + CONF_URL: "https://ntfy.sh", + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + { + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ), + ( + {CONF_URL: "https://ntfy.sh", SECTION_AUTH: {}}, + {CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None, CONF_TOKEN: "token"}, + ), + ], +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + user_input: dict[str, Any], + entry_data: dict[str, Any], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.ntfy.config_flow.Ntfy.publish", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == entry_data + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_aiontfy.account.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == { + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://ntfy.sh", SECTION_AUTH: {}}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_add_topic_flow(hass: HomeAssistant) -> None: + """Test add topic subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with generated topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/"}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "generate_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "generate_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: ""}, + ) + + mock_random.assert_called_once() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="randomtopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with invalid topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/"}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "invalid,topic"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_topic_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/ntfy/test_init.py b/tests/components/ntfy/test_init.py new file mode 100644 index 00000000000..2ee90854426 --- /dev/null +++ b/tests/components/ntfy/test_init.py @@ -0,0 +1,60 @@ +"""Tests for the ntfy integration.""" + +from unittest.mock import AsyncMock + +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception"), + [ + NtfyUnauthorizedAuthenticationError( + 40101, 401, "unauthorized", "https://ntfy.sh/docs/publish/#authentication" + ), + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + NtfyConnectionError, + NtfyTimeoutError, + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready.""" + + mock_aiontfy.account.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py new file mode 100644 index 00000000000..76bf1049ae8 --- /dev/null +++ b/tests/components/ntfy/test_notify.py @@ -0,0 +1,137 @@ +"""Tests for the ntfy notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from aiontfy import Message +from aiontfy.exceptions import NtfyException, NtfyHTTPError +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import AsyncMock, MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@freeze_time("2025-01-09T12:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == "2025-01-09T12:00:00+00:00" + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + "Failed to publish notification: I'm a teapot", + ), + ( + NtfyException, + "Failed to publish notification due to a connection error", + ), + ], +) +async def test_send_message_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test publish message exceptions.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_aiontfy.publish.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_msg): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + )