From 97724f00b4712be6b99bd3f9e078bd0a19adc78b Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Dec 2024 14:07:23 +0000 Subject: [PATCH 01/16] WIP --- homeassistant/components/mastodon/__init__.py | 8 + homeassistant/components/mastodon/const.py | 7 + homeassistant/components/mastodon/notify.py | 10 +- homeassistant/components/mastodon/services.py | 138 ++++++++++++++++++ .../components/mastodon/services.yaml | 29 ++++ .../components/mastodon/strings.json | 48 ++++++ 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/mastodon/services.py create mode 100644 homeassistant/components/mastodon/services.yaml diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index f7f974ffbb081..a92e6e3799b15 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -17,10 +17,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import CONF_BASE_URL, DOMAIN, LOGGER from .coordinator import MastodonCoordinator +from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] @@ -39,6 +41,12 @@ class MastodonData: type MastodonConfigEntry = ConfigEntry[MastodonData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Mastodon component.""" + setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: """Set up Mastodon from a config entry.""" diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index e0593d15d2cb6..b7e86eaad5a09 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -19,3 +19,10 @@ ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count" ACCOUNT_FOLLOWING_COUNT: Final = "following_count" ACCOUNT_STATUSES_COUNT: Final = "statuses_count" + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_STATUS = "status" +ATTR_VISIBILITY = "visibility" +ATTR_CONTENT_WARNING = "content_warning" +ATTR_MEDIA_WARNING = "media_warning" +ATTR_MEDIA = "media" diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index bdfdbbf6e99e7..3436951a4ec43 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -19,12 +19,16 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER +from .const import ( + CONF_BASE_URL, + DEFAULT_URL, + LOGGER, + ATTR_MEDIA_WARNING, + ATTR_CONTENT_WARNING, +) ATTR_MEDIA = "media" ATTR_TARGET = "target" -ATTR_MEDIA_WARNING = "media_warning" -ATTR_CONTENT_WARNING = "content_warning" PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py new file mode 100644 index 0000000000000..092f610c894cd --- /dev/null +++ b/homeassistant/components/mastodon/services.py @@ -0,0 +1,138 @@ +"""Define services for the Mastodon integration.""" + +from dataclasses import asdict +from enum import StrEnum +import mimetypes +from typing import Any, cast + +from mastodon import Mastodon +from mastodon.Mastodon import MastodonAPIError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from . import MastodonConfigEntry +from .const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_MEDIA_WARNING, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, + LOGGER, +) + + +class StatusVisibility(StrEnum): + """StatusVisibility model.""" + + PUBLIC = "public" + UNLISTED = "unlisted" + PRIVATE = "private" + DIRECT = "direct" + + +SERVICE_POST = "post" +SERVICE_POST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_STATUS): str, + vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), + vol.Optional(ATTR_CONTENT_WARNING): bool, + vol.Optional(ATTR_MEDIA): str, + vol.Optional(ATTR_MEDIA_WARNING): bool, + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry: + """Get the Mastodon config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(MastodonConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Mastodon integration.""" + + async def async_post(call: ServiceCall) -> ServiceResponse: + """Post a status.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + status = call.data[ATTR_STATUS] + visibility = StatusVisibility(call.data[ATTR_VISIBILITY]) + content_warning = call.data.get(ATTR_CONTENT_WARNING) + media = call.data.get(ATTR_MEDIA) + media_warning = call.data.get(ATTR_MEDIA_WARNING) + client = entry.runtime_data.client + + if media: + if not hass.config.is_allowed_path(media): + LOGGER.warning("'%s' is not a whitelisted directory", media) + return None + mediadata = _upload_media(client, media) + + try: + client.status_post( + status, + media_ids=mediadata["id"], + sensitive=content_warning, + visibility=visibility, + spoiler_text=media_warning, + ) + except MastodonAPIError as err: + LOGGER.error("Unable to send message") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + else: + try: + client.status_post( + status, visibility=visibility, spoiler_text=content_warning + ) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + return None + + def _upload_media(client: Mastodon, media_path: Any = None) -> Any: + """Upload media.""" + with open(media_path, "rb"): + media_type = _media_type(media_path) + try: + mediadata = client.media_post(media_path, mime_type=media_type) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err + + return mediadata + + def _media_type(self, media_path: Any = None) -> Any: + """Get media Type.""" + (media_type, _) = mimetypes.guess_type(media_path) + + return media_type + + hass.services.async_register( + DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA + ) diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml new file mode 100644 index 0000000000000..513e5151bc3b9 --- /dev/null +++ b/homeassistant/components/mastodon/services.yaml @@ -0,0 +1,29 @@ +post: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + status: + selector: + text: + visibility: + selector: + select: + options: + - public + - unlisted + - private + - direct + translation_key: post_visibility + content_warning: + selector: + boolean: + media: + selector: + text: + media_warning: + selector: + boolean: + diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9df94ecf20430..d80f0c0a42de6 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -25,6 +25,16 @@ "unknown": "Unknown error occured when connecting to the Mastodon instance." } }, + "exceptions": { + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "unable_to_send_message": "Unable to send message", + "unable_to_upload_image": "Unable to upload image {media_path}" + }, "entity": { "sensor": { "followers": { @@ -40,5 +50,43 @@ "unit_of_measurement": "posts" } } + }, + "services": { + "post": { + "name": "Post", + "description": "Post a status to Mastodon", + "fields": { + "config_entry_id": { + "name": "Mastodon account", + "description": "Select the Mastodon account to post to" + }, + "status": { + "name": "Status", + "description": "The status to post." + }, + "visibility": { + "name": "Visibility", + "description": "The visibility of the post." + }, + "content_warning": { + "name": "Content warning", + "description": "A content warning will be shown before the status text is shown. (default: no content warning)." + }, + "media_warning": { + "name": "Media warning", + "description": "If an image or video is attached, will mark the media as sensitive. (default: no media warning)." + } + } + } + }, + "selector": { + "post_visibility": { + "options": { + "public": "Public", + "unlisted": "Unlisted", + "private": "Private", + "direct": "Direct" + } + } } } From 68ed90dc619d64d65866f48f6b88349d69661146 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Dec 2024 15:46:59 +0000 Subject: [PATCH 02/16] WIP --- homeassistant/components/mastodon/__init__.py | 18 +---- .../components/mastodon/coordinator.py | 15 ++++ homeassistant/components/mastodon/notify.py | 50 +++++++++---- .../components/mastodon/quality_scale.yaml | 2 +- homeassistant/components/mastodon/services.py | 70 ++++++++++++------- .../components/mastodon/services.yaml | 1 + .../components/mastodon/strings.json | 23 ++++-- tests/components/mastodon/test_services.py | 42 +++++++++++ 8 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 tests/components/mastodon/test_services.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index a92e6e3799b15..21585ec77fef2 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass - from mastodon.Mastodon import Mastodon, MastodonError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -21,26 +18,13 @@ from homeassistant.util import slugify from .const import CONF_BASE_URL, DOMAIN, LOGGER -from .coordinator import MastodonCoordinator +from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] -@dataclass -class MastodonData: - """Mastodon data type.""" - - client: Mastodon - instance: dict - account: dict - coordinator: MastodonCoordinator - - -type MastodonConfigEntry = ConfigEntry[MastodonData] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Mastodon component.""" setup_services(hass) diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index f1332a0ea43dc..4c6fe6b1c8866 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -2,18 +2,33 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import Any from mastodon import Mastodon from mastodon.Mastodon import MastodonError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +@dataclass +class MastodonData: + """Mastodon data type.""" + + client: Mastodon + instance: dict + account: dict + coordinator: MastodonCoordinator + + +type MastodonConfigEntry = ConfigEntry[MastodonData] + + class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Mastodon data.""" diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 3436951a4ec43..041c9a78bf078 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -16,15 +16,16 @@ ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + ATTR_CONTENT_WARNING, + ATTR_MEDIA_WARNING, CONF_BASE_URL, DEFAULT_URL, - LOGGER, - ATTR_MEDIA_WARNING, - ATTR_CONTENT_WARNING, + DOMAIN, ) ATTR_MEDIA = "media" @@ -71,6 +72,17 @@ def __init__( def send_message(self, message: str = "", **kwargs: Any) -> None: """Toot a message, with media perhaps.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_notify_action_mastodon", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + ) + target = None if (target_list := kwargs.get(ATTR_TARGET)) is not None: target = cast(list[str], target_list)[0] @@ -86,8 +98,11 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: media = data.get(ATTR_MEDIA) if media: if not self.hass.config.is_allowed_path(media): - LOGGER.warning("'%s' is not a whitelisted directory", media) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media}, + ) mediadata = self._upload_media(media) sensitive = data.get(ATTR_MEDIA_WARNING) @@ -102,15 +117,22 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: visibility=target, spoiler_text=content_warning, ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + else: try: self.client.status_post( message, visibility=target, spoiler_text=content_warning ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err def _upload_media(self, media_path: Any = None) -> Any: """Upload media.""" @@ -118,8 +140,12 @@ def _upload_media(self, media_path: Any = None) -> Any: media_type = self._media_type(media_path) try: mediadata = self.client.media_post(media_path, mime_type=media_type) - except MastodonAPIError: - LOGGER.error(f"Unable to upload image {media_path}") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err return mediadata diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 86702095e9514..8044d2937cb60 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -78,7 +78,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: status: todo diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 092f610c894cd..52728f165aa4f 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -1,7 +1,7 @@ """Define services for the Mastodon integration.""" -from dataclasses import asdict from enum import StrEnum +from functools import partial import mimetypes from typing import Any, cast @@ -12,8 +12,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType from . import MastodonConfigEntry from .const import ( @@ -24,7 +22,6 @@ ATTR_STATUS, ATTR_VISIBILITY, DOMAIN, - LOGGER, ) @@ -73,37 +70,57 @@ def setup_services(hass: HomeAssistant) -> None: async def async_post(call: ServiceCall) -> ServiceResponse: """Post a status.""" entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - status = call.data[ATTR_STATUS] - visibility = StatusVisibility(call.data[ATTR_VISIBILITY]) - content_warning = call.data.get(ATTR_CONTENT_WARNING) - media = call.data.get(ATTR_MEDIA) - media_warning = call.data.get(ATTR_MEDIA_WARNING) client = entry.runtime_data.client + status = call.data[ATTR_STATUS] + + visibility = None + if ATTR_VISIBILITY in call.data: + visibility = StatusVisibility(call.data[ATTR_VISIBILITY]) + content_warning = None + if ATTR_CONTENT_WARNING in call.data: + content_warning = call.data.get(ATTR_CONTENT_WARNING) + media = None + if ATTR_MEDIA in call.data: + media = call.data.get(ATTR_MEDIA) + media_warning = None + if ATTR_MEDIA_WARNING in call.data: + media_warning = call.data.get(ATTR_MEDIA_WARNING) + if media: if not hass.config.is_allowed_path(media): - LOGGER.warning("'%s' is not a whitelisted directory", media) - return None - mediadata = _upload_media(client, media) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media}, + ) + mediadata = await _upload_media(client, media) try: - client.status_post( - status, - media_ids=mediadata["id"], - sensitive=content_warning, - visibility=visibility, - spoiler_text=media_warning, + await hass.async_add_executor_job( + partial( + client.status_post, + status=status, + media_ids=mediadata["id"], + sensitive=content_warning, + visibility=visibility, + spoiler_text=media_warning, + ) ) except MastodonAPIError as err: - LOGGER.error("Unable to send message") raise HomeAssistantError( translation_domain=DOMAIN, translation_key="unable_to_send_message", ) from err else: try: - client.status_post( - status, visibility=visibility, spoiler_text=content_warning + await hass.async_add_executor_job( + partial( + client.status_post, + status=status, + visibility=visibility, + spoiler_text=content_warning, + ) ) except MastodonAPIError as err: raise HomeAssistantError( @@ -112,12 +129,15 @@ async def async_post(call: ServiceCall) -> ServiceResponse: ) from err return None - def _upload_media(client: Mastodon, media_path: Any = None) -> Any: + async def _upload_media(client: Mastodon, media_path: Any = None) -> Any: """Upload media.""" - with open(media_path, "rb"): - media_type = _media_type(media_path) + + media_type = _media_type(media_path) try: - mediadata = client.media_post(media_path, mime_type=media_type) + mediadata = await hass.async_add_executor_job( + partial(client.media_post, media_file=media_path, mime_type=media_type) + ) + except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index 513e5151bc3b9..e4e50a335ed90 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -6,6 +6,7 @@ post: config_entry: integration: mastodon status: + required: true selector: text: visibility: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index d80f0c0a42de6..b31ccb0efb411 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -32,8 +32,15 @@ "integration_not_found": { "message": "Integration \"{target}\" not found in registry." }, - "unable_to_send_message": "Unable to send message", - "unable_to_upload_image": "Unable to upload image {media_path}" + "unable_to_send_message": "Unable to send message.", + "unable_to_upload_image": "Unable to upload image {media_path}.", + "not_whitelisted_directory": "{media} is not a whitelisted directory." + }, + "issues": { + "deprecated_notify_action": { + "title": "Deprecated Notify action used for Mastodon", + "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." + } }, "entity": { "sensor": { @@ -58,7 +65,7 @@ "fields": { "config_entry_id": { "name": "Mastodon account", - "description": "Select the Mastodon account to post to" + "description": "Select the Mastodon account to post to." }, "status": { "name": "Status", @@ -66,15 +73,19 @@ }, "visibility": { "name": "Visibility", - "description": "The visibility of the post." + "description": "The visibility of the post (default: account setting)." }, "content_warning": { "name": "Content warning", - "description": "A content warning will be shown before the status text is shown. (default: no content warning)." + "description": "A content warning will be shown before the status text is shown (default: no content warning)." + }, + "media": { + "name": "Media", + "description": "Attach an image or video to the post." }, "media_warning": { "name": "Media warning", - "description": "If an image or video is attached, will mark the media as sensitive. (default: no media warning)." + "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)." } } } diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py new file mode 100644 index 0000000000000..076727fedf463 --- /dev/null +++ b/tests/components/mastodon/test_services.py @@ -0,0 +1,42 @@ +"""Tests for the Mastodon services.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.mastodon.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_STATUS, + DOMAIN, +) +from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_service_post( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the post service.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_STATUS: "test toot", + }, + blocking=True, + return_response=False, + ) + + assert mock_mastodon_client.status_post.assert_called_once From fb1eea1569f4fd0ffd5d35dd36154de7a1de9146 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Dec 2024 15:48:40 +0000 Subject: [PATCH 03/16] WIP --- tests/components/mastodon/test_services.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 076727fedf463..860b03bcd6123 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -11,7 +11,6 @@ ) from homeassistant.components.mastodon.services import SERVICE_POST from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from . import setup_integration From b6778e1ca3c9e49b9216d7163ef880a5de398c93 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Dec 2024 16:01:45 +0000 Subject: [PATCH 04/16] WIP --- homeassistant/components/mastodon/services.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 52728f165aa4f..a26e0ec742a77 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -147,8 +147,9 @@ async def _upload_media(client: Mastodon, media_path: Any = None) -> Any: return mediadata - def _media_type(self, media_path: Any = None) -> Any: + def _media_type(media_path: Any = None) -> Any: """Get media Type.""" + (media_type, _) = mimetypes.guess_type(media_path) return media_type From 1cf3c1272bce37f5bc63ac7d0f2a80a6b43d223e Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Dec 2024 16:41:34 +0000 Subject: [PATCH 05/16] WIP --- homeassistant/components/mastodon/__init__.py | 4 +++- homeassistant/components/mastodon/icons.json | 5 +++++ homeassistant/components/mastodon/notify.py | 4 ++-- homeassistant/components/mastodon/services.py | 10 ++++------ .../components/mastodon/services.yaml | 4 ++-- .../components/mastodon/strings.json | 20 ++++++++++++------- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 21585ec77fef2..2f713a97dfeb1 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -13,7 +13,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -24,6 +24,8 @@ PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Mastodon component.""" diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index 082e27a64c2eb..e7272c2b6f8cd 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -11,5 +11,10 @@ "default": "mdi:message-text" } } + }, + "services": { + "post": { + "service": "mdi:message-text" + } } } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 041c9a78bf078..417745f458c52 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -112,10 +112,10 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: try: self.client.status_post( message, - media_ids=mediadata["id"], - sensitive=sensitive, visibility=target, spoiler_text=content_warning, + media_ids=mediadata["id"], + sensitive=sensitive, ) except MastodonAPIError as err: raise HomeAssistantError( diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index a26e0ec742a77..b4fe4707e4d80 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -40,7 +40,7 @@ class StatusVisibility(StrEnum): vol.Required(ATTR_CONFIG_ENTRY_ID): str, vol.Required(ATTR_STATUS): str, vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), - vol.Optional(ATTR_CONTENT_WARNING): bool, + vol.Optional(ATTR_CONTENT_WARNING): str, vol.Optional(ATTR_MEDIA): str, vol.Optional(ATTR_MEDIA_WARNING): bool, } @@ -83,9 +83,7 @@ async def async_post(call: ServiceCall) -> ServiceResponse: media = None if ATTR_MEDIA in call.data: media = call.data.get(ATTR_MEDIA) - media_warning = None - if ATTR_MEDIA_WARNING in call.data: - media_warning = call.data.get(ATTR_MEDIA_WARNING) + media_warning = call.data.get(ATTR_MEDIA_WARNING) if media: if not hass.config.is_allowed_path(media): @@ -102,9 +100,9 @@ async def async_post(call: ServiceCall) -> ServiceResponse: client.status_post, status=status, media_ids=mediadata["id"], - sensitive=content_warning, + spoiler_text=content_warning, visibility=visibility, - spoiler_text=media_warning, + sensitive=media_warning, ) ) except MastodonAPIError as err: diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index e4e50a335ed90..161a0d152ca45 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -20,11 +20,11 @@ post: translation_key: post_visibility content_warning: selector: - boolean: + text: media: selector: text: media_warning: + required: true selector: boolean: - diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index b31ccb0efb411..67c24971fdbc3 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -32,9 +32,15 @@ "integration_not_found": { "message": "Integration \"{target}\" not found in registry." }, - "unable_to_send_message": "Unable to send message.", - "unable_to_upload_image": "Unable to upload image {media_path}.", - "not_whitelisted_directory": "{media} is not a whitelisted directory." + "unable_to_send_message": { + "message": "Unable to send message." + }, + "unable_to_upload_image": { + "message": "Unable to upload image {media_path}." + }, + "not_whitelisted_directory": { + "message": "{media} is not a whitelisted directory." + } }, "issues": { "deprecated_notify_action": { @@ -93,10 +99,10 @@ "selector": { "post_visibility": { "options": { - "public": "Public", - "unlisted": "Unlisted", - "private": "Private", - "direct": "Direct" + "public": "Public - Visible to everyone", + "unlisted": "Unlisted - Public but not shown in public timelines", + "private": "Private - Followers only", + "direct": "Direct - Mentioned accounts only" } } } From 5c056def3426e320eccd5b7ebaaf40fd92cc2232 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 2 Jan 2025 12:24:51 +0000 Subject: [PATCH 06/16] Add create_issue --- homeassistant/components/mastodon/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 417745f458c52..872188dc181e9 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -72,7 +72,7 @@ def __init__( def send_message(self, message: str = "", **kwargs: Any) -> None: """Toot a message, with media perhaps.""" - ir.async_create_issue( + ir.create_issue( self.hass, DOMAIN, "deprecated_notify_action_mastodon", From e1a50abc774adfe1f825c4d58e7d80abc962b23a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 2 Jan 2025 14:38:24 +0000 Subject: [PATCH 07/16] Tests --- tests/components/mastodon/test_services.py | 38 +++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 860b03bcd6123..c8c864eac2e7d 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -2,11 +2,13 @@ from unittest.mock import AsyncMock -from syrupy.assertion import SnapshotAssertion +import pytest from homeassistant.components.mastodon.const import ( ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, ATTR_STATUS, + ATTR_VISIBILITY, DOMAIN, ) from homeassistant.components.mastodon.services import SERVICE_POST @@ -17,11 +19,35 @@ from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + {"status": "test toot", "spoiler_text": None, "visibility": None}, + ), + ( + {ATTR_STATUS: "test toot", ATTR_VISIBILITY: "private"}, + {"status": "test toot", "spoiler_text": None, "visibility": "private"}, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_VISIBILITY: "private", + }, + {"status": "test toot", "spoiler_text": "Spoiler", "visibility": "private"}, + ), + ], +) async def test_service_post( hass: HomeAssistant, mock_mastodon_client: AsyncMock, mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, + payload: dict[str, str], + kwargs: dict[str, str | None], ) -> None: """Test the post service.""" @@ -32,10 +58,12 @@ async def test_service_post( SERVICE_POST, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_STATUS: "test toot", - }, + } + | payload, blocking=True, return_response=False, ) - assert mock_mastodon_client.status_post.assert_called_once + mock_mastodon_client.status_post.assert_called_with(**kwargs) + + mock_mastodon_client.status_post.reset_mock() From 2b25129391c175bf9ada2e4290be5e20f5c56435 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 2 Jan 2025 14:51:57 +0000 Subject: [PATCH 08/16] Service params --- homeassistant/components/mastodon/services.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index b4fe4707e4d80..3965575e4667c 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -74,16 +74,14 @@ async def async_post(call: ServiceCall) -> ServiceResponse: status = call.data[ATTR_STATUS] - visibility = None - if ATTR_VISIBILITY in call.data: - visibility = StatusVisibility(call.data[ATTR_VISIBILITY]) - content_warning = None - if ATTR_CONTENT_WARNING in call.data: - content_warning = call.data.get(ATTR_CONTENT_WARNING) - media = None - if ATTR_MEDIA in call.data: - media = call.data.get(ATTR_MEDIA) - media_warning = call.data.get(ATTR_MEDIA_WARNING) + visibility: str | None = ( + StatusVisibility(call.data[ATTR_VISIBILITY]) + if ATTR_VISIBILITY in call.data + else None + ) + content_warning: str | None = call.data.get(ATTR_CONTENT_WARNING) + media: str | None = call.data.get(ATTR_MEDIA) + media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING) if media: if not hass.config.is_allowed_path(media): From 7fdf9f7faf605715a1e664fa121aa47369727248 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 3 Jan 2025 10:40:57 +0000 Subject: [PATCH 09/16] Update IQS todo --- homeassistant/components/mastodon/quality_scale.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 8044d2937cb60..43636ed692481 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -29,7 +29,7 @@ rules: action-exceptions: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -42,7 +42,7 @@ rules: parallel-updates: status: todo comment: | - Does not set parallel-updates on notify platform. + Awaiting legacy Notify deprecation. reauthentication-flow: status: todo comment: | @@ -50,7 +50,7 @@ rules: test-coverage: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. # Gold devices: done From 575b6ed66dd3ff053af66790e63816986cb57384 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 3 Jan 2025 10:50:05 +0000 Subject: [PATCH 10/16] Reorder params --- homeassistant/components/mastodon/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 3965575e4667c..7982e85132c33 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -97,9 +97,9 @@ async def async_post(call: ServiceCall) -> ServiceResponse: partial( client.status_post, status=status, - media_ids=mediadata["id"], - spoiler_text=content_warning, visibility=visibility, + spoiler_text=content_warning, + media_ids=mediadata["id"], sensitive=media_warning, ) ) From 993ccb1d7d0b66aa2d2cf5fdad4ad61409dcfe8e Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 3 Jan 2025 11:39:18 +0000 Subject: [PATCH 11/16] Move media_type to common, more tests --- homeassistant/components/mastodon/notify.py | 10 ++---- homeassistant/components/mastodon/services.py | 11 ++---- homeassistant/components/mastodon/utils.py | 11 ++++++ tests/components/mastodon/test_services.py | 36 +++++++++++++++++++ 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 872188dc181e9..370c2d75214f8 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -2,7 +2,6 @@ from __future__ import annotations -import mimetypes from typing import Any, cast from mastodon import Mastodon @@ -27,6 +26,7 @@ DEFAULT_URL, DOMAIN, ) +from .utils import get_media_type ATTR_MEDIA = "media" ATTR_TARGET = "target" @@ -137,7 +137,7 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: def _upload_media(self, media_path: Any = None) -> Any: """Upload media.""" with open(media_path, "rb"): - media_type = self._media_type(media_path) + media_type = get_media_type(media_path) try: mediadata = self.client.media_post(media_path, mime_type=media_type) except MastodonAPIError as err: @@ -148,9 +148,3 @@ def _upload_media(self, media_path: Any = None) -> Any: ) from err return mediadata - - def _media_type(self, media_path: Any = None) -> Any: - """Get media Type.""" - (media_type, _) = mimetypes.guess_type(media_path) - - return media_type diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 7982e85132c33..9ab6ebe2c0b22 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -2,7 +2,6 @@ from enum import StrEnum from functools import partial -import mimetypes from typing import Any, cast from mastodon import Mastodon @@ -23,6 +22,7 @@ ATTR_VISIBILITY, DOMAIN, ) +from .utils import get_media_type class StatusVisibility(StrEnum): @@ -128,7 +128,7 @@ async def async_post(call: ServiceCall) -> ServiceResponse: async def _upload_media(client: Mastodon, media_path: Any = None) -> Any: """Upload media.""" - media_type = _media_type(media_path) + media_type = get_media_type(media_path) try: mediadata = await hass.async_add_executor_job( partial(client.media_post, media_file=media_path, mime_type=media_type) @@ -143,13 +143,6 @@ async def _upload_media(client: Mastodon, media_path: Any = None) -> Any: return mediadata - def _media_type(media_path: Any = None) -> Any: - """Get media Type.""" - - (media_type, _) = mimetypes.guess_type(media_path) - - return media_type - hass.services.async_register( DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA ) diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index 8e1bd69702769..428556c7f6155 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +import mimetypes +from typing import Any + from mastodon import Mastodon from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI @@ -30,3 +33,11 @@ def construct_mastodon_username( ) return DEFAULT_NAME + + +def get_media_type(media_path: Any = None) -> Any: + """Get media Type.""" + + (media_type, _) = mimetypes.guess_type(media_path) + + return media_type diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index c8c864eac2e7d..d292144e04d33 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -13,6 +13,7 @@ ) from homeassistant.components.mastodon.services import SERVICE_POST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from . import setup_integration @@ -67,3 +68,38 @@ async def test_service_post( mock_mastodon_client.status_post.assert_called_with(**kwargs) mock_mastodon_client.status_post.reset_mock() + + +async def test_service_entry_availability( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot"} + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, + blocking=True, + return_response=False, + ) + + with pytest.raises( + ServiceValidationError, match='Integration "mastodon" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, + blocking=True, + return_response=False, + ) From 5f0c9cd17e8dd62303327fd93a8a29edc47b246d Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 5 Jan 2025 15:03:13 +0000 Subject: [PATCH 12/16] Add post service failed test --- tests/components/mastodon/test_services.py | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index d292144e04d33..e34ef53778b4c 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonAPIError import pytest from homeassistant.components.mastodon.const import ( @@ -13,7 +14,7 @@ ) from homeassistant.components.mastodon.services import SERVICE_POST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import setup_integration @@ -70,6 +71,30 @@ async def test_service_post( mock_mastodon_client.status_post.reset_mock() +async def test_post_service_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot"} + + getattr(mock_mastodon_client, "status_post").side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + async def test_service_entry_availability( hass: HomeAssistant, mock_mastodon_client: AsyncMock, From a56e923790413dc5bc77458867ed47c9f9eab2f3 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 5 Jan 2025 15:13:32 +0000 Subject: [PATCH 13/16] Add notify fail test --- tests/components/mastodon/test_notify.py | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py index ab2d7456baf02..f6d453bee0181 100644 --- a/tests/components/mastodon/test_notify.py +++ b/tests/components/mastodon/test_notify.py @@ -2,10 +2,13 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonAPIError +import pytest from syrupy.assertion import SnapshotAssertion 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 . import setup_integration @@ -36,3 +39,27 @@ async def test_notify( ) assert mock_mastodon_client.status_post.assert_called_once + + +async def test_notify_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the notify raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + getattr(mock_mastodon_client, "status_post").side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + NOTIFY_DOMAIN, + "trwnh_mastodon_social", + { + "message": "test toot", + }, + blocking=True, + return_response=False, + ) From 7394e8bf797055da795da377d05fca2aa6ce8f9b Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 5 Jan 2025 15:49:14 +0000 Subject: [PATCH 14/16] Add service media test --- tests/components/mastodon/test_services.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index e34ef53778b4c..aaeeb7dddb436 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -1,6 +1,6 @@ """Tests for the Mastodon services.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from mastodon.Mastodon import MastodonAPIError import pytest @@ -8,6 +8,7 @@ from homeassistant.components.mastodon.const import ( ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, + ATTR_MEDIA, ATTR_STATUS, ATTR_VISIBILITY, DOMAIN, @@ -42,6 +43,20 @@ }, {"status": "test toot", "spoiler_text": "Spoiler", "visibility": "private"}, ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), ], ) async def test_service_post( @@ -55,6 +70,9 @@ async def test_service_post( await setup_integration(hass, mock_config_entry) + hass.config.is_allowed_path = Mock(return_value=True) + mock_mastodon_client.media_post.return_value = {"id": "1"} + await hass.services.async_call( DOMAIN, SERVICE_POST, From f480c6412f98c3aa7d5875a7706e4ff850d116a0 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 5 Jan 2025 16:47:41 +0000 Subject: [PATCH 15/16] Add media upload fail test --- tests/components/mastodon/test_services.py | 56 +++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index aaeeb7dddb436..6aab2c7da0c9d 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -89,17 +89,45 @@ async def test_service_post( mock_mastodon_client.status_post.reset_mock() +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + {"status": "test toot", "spoiler_text": None, "visibility": None}, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) async def test_post_service_failed( hass: HomeAssistant, mock_mastodon_client: AsyncMock, mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], ) -> None: """Test the post service raising an error.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - payload = {"status": "test toot"} + hass.config.is_allowed_path = Mock(return_value=True) + mock_mastodon_client.media_post.return_value = {"id": "1"} getattr(mock_mastodon_client, "status_post").side_effect = MastodonAPIError @@ -113,6 +141,32 @@ async def test_post_service_failed( ) +async def test_post_media_upload_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because media upload fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + hass.config.is_allowed_path = Mock(return_value=True) + + getattr(mock_mastodon_client, "media_post").side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to upload image /fail.jpg"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + async def test_service_entry_availability( hass: HomeAssistant, mock_mastodon_client: AsyncMock, From 100a32093bfb983a4a775188b72fb2797e8b8039 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 5 Jan 2025 17:06:24 +0000 Subject: [PATCH 16/16] Add path not whitelisted tests --- tests/components/mastodon/test_services.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 6aab2c7da0c9d..a5af890f7d38a 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -167,6 +167,30 @@ async def test_post_media_upload_failed( ) +async def test_post_path_not_whitelisted( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because the file path is not whitelisted.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + with pytest.raises( + HomeAssistantError, match="/fail.jpg is not a whitelisted directory" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + async def test_service_entry_availability( hass: HomeAssistant, mock_mastodon_client: AsyncMock,