diff --git a/CODEOWNERS b/CODEOWNERS index 4ef40a79bd1840..f66c956e6c492f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1038,6 +1038,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 00000000000000..66b68e280bc221 --- /dev/null +++ b/homeassistant/components/ntfy/__init__.py @@ -0,0 +1,31 @@ +"""The ntfy integration.""" + +from __future__ import annotations + +from aiontfy import Ntfy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +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) + entry.runtime_data = Ntfy(entry.data[CONF_URL], session) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +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 00000000000000..af3e81836c70d5 --- /dev/null +++ b/homeassistant/components/ntfy/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for the ntfy integration.""" + +from __future__ import annotations + +import logging +import random +import string +from typing import Any + +from aiontfy import Message, Ntfy +from aiontfy.exceptions import NtfyException, NtfyForbiddenAccessError, NtfyHTTPError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Optional(CONF_TOPIC): str, + } +) + + +class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ntfy.""" + + 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: + if not user_input.get(CONF_TOPIC): + user_input[CONF_TOPIC] = "".join( + random.choices( + string.ascii_lowercase + string.ascii_uppercase + string.digits, + k=16, + ) + ) + self._async_abort_entries_match(user_input) + try: + session = async_get_clientsession(self.hass) + ntfy = Ntfy(user_input[CONF_URL], session) + await ntfy.publish( + Message( + topic=user_input[CONF_TOPIC], + title="Home Assistant", + message="The Home Assistant ntfy integration has been successfully set up for this topic.", + ) + ) + except NtfyForbiddenAccessError: + errors["base"] = "forbidden_topic" + 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 ValueError: + errors["base"] = "invalid_url" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_TOPIC], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py new file mode 100644 index 00000000000000..89e22d90ec85b9 --- /dev/null +++ b/homeassistant/components/ntfy/const.py @@ -0,0 +1,8 @@ +"""Constants for the ntfy integration.""" + +from typing import Final + +DOMAIN = "ntfy" +DEFAULT_URL: Final = "https://ntfy.sh" + +CONF_TOPIC = "topic" diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json new file mode 100644 index 00000000000000..9fe617880afd6e --- /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 00000000000000..1f2ca76d7d863f --- /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": "silver", + "requirements": ["aiontfy==0.2.1"] +} diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py new file mode 100644 index 00000000000000..c81860bb510403 --- /dev/null +++ b/homeassistant/components/ntfy/notify.py @@ -0,0 +1,77 @@ +"""ntfy notification entity.""" + +from __future__ import annotations + +from aiontfy import Message +from aiontfy.exceptions import NtfyException, NtfyHTTPError + +from homeassistant.components.notify import ( + NotifyEntity, + NotifyEntityDescription, + NotifyEntityFeature, +) +from homeassistant.const import 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 AddEntitiesCallback + +from . import NtfyConfigEntry +from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the ntfy notification entity platform.""" + + async_add_entities([NtfyNotifyEntity(config_entry, config_entry.data[CONF_TOPIC])]) + + +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, topic: str) -> None: + """Initialize a notification entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{self.entity_description.key}" + self._attr_supported_features = NotifyEntityFeature.TITLE + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + model_id=config_entry.data[CONF_URL], + name=topic, + configuration_url=f"{DEFAULT_URL}/{topic}", + identifiers={(DOMAIN, config_entry.entry_id)}, + ) + self.ntfy = config_entry.runtime_data + self.topic = topic + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Publish a message to a topic.""" + try: + msg = Message(topic=self.topic, message=message, title=title) + 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 00000000000000..9665da362cc925 --- /dev/null +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -0,0 +1,90 @@ +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: + status: done + comment: tested by publishing a success message to the topic + test-before-setup: + status: exempt + comment: testing would require to trigger a notification + 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: + status: exempt + comment: the integration currently does not implement authenticated requests + 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: todo diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json new file mode 100644 index 00000000000000..8f2aa785340cf1 --- /dev/null +++ b/homeassistant/components/ntfy/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "Setup a topic for publishing notifications", + "data": { + "url": "Service URL", + "topic": "Topic" + }, + + "data_description": { + "url": "Address of the ntfy service. Modify this if you want to use a different server", + "topic": "Enter the name of the topic you want to publish to. Topics may not be password-protected, so choose a name that's not easy to guess. If left empty, a random topic name will be generated." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "forbidden_topic": "Protected topics are not supported", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_url": "Invalid service URL" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "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" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 14061d2e960240..e5ff6822396a3f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -418,6 +418,7 @@ "nobo_hub", "nordpool", "notion", + "ntfy", "nuheat", "nuki", "nut", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 768443c36ee9c8..aa48f2679c19f5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4276,6 +4276,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/requirements_all.txt b/requirements_all.txt index 34e7a267c7dec6..3d238a58331517 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -311,6 +311,9 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.2.1 + # homeassistant.components.nut aionut==4.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59cd9320e44d60..362e6a0e50fdab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -293,6 +293,9 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.2.1 + # homeassistant.components.nut aionut==4.3.3 diff --git a/tests/components/ntfy/__init__.py b/tests/components/ntfy/__init__.py new file mode 100644 index 00000000000000..e059dc61ae96b6 --- /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 00000000000000..aadfca25546161 --- /dev/null +++ b/tests/components/ntfy/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the ntfy tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DEFAULT_URL, DOMAIN +from homeassistant.const import CONF_URL + +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 = {} + + yield client + + +@pytest.fixture(autouse=True) +def mopck_random() -> Generator[AsyncMock]: + """Mock random.""" + + with patch( + "homeassistant.components.ntfy.config_flow.random.choices", + return_value=["mytopic"], + ) as mock_random: + yield mock_random.return_value + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock ntfy configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="mytopic", + data={ + CONF_URL: DEFAULT_URL, + CONF_TOPIC: "mytopic", + }, + entry_id="123456789", + ) diff --git a/tests/components/ntfy/snapshots/test_notify.ambr b/tests/components/ntfy/snapshots/test_notify.ambr new file mode 100644 index 00000000000000..5a16831257713e --- /dev/null +++ b/tests/components/ntfy/snapshots/test_notify.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_notify_platform[notify.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_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_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 00000000000000..349030b0bc70b1 --- /dev/null +++ b/tests/components/ntfy/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the ntfy config flow.""" + +from unittest.mock import AsyncMock, patch + +from aiontfy.exceptions import NtfyException, NtfyForbiddenAccessError, NtfyHTTPError +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("topic", ["mytopic", ""]) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, topic: str +) -> 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"], + { + CONF_URL: "https://ntfy.sh", + CONF_TOPIC: topic, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "mytopic" + assert result["data"] == { + CONF_URL: "https://ntfy.sh", + CONF_TOPIC: "mytopic", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyForbiddenAccessError( + 40301, 403, "forbidden", "https://ntfy.sh/docs/publish/#authentication" + ), + "forbidden_topic", + ), + (NtfyException, "cannot_connect"), + (ValueError, "invalid_url"), + (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.publish.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + CONF_TOPIC: "mytopic", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.publish.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + CONF_TOPIC: "mytopic", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "mytopic" + assert result["data"] == { + CONF_URL: "https://ntfy.sh", + CONF_TOPIC: "mytopic", + } + 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", + CONF_TOPIC: "mytopic", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py new file mode 100644 index 00000000000000..76bf1049ae8a7e --- /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") + )