diff --git a/.strict-typing b/.strict-typing index 5e1549256616c9..628a7031c1e584 100644 --- a/.strict-typing +++ b/.strict-typing @@ -554,6 +554,7 @@ homeassistant.components.telegram_bot.* homeassistant.components.teslemetry.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* +homeassistant.components.threema.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index 109248b6c72729..9c01b9909c606a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1748,6 +1748,8 @@ CLAUDE.md @home-assistant/core /tests/components/thethingsnetwork/ @angelnu /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core +/homeassistant/components/threema/ @LukasQ +/tests/components/threema/ @LukasQ /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py new file mode 100644 index 00000000000000..942df97277f7d3 --- /dev/null +++ b/homeassistant/components/threema/__init__.py @@ -0,0 +1,60 @@ +"""The Threema Gateway integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .client import ThreemaAPIClient, ThreemaAuthError, ThreemaConnectionError +from .const import CONF_API_SECRET, CONF_GATEWAY_ID, CONF_PRIVATE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.NOTIFY] + +type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Set up Threema Gateway from a config entry.""" + client = ThreemaAPIClient( + hass, + gateway_id=entry.data[CONF_GATEWAY_ID], + api_secret=entry.data[CONF_API_SECRET], + private_key=entry.data.get(CONF_PRIVATE_KEY), + ) + + try: + await client.validate_credentials() + except ThreemaAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ThreemaConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = client + + 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: ThreemaConfigEntry +) -> None: + """Reload entry when config is updated (e.g. subentry added/removed).""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/threema/client.py b/homeassistant/components/threema/client.py new file mode 100644 index 00000000000000..2b758dbb98a057 --- /dev/null +++ b/homeassistant/components/threema/client.py @@ -0,0 +1,133 @@ +"""Threema Gateway API client.""" + +from __future__ import annotations + +import logging + +from threema.gateway import Connection, GatewayError, key +from threema.gateway.e2e import TextMessage +from threema.gateway.exception import GatewayServerError +from threema.gateway.simple import TextMessage as SimpleTextMessage + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +# HTTP 401 from Threema Gateway means invalid credentials +_HTTP_UNAUTHORIZED = 401 + + +class ThreemaConnectionError(Exception): + """Error to indicate a connection issue with the Threema Gateway.""" + + +class ThreemaAuthError(Exception): + """Error to indicate invalid credentials for the Threema Gateway.""" + + +class ThreemaSendError(Exception): + """Error to indicate a message send failure.""" + + +class ThreemaAPIClient: + """Threema Gateway API client.""" + + def __init__( + self, + hass: HomeAssistant, + gateway_id: str, + api_secret: str, + private_key: str | None = None, + ) -> None: + """Initialize the Threema API client.""" + self.hass = hass + self.gateway_id = gateway_id + self.api_secret = api_secret + self.private_key = private_key + + def _get_connection(self) -> Connection: + """Get a Threema Gateway connection. + + Note: Connection manages its own aiohttp session lifecycle. + Do not pass HA's shared session as Connection will close it. + """ + return Connection( + identity=self.gateway_id, + secret=self.api_secret, + key=self.private_key, + ) + + async def validate_credentials(self) -> None: + """Validate the Gateway credentials by checking credits. + + Raises ThreemaAuthError for invalid credentials. + Raises ThreemaConnectionError for other failures. + """ + try: + async with self._get_connection() as conn: + remaining_credits = await conn.get_credits() + _LOGGER.debug( + "Gateway credentials validated, credits: %s", + remaining_credits, + ) + except GatewayServerError as err: + if err.status == _HTTP_UNAUTHORIZED: + raise ThreemaAuthError("Invalid Threema Gateway credentials") from err + raise ThreemaConnectionError( + f"Gateway server error validating credentials: {err}" + ) from err + except GatewayError as err: + raise ThreemaConnectionError( + f"Gateway error validating credentials: {err}" + ) from err + + async def send_text_message(self, recipient_id: str, text: str) -> str: + """Send a text message to a Threema ID. + + Returns the message ID on success. + Raises ThreemaSendError on failure. + """ + async with self._get_connection() as conn: + if self.private_key: + _LOGGER.debug("Sending E2E encrypted message to %s", recipient_id) + message = TextMessage( + connection=conn, + to_id=recipient_id, + text=text, + ) + else: + _LOGGER.debug("Sending simple message to %s", recipient_id) + message = SimpleTextMessage( + connection=conn, + to_id=recipient_id, + text=text, + ) + + try: + message_id: str = await message.send() + except GatewayServerError as err: + if err.status == _HTTP_UNAUTHORIZED: + raise ThreemaAuthError( + "Invalid Threema Gateway credentials" + ) from err + raise ThreemaSendError( + f"Gateway server error sending message to {recipient_id}: {err}" + ) from err + except GatewayError as err: + raise ThreemaSendError( + f"Gateway error sending message to {recipient_id}: {err}" + ) from err + + _LOGGER.debug("Message sent to %s (ID: %s)", recipient_id, message_id) + return message_id + + +def generate_key_pair() -> tuple[str, str]: + """Generate a new key pair for E2E encryption using official SDK. + + Returns tuple of (private_key, public_key) as encoded strings. + """ + private_key_obj, public_key_obj = key.Key.generate_pair() + private_key_str = key.Key.encode(private_key_obj) + public_key_str = key.Key.encode(public_key_obj) + return private_key_str, public_key_str diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py new file mode 100644 index 00000000000000..2944b07899d471 --- /dev/null +++ b/homeassistant/components/threema/config_flow.py @@ -0,0 +1,208 @@ +"""Config flow for Threema Gateway integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + generate_key_pair, +) +from .const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + CONF_PUBLIC_KEY, + CONF_RECIPIENT, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) + +_LOGGER = logging.getLogger(__name__) + + +class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Threema Gateway.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentry types supported by this integration.""" + return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler} + + _gateway_id: str | None = None + _api_secret: str | None = None + _private_key: str | None = None + _public_key: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - choose setup type.""" + return self.async_show_menu( + step_id="user", + menu_options=["credentials", "setup_new"], + ) + + async def async_step_setup_new( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Generate keys for a new Gateway ID.""" + if user_input is not None: + return await self.async_step_credentials() + + try: + private_key, public_key = await self.hass.async_add_executor_job( + generate_key_pair + ) + except Exception: + _LOGGER.exception("Failed to generate key pair") + return self.async_abort(reason="key_generation_failed") + + self._private_key = private_key + self._public_key = public_key + + return self.async_show_form( + step_id="setup_new", + description_placeholders={ + "public_key": public_key, + "private_key": private_key, + }, + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect Gateway credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + gateway_id = user_input[CONF_GATEWAY_ID].strip().upper() + + if not gateway_id.startswith("*") or len(gateway_id) != 8: + errors["base"] = "invalid_gateway_id" + else: + await self.async_set_unique_id(gateway_id) + self._abort_if_unique_id_configured() + + self._gateway_id = gateway_id + self._api_secret = user_input[CONF_API_SECRET].strip() + + private_key = user_input.get(CONF_PRIVATE_KEY, "").strip() or None + if private_key: + self._private_key = private_key + public_key = user_input.get(CONF_PUBLIC_KEY, "").strip() or None + if public_key: + self._public_key = public_key + + client = ThreemaAPIClient( + self.hass, + gateway_id=gateway_id, + api_secret=self._api_secret, + private_key=self._private_key, + ) + + try: + await client.validate_credentials() + except ThreemaAuthError: + errors["base"] = "invalid_auth" + except ThreemaConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating credentials") + errors["base"] = "unknown" + else: + data: dict[str, str] = { + CONF_GATEWAY_ID: self._gateway_id, + CONF_API_SECRET: self._api_secret, + } + if self._private_key: + data[CONF_PRIVATE_KEY] = self._private_key + if self._public_key: + data[CONF_PUBLIC_KEY] = self._public_key + + return self.async_create_entry( + title=f"Threema {self._gateway_id}", + data=data, + ) + + schema = vol.Schema( + { + vol.Required(CONF_GATEWAY_ID): str, + vol.Required(CONF_API_SECRET): str, + vol.Optional(CONF_PRIVATE_KEY, default=self._private_key or ""): str, + vol.Optional(CONF_PUBLIC_KEY, default=self._public_key or ""): str, + } + ) + + return self.async_show_form( + step_id="credentials", + data_schema=schema, + errors=errors, + ) + + +RECIPIENT_SCHEMA = vol.All( + cv.string, + cv.matches_regex(r"^[0-9A-Za-z]{8}$"), + lambda value: value.upper(), +) + + +class RecipientSubentryFlowHandler(ConfigSubentryFlow): + """Handle adding a Threema recipient as a subentry.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the recipient subentry step.""" + errors: dict[str, str] = {} + + if user_input is not None: + recipient_id = user_input[CONF_RECIPIENT] + + # Check for duplicate recipients + for subentry in self._get_entry().subentries.values(): + if subentry.data.get(CONF_RECIPIENT) == recipient_id: + return self.async_abort(reason="already_configured") + + raw_name = user_input.get(CONF_NAME, "").strip() + name = f"{raw_name} ({recipient_id})" if raw_name else recipient_id + + return self.async_create_entry( + title=name, + data={CONF_RECIPIENT: recipient_id}, + unique_id=recipient_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_RECIPIENT): RECIPIENT_SCHEMA, + vol.Optional(CONF_NAME): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/threema/const.py b/homeassistant/components/threema/const.py new file mode 100644 index 00000000000000..ffd5c68c5a669d --- /dev/null +++ b/homeassistant/components/threema/const.py @@ -0,0 +1,10 @@ +"""Constants for the Threema Gateway integration.""" + +DOMAIN = "threema" + +CONF_GATEWAY_ID = "gateway_id" +CONF_API_SECRET = "api_secret" +CONF_PRIVATE_KEY = "private_key" +CONF_PUBLIC_KEY = "public_key" +CONF_RECIPIENT = "recipient" +SUBENTRY_TYPE_RECIPIENT = "recipient" diff --git a/homeassistant/components/threema/manifest.json b/homeassistant/components/threema/manifest.json new file mode 100644 index 00000000000000..c0223742496dfb --- /dev/null +++ b/homeassistant/components/threema/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "threema", + "name": "Threema", + "codeowners": ["@LukasQ"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/threema", + "integration_type": "service", + "iot_class": "cloud_push", + "loggers": ["threema"], + "quality_scale": "bronze", + "requirements": ["threema.gateway==8.0.0"] +} diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py new file mode 100644 index 00000000000000..bae6508238618e --- /dev/null +++ b/homeassistant/components/threema/notify.py @@ -0,0 +1,76 @@ +"""Notify platform for Threema Gateway integration.""" + +from __future__ import annotations + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigSubentry +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 ThreemaConfigEntry +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + ThreemaSendError, +) +from .const import CONF_RECIPIENT, DOMAIN, SUBENTRY_TYPE_RECIPIENT + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThreemaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Threema notify entities from config entry subentries.""" + for subentry_id, subentry in entry.subentries.items(): + if subentry.subentry_type != SUBENTRY_TYPE_RECIPIENT: + continue + async_add_entities( + [ThreemaNotifyEntity(entry.runtime_data, subentry)], + config_subentry_id=subentry_id, + ) + + +class ThreemaNotifyEntity(NotifyEntity): + """Notify entity for sending messages to a Threema recipient.""" + + _attr_has_entity_name = True + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + client: ThreemaAPIClient, + subentry: ConfigSubentry, + ) -> None: + """Initialize the notify entity.""" + self._client = client + self._recipient_id: str = subentry.data[CONF_RECIPIENT] + + self._attr_unique_id = f"{client.gateway_id}_{self._recipient_id}" + self._attr_name = subentry.title + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="Threema", + identifiers={(DOMAIN, client.gateway_id)}, + ) + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to the configured Threema recipient.""" + text = f"*{title}*\n{message}" if title else message + try: + await self._client.send_text_message(self._recipient_id, text) + except ThreemaAuthError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err + except (ThreemaSendError, ThreemaConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml new file mode 100644 index 00000000000000..f6dbbf364a097a --- /dev/null +++ b/homeassistant/components/threema/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: Integration only sends messages, no polling required. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: 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: Integration does not use event subscriptions. + 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: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Notify entities are stateless send-only, availability is not applicable. + integration-owner: done + log-when-unavailable: + status: exempt + comment: Notify entities are stateless send-only. + parallel-updates: + status: exempt + comment: No polling or data updates. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: Cloud service, not discoverable on the network. + discovery-update-info: + status: exempt + comment: Cloud service, not discoverable on the network. + docs-data-update: + status: exempt + comment: Integration does not poll or update data. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Service integration, no physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices in current scope. + entity-category: + status: exempt + comment: Notify entities have no applicable category. + entity-device-class: + status: exempt + comment: No applicable device class for notify entities. + entity-disabled-by-default: + status: exempt + comment: Notify entities should be enabled by default for immediate use. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Notify entities use default platform icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No scenarios requiring repair issues. + stale-devices: + status: exempt + comment: No devices in current scope. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json new file mode 100644 index 00000000000000..92955a14394107 --- /dev/null +++ b/homeassistant/components/threema/strings.json @@ -0,0 +1,82 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "key_generation_failed": "Failed to generate encryption keys." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_gateway_id": "Gateway ID must be 8 characters long and start with *.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "credentials": { + "data": { + "api_secret": "API secret", + "gateway_id": "Gateway ID", + "private_key": "Private key (mandatory for E2E encryption)", + "public_key": "Public key (mandatory for E2E encryption)" + }, + "data_description": { + "api_secret": "Secret you received from Threema", + "gateway_id": "ID of your Gateway, including '*', e.g.: *ABCD123", + "private_key": "Private key, including 'private:' prefix", + "public_key": "Public key, including 'public:' prefix" + }, + "description": "Enter your Threema Gateway ID and secret obtained from gateway.threema.ch.", + "title": "Gateway credentials" + }, + "setup_new": { + "description": "Your keys have been generated. Save them now before continuing!\n\nPublic key: `{public_key}`\nPrivate key: `{private_key}`\n\nWhen registering at gateway.threema.ch, paste only the hex part of the public key (without the `public:` prefix). Store the private key securely.", + "title": "Keys generated" + }, + "user": { + "menu_options": { + "credentials": "Add existing Gateway ID", + "setup_new": "Generate new encryption keys" + }, + "title": "Threema setup" + } + } + }, + "config_subentries": { + "recipient": { + "abort": { + "already_configured": "This recipient is already configured." + }, + "entry_type": "Recipient", + "error": { + "invalid_recipient_id": "Threema ID must be exactly 8 alphanumeric characters." + }, + "initiate_flow": { + "user": "Add recipient" + }, + "step": { + "user": { + "data": { + "name": "Display name", + "recipient": "Threema ID" + }, + "data_description": { + "name": "Optional friendly name for this recipient (e.g. 'Dad').", + "recipient": "The 8-character Threema ID of the recipient." + }, + "description": "Enter the Threema ID of the person you want to send messages to.", + "title": "Add recipient" + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "send_error": { + "message": "Error sending message: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eb103b00ced2e1..ae30873aef6f62 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -728,6 +728,7 @@ "thermopro", "thethingsnetwork", "thread", + "threema", "tibber", "tile", "tilt_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1f9c0286ca3708..1bc0ffe8f6a0c5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7073,6 +7073,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "threema": { + "name": "Threema", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push" + }, "tibber": { "name": "Tibber", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 0ca25a2f94ba2b..7a528393ac88a4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5297,6 +5297,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.threema.*] +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.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3636df6394775a..5a4b2e755898b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3107,6 +3107,9 @@ thingspeak==1.0.0 # homeassistant.components.lg_thinq thinqconnect==1.0.11 +# homeassistant.components.threema +threema.gateway==8.0.0 + # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80bbc94d3a14af..bcdf53127479a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2625,6 +2625,9 @@ thermopro-ble==1.1.3 # homeassistant.components.lg_thinq thinqconnect==1.0.11 +# homeassistant.components.threema +threema.gateway==8.0.0 + # homeassistant.components.tilt_ble tilt-ble==1.0.1 diff --git a/tests/components/threema/__init__.py b/tests/components/threema/__init__.py new file mode 100644 index 00000000000000..a7bc4a6805f0ad --- /dev/null +++ b/tests/components/threema/__init__.py @@ -0,0 +1 @@ +"""Tests for the Threema Gateway integration.""" diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py new file mode 100644 index 00000000000000..1d4b10d87fe7e1 --- /dev/null +++ b/tests/components/threema/conftest.py @@ -0,0 +1,117 @@ +"""Fixtures for Threema Gateway integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + CONF_PUBLIC_KEY, + CONF_RECIPIENT, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.config_entries import ConfigSubentryDataWithId + +from tests.common import MockConfigEntry + +MOCK_GATEWAY_ID = "*TESTGWY" +MOCK_API_SECRET = "test_secret_key_12345" +MOCK_PRIVATE_KEY = ( + "private:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) +MOCK_PUBLIC_KEY = ( + "public:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +) +MOCK_RECIPIENT_ID = "ABCD1234" +MOCK_SUBENTRY_ID = "mock_subentry_id" + +RECIPIENT_SUBENTRY: ConfigSubentryDataWithId = { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, +} + + +@pytest.fixture +def mock_subentries() -> list[ConfigSubentryDataWithId]: + """Fixture providing one recipient subentry by default; override to [] when not needed.""" + return [RECIPIENT_SUBENTRY] + + +@pytest.fixture +def mock_config_entry( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for basic mode (no encryption).""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture +def mock_config_entry_with_keys( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for E2E encrypted mode.""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: MOCK_PRIVATE_KEY, + CONF_PUBLIC_KEY: MOCK_PUBLIC_KEY, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture +def mock_connection() -> Generator[MagicMock]: + """Mock the Threema Gateway Connection.""" + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = connection_class.return_value + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(return_value=100) + yield connection + + +@pytest.fixture +def mock_send() -> Generator[tuple[MagicMock, MagicMock]]: + """Mock TextMessage and SimpleTextMessage send methods.""" + with ( + patch( + "homeassistant.components.threema.client.TextMessage", autospec=True + ) as e2e_mock, + patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock, + ): + e2e_instance = MagicMock() + e2e_instance.send = AsyncMock(return_value="mock_message_id") + e2e_mock.return_value = e2e_instance + + simple_instance = MagicMock() + simple_instance.send = AsyncMock(return_value="mock_message_id") + simple_mock.return_value = simple_instance + + yield (e2e_mock, simple_mock) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py new file mode 100644 index 00000000000000..578a935a82538c --- /dev/null +++ b/tests/components/threema/test_config_flow.py @@ -0,0 +1,408 @@ +"""Test the Threema Gateway config flow.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from threema.gateway import GatewayError +from threema.gateway.exception import GatewayServerError + +from homeassistant import config_entries +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + CONF_PUBLIC_KEY, + CONF_RECIPIENT, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType, InvalidData + +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[None]: + """Patch async_setup_entry to avoid full setup during flow tests.""" + with patch("homeassistant.components.threema.async_setup_entry", return_value=True): + yield + + +async def test_user_flow_existing_gateway( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test user flow with existing gateway credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Threema {MOCK_GATEWAY_ID}" + assert result["data"] == { + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + } + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_existing_with_keys( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test user flow with existing gateway including optional keys.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "private:abcdef1234567890", + CONF_PUBLIC_KEY: "public:1234567890abcdef", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == "private:abcdef1234567890" + assert result["data"][CONF_PUBLIC_KEY] == "public:1234567890abcdef" + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_new_gateway( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test user flow with new gateway (key generation).""" + mock_private_key = MagicMock() + mock_public_key = MagicMock() + + with ( + patch( + "homeassistant.components.threema.client.key.Key.generate_pair", + return_value=(mock_private_key, mock_public_key), + ), + patch( + "homeassistant.components.threema.client.key.Key.encode", + side_effect=[ + "private:generated_private_key_hex", + "public:generated_public_key_hex", + ], + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "setup_new"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "setup_new" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == "private:generated_private_key_hex" + assert result["data"][CONF_PUBLIC_KEY] == "public:generated_public_key_hex" + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_key_generation_failure(hass: HomeAssistant) -> None: + """Test user flow aborts when key generation fails.""" + with patch( + "homeassistant.components.threema.client.key.Key.generate_pair", + side_effect=RuntimeError("Key generation failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "setup_new"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "key_generation_failed" + + +async def test_credentials_invalid_gateway_id( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test credentials step with invalid Gateway ID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + # Gateway ID not starting with * + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "TESTGWY1", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Gateway ID wrong length + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "*TEST", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Valid Gateway ID — recover and create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_credentials_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test credentials step when gateway is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (GatewayError("Connection refused"), "cannot_connect"), + (GatewayServerError(status=500), "cannot_connect"), + (GatewayServerError(status=401), "invalid_auth"), + (RuntimeError("Unexpected"), "unknown"), + ], + ids=["cannot_connect", "server_error_non_auth", "invalid_auth", "unknown_error"], +) +async def test_credentials_error( + hass: HomeAssistant, + mock_connection: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test credentials step with various errors.""" + mock_connection.get_credits.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_connection.get_credits.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_subentry_add_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test adding a recipient via subentry flow.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "EFGH5678" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678"} + assert result["unique_id"] == "EFGH5678" + + +async def test_subentry_add_recipient_with_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test adding a recipient with a display name.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678", "name": "Dad"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Dad (EFGH5678)" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678"} + assert result["unique_id"] == "EFGH5678" + + +@pytest.mark.parametrize( + "invalid_id", + ["ABC", "ABCD!@#$", ""], + ids=["too_short", "special_chars", "empty"], +) +async def test_subentry_invalid_recipient_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + invalid_id: str, +) -> None: + """Test subentry flow rejects invalid Threema ID via schema validation.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + with pytest.raises(InvalidData): + await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: invalid_id}, + ) + + +async def test_subentry_duplicate_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test subentry flow rejects duplicate recipient.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py new file mode 100644 index 00000000000000..961ac1f143017a --- /dev/null +++ b/tests/components/threema/test_init.py @@ -0,0 +1,95 @@ +"""Test the Threema Gateway integration setup.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from threema.gateway import GatewayError +from threema.gateway.exception import GatewayServerError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (GatewayError("Connection refused"), ConfigEntryState.SETUP_RETRY), + (GatewayServerError(status=401), ConfigEntryState.SETUP_ERROR), + (GatewayServerError(status=500), ConfigEntryState.SETUP_RETRY), + ], + ids=["connection_error", "auth_error", "server_error_non_auth"], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup handles various errors correctly.""" + mock_config_entry.add_to_hass(hass) + mock_connection.get_credits.side_effect = side_effect + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_update_listener_reloads( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test that update listener reloads the entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as mock_reload: + mock_reload.return_value = None + hass.config_entries.async_update_entry(mock_config_entry, title="Updated") + await hass.async_block_till_done() + + mock_reload.assert_called_once_with(mock_config_entry.entry_id) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py new file mode 100644 index 00000000000000..d761861e791ef8 --- /dev/null +++ b/tests/components/threema/test_notify.py @@ -0,0 +1,164 @@ +"""Test the Threema Gateway notify platform.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from threema.gateway import GatewayError +from threema.gateway.exception import GatewayServerError + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID + +from tests.common import MockConfigEntry + + +async def test_notify_entity_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, +) -> None: + """Test notify entity is created from subentry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_RECIPIENT_ID}" + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_notify_entity_not_created_without_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, +) -> None: + """Test no notify entity without subentries.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 0 + + +async def test_send_message_simple( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, +) -> None: + """Test sending a message via notify entity (simple mode).""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello from tests!"}, + blocking=True, + ) + + mock_send[1].assert_called_once() + call_kwargs = mock_send[1].call_args[1] + assert call_kwargs["to_id"] == MOCK_RECIPIENT_ID + assert call_kwargs["text"] == "Hello from tests!" + mock_send[1].return_value.send.assert_awaited_once() + + +async def test_send_message_e2e( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, +) -> None: + """Test sending a message via notify entity (E2E mode).""" + mock_config_entry_with_keys.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_keys.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello E2E!"}, + blocking=True, + ) + + mock_send[0].assert_called_once() + call_kwargs = mock_send[0].call_args[1] + assert call_kwargs["to_id"] == MOCK_RECIPIENT_ID + assert call_kwargs["text"] == "Hello E2E!" + mock_send[0].return_value.send.assert_awaited_once() + + +@pytest.mark.parametrize( + ("side_effect", "match"), + [ + (GatewayServerError(status=401), "Error sending message"), + (GatewayError("Network error"), "Error sending message"), + (GatewayServerError(status=500), "Error sending message"), + ], + ids=["auth_error", "send_error", "server_error_non_auth"], +) +async def test_send_message_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, + side_effect: Exception, + match: str, +) -> None: + """Test notify entity handles send errors.""" + mock_send[1].return_value.send.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, + )