diff --git a/CODEOWNERS b/CODEOWNERS index 273607234e582a08a5b3baaa4e92a141956149f2..1b9808a418a8e8cecb82186bffe3efadbe5a21b8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -839,7 +839,8 @@ build.json @home-assistant/supervisor /tests/components/lyric/ @timmo001 /homeassistant/components/madvr/ @iloveicedgreentea /tests/components/madvr/ @iloveicedgreentea -/homeassistant/components/mastodon/ @fabaff +/homeassistant/components/mastodon/ @fabaff @andrew-codechimp +/tests/components/mastodon/ @fabaff @andrew-codechimp /homeassistant/components/matrix/ @PaarthShah /tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 6a9f074a9ba93859231b4d55bdda7d56cc3e1e39..2fe379702ee7016b698d4b92a3591c9b9c563526 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -1 +1,60 @@ """The Mastodon integration.""" + +from __future__ import annotations + +from mastodon.Mastodon import Mastodon, MastodonError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_NAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery + +from .const import CONF_BASE_URL, DOMAIN +from .utils import create_mastodon_client + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Mastodon from a config entry.""" + + try: + client, _, _ = await hass.async_add_executor_job( + setup_mastodon, + entry, + ) + + except MastodonError as ex: + raise ConfigEntryNotReady("Failed to connect") from ex + + assert entry.unique_id + + await discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_NAME: entry.title, "client": client}, + {}, + ) + + return True + + +def setup_mastodon(entry: ConfigEntry) -> tuple[Mastodon, dict, dict]: + """Get mastodon details.""" + client = create_mastodon_client( + entry.data[CONF_BASE_URL], + entry.data[CONF_CLIENT_ID], + entry.data[CONF_CLIENT_SECRET], + entry.data[CONF_ACCESS_TOKEN], + ) + + instance = client.instance() + account = client.account_verify_credentials() + + return client, instance, account diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..7d1c9396cbb01a5c92e940af5f816752824f2e80 --- /dev/null +++ b/homeassistant/components/mastodon/config_flow.py @@ -0,0 +1,168 @@ +"""Config flow for Mastodon.""" + +from __future__ import annotations + +from typing import Any + +from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_NAME, +) +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER +from .utils import construct_mastodon_username, create_mastodon_client + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_BASE_URL, + default=DEFAULT_URL, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)), + vol.Required( + CONF_CLIENT_ID, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_CLIENT_SECRET, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) + + +class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + config_entry: ConfigEntry + + def check_connection( + self, + base_url: str, + client_id: str, + client_secret: str, + access_token: str, + ) -> tuple[ + dict[str, str] | None, + dict[str, str] | None, + dict[str, str], + ]: + """Check connection to the Mastodon instance.""" + try: + client = create_mastodon_client( + base_url, + client_id, + client_secret, + access_token, + ) + instance = client.instance() + account = client.account_verify_credentials() + + except MastodonNetworkError: + return None, None, {"base": "network_error"} + except MastodonUnauthorizedError: + return None, None, {"base": "unauthorized_error"} + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + return None, None, {"base": "unknown"} + return instance, account, {} + + def show_user_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + description_placeholders: dict[str, str] | None = None, + step_id: str = "user", + ) -> ConfigFlowResult: + """Show the user form.""" + if user_input is None: + user_input = {} + return self.async_show_form( + step_id=step_id, + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] | None = None + if user_input: + self._async_abort_entries_match( + {CONF_CLIENT_ID: user_input[CONF_CLIENT_ID]} + ) + + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection, + user_input[CONF_BASE_URL], + user_input[CONF_CLIENT_ID], + user_input[CONF_CLIENT_SECRET], + user_input[CONF_ACCESS_TOKEN], + ) + + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(user_input[CONF_CLIENT_ID]) + return self.async_create_entry( + title=name, + data=user_input, + ) + + return self.show_user_form(user_input, errors) + + async def async_step_import(self, import_config: ConfigType) -> ConfigFlowResult: + """Import a config entry from configuration.yaml.""" + errors: dict[str, str] | None = None + + LOGGER.debug("Importing Mastodon from configuration.yaml") + + base_url = str(import_config.get(CONF_BASE_URL, DEFAULT_URL)) + client_id = str(import_config.get(CONF_CLIENT_ID)) + client_secret = str(import_config.get(CONF_CLIENT_SECRET)) + access_token = str(import_config.get(CONF_ACCESS_TOKEN)) + name = import_config.get(CONF_NAME, None) + + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection, + base_url, + client_id, + client_secret, + access_token, + ) + + if not errors: + await self.async_set_unique_id(client_id) + self._abort_if_unique_id_configured() + + if not name: + name = construct_mastodon_username(instance, account) + + return self.async_create_entry( + title=name, + data={ + CONF_BASE_URL: base_url, + CONF_CLIENT_ID: client_id, + CONF_CLIENT_SECRET: client_secret, + CONF_ACCESS_TOKEN: access_token, + }, + ) + + reason = next(iter(errors.items()))[1] + return self.async_abort(reason=reason) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 6fe9552f99174646078d0a4275d41840b5cbbc49..3a9cf7462e62701dd1bf705b2f0649a83518d486 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -5,5 +5,14 @@ from typing import Final LOGGER = logging.getLogger(__name__) +DOMAIN: Final = "mastodon" + CONF_BASE_URL: Final = "base_url" +DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" +DEFAULT_NAME: Final = "Mastodon" + +INSTANCE_VERSION: Final = "version" +INSTANCE_URI: Final = "uri" +INSTANCE_DOMAIN: Final = "domain" +ACCOUNT_USERNAME: Final = "username" diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 673a60166c086fe82bc2880abf29acd62b2502b3..40fd9d2f7b36dad81470465d7d738acbbea3e6ad 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -1,8 +1,10 @@ { "domain": "mastodon", "name": "Mastodon", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@andrew-codechimp"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mastodon", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["mastodon"], "requirements": ["Mastodon.py==1.8.1"] diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 99999275aeb33cb8b872ec2b02bae145317d6920..7878fc665a177d52035b93e7494a42631f8cbcdd 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -6,7 +6,7 @@ import mimetypes from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError, MastodonUnauthorizedError +from mastodon.Mastodon import MastodonAPIError import voluptuous as vol from homeassistant.components.notify import ( @@ -14,12 +14,14 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.config_entries import SOURCE_IMPORT 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.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER +from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER ATTR_MEDIA = "media" ATTR_TARGET = "target" @@ -35,39 +37,78 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( } ) +INTEGRATION_TITLE = "Mastodon" -def get_service( + +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, ) -> MastodonNotificationService | None: """Get the Mastodon notification service.""" - client_id = config.get(CONF_CLIENT_ID) - client_secret = config.get(CONF_CLIENT_SECRET) - access_token = config.get(CONF_ACCESS_TOKEN) - base_url = config.get(CONF_BASE_URL) - - try: - mastodon = Mastodon( - client_id=client_id, - client_secret=client_secret, - access_token=access_token, - api_base_url=base_url, + + if not discovery_info: + # Import config entry + + import_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + + if ( + import_result["type"] == FlowResultType.ABORT + and import_result["reason"] != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{import_result["reason"]}", + breaks_in_ha_version="2025.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{import_result["reason"]}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + return None + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, ) - mastodon.account_verify_credentials() - except MastodonUnauthorizedError: - LOGGER.warning("Authentication failed") + return None - return MastodonNotificationService(mastodon) + client: Mastodon = discovery_info.get("client") + + return MastodonNotificationService(hass, client) class MastodonNotificationService(BaseNotificationService): """Implement the notification service for Mastodon.""" - def __init__(self, api: Mastodon) -> None: + def __init__( + self, + hass: HomeAssistant, + client: Mastodon, + ) -> None: """Initialize the service.""" - self._api = api + + self.client = client def send_message(self, message: str = "", **kwargs: Any) -> None: """Toot a message, with media perhaps.""" @@ -96,7 +137,7 @@ class MastodonNotificationService(BaseNotificationService): if mediadata: try: - self._api.status_post( + self.client.status_post( message, media_ids=mediadata["id"], sensitive=sensitive, @@ -107,7 +148,7 @@ class MastodonNotificationService(BaseNotificationService): LOGGER.error("Unable to send message") else: try: - self._api.status_post( + self.client.status_post( message, visibility=target, spoiler_text=content_warning ) except MastodonAPIError: @@ -118,7 +159,7 @@ class MastodonNotificationService(BaseNotificationService): with open(media_path, "rb"): media_type = self._media_type(media_path) try: - mediadata = self._api.media_post(media_path, mime_type=media_type) + mediadata = self.client.media_post(media_path, mime_type=media_type) except MastodonAPIError: LOGGER.error(f"Unable to upload image {media_path}") diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..e1124aad1a929772b810372379a7c177ca583a16 --- /dev/null +++ b/homeassistant/components/mastodon/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "data": { + "base_url": "[%key:common::config_flow::data::url%]", + "client_id": "Client Key", + "client_secret": "Client Secret", + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "base_url": "The URL of your Mastodon instance." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "unauthorized_error": "The credentials are incorrect.", + "network_error": "The Mastodon instance was not found.", + "unknown": "Unknown error occured when connecting to the Mastodon instance." + } + }, + "issues": { + "deprecated_yaml_import_issue_unauthorized_error": { + "title": "YAML import failed due to an authentication error", + "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration." + }, + "deprecated_yaml_import_issue_network_error": { + "title": "YAML import failed because the instance was not found", + "description": "Configuring {integration_title} using YAML is being removed but no instance was found while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "YAML import failed with unknown error", + "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration." + } + } +} diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8e1bd69702769a7dc0d3cf0f70c17ecf7fa553b2 --- /dev/null +++ b/homeassistant/components/mastodon/utils.py @@ -0,0 +1,32 @@ +"""Mastodon util functions.""" + +from __future__ import annotations + +from mastodon import Mastodon + +from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI + + +def create_mastodon_client( + base_url: str, client_id: str, client_secret: str, access_token: str +) -> Mastodon: + """Create a Mastodon client with the api base url.""" + return Mastodon( + api_base_url=base_url, + client_id=client_id, + client_secret=client_secret, + access_token=access_token, + ) + + +def construct_mastodon_username( + instance: dict[str, str] | None, account: dict[str, str] | None +) -> str: + """Construct a mastodon username from the account and instance.""" + if instance and account: + return ( + f"@{account[ACCOUNT_USERNAME]}@" + f"{instance.get(INSTANCE_URI, instance.get(INSTANCE_DOMAIN))}" + ) + + return DEFAULT_NAME diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 14036dcb1b5c43c5ce0dfcdfd4e6dda6181fbf9e..90f9675339bf1c847717b6ed947dcbfc6add80d4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -330,6 +330,7 @@ FLOWS = { "lyric", "madvr", "mailgun", + "mastodon", "matter", "mealie", "meater", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c1b8c5a7cf64f416b166551f044d3895f62375d7..dc1d203856c5c7fa23250990182f473b4d120715 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3495,8 +3495,8 @@ }, "mastodon": { "name": "Mastodon", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_push" }, "matrix": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3b94163ea8f14d0f69c43f0ed67052a0b4a52a1..6ba2e3fd2c4987dec69224bd0c7749074e2ac474 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,6 +21,9 @@ HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.9.2 +# homeassistant.components.mastodon +Mastodon.py==1.8.1 + # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload diff --git a/tests/components/mastodon/__init__.py b/tests/components/mastodon/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a4c730db07ab5d2f7a2880b42b1680d1e11fb2ff --- /dev/null +++ b/tests/components/mastodon/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Mastodon integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..03c3e754c111b0d5ab8e9fab50d9ac3892d2bef2 --- /dev/null +++ b/tests/components/mastodon/conftest.py @@ -0,0 +1,57 @@ +"""Mastodon tests configuration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET + +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mastodon.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mastodon_client() -> Generator[AsyncMock]: + """Mock a Mastodon client.""" + with ( + patch( + "homeassistant.components.mastodon.utils.Mastodon", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + client.instance.return_value = load_json_object_fixture("instance.json", DOMAIN) + client.account_verify_credentials.return_value = load_json_object_fixture( + "account_verify_credentials.json", DOMAIN + ) + client.status_post.return_value = None + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="@trwnh@mastodon.social", + data={ + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + entry_id="01J35M4AH9HYRC2V0G6RNVNWJH", + unique_id="client_id", + ) diff --git a/tests/components/mastodon/fixtures/account_verify_credentials.json b/tests/components/mastodon/fixtures/account_verify_credentials.json new file mode 100644 index 0000000000000000000000000000000000000000..401caa121ae6cca0de57f53d7f966ebd29a43891 --- /dev/null +++ b/tests/components/mastodon/fixtures/account_verify_credentials.json @@ -0,0 +1,78 @@ +{ + "id": "14715", + "username": "trwnh", + "acct": "trwnh", + "display_name": "infinite love ⴳ", + "locked": false, + "bot": false, + "created_at": "2016-11-24T10:02:12.085Z", + "note": "<p>i have approximate knowledge of many things. perpetual student. (nb/ace/they)</p><p>xmpp/email: a@trwnh.com<br /><a href=\"https://trwnh.com\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">trwnh.com</span><span class=\"invisible\"></span></a><br />help me live: <a href=\"https://liberapay.com/at\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">liberapay.com/at</span><span class=\"invisible\"></span></a> or <a href=\"https://paypal.me/trwnh\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">paypal.me/trwnh</span><span class=\"invisible\"></span></a></p><p>- my triggers are moths and glitter<br />- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise<br />- dm me if i did something wrong, so i can improve<br />- purest person on fedi, do not lewd in my presence<br />- #1 ami cole fan account</p><p>:fatyoshi:</p>", + "url": "https://mastodon.social/@trwnh", + "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", + "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "followers_count": 821, + "following_count": 178, + "statuses_count": 33120, + "last_status_at": "2019-11-24T15:49:42.251Z", + "source": { + "privacy": "public", + "sensitive": false, + "language": "", + "note": "i have approximate knowledge of many things. perpetual student. (nb/ace/they)\r\n\r\nxmpp/email: a@trwnh.com\r\nhttps://trwnh.com\r\nhelp me live: https://liberapay.com/at or https://paypal.me/trwnh\r\n\r\n- my triggers are moths and glitter\r\n- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise\r\n- dm me if i did something wrong, so i can improve\r\n- purest person on fedi, do not lewd in my presence\r\n- #1 ami cole fan account\r\n\r\n:fatyoshi:", + "fields": [ + { + "name": "Website", + "value": "https://trwnh.com", + "verified_at": "2019-08-29T04:14:55.571+00:00" + }, + { + "name": "Sponsor", + "value": "https://liberapay.com/at", + "verified_at": "2019-11-15T10:06:15.557+00:00" + }, + { + "name": "Fan of:", + "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", + "verified_at": null + }, + { + "name": "Main topics:", + "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", + "verified_at": null + } + ], + "follow_requests_count": 0 + }, + "emojis": [ + { + "shortcode": "fatyoshi", + "url": "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", + "static_url": "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", + "visible_in_picker": true + } + ], + "fields": [ + { + "name": "Website", + "value": "<a href=\"https://trwnh.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">trwnh.com</span><span class=\"invisible\"></span></a>", + "verified_at": "2019-08-29T04:14:55.571+00:00" + }, + { + "name": "Sponsor", + "value": "<a href=\"https://liberapay.com/at\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">liberapay.com/at</span><span class=\"invisible\"></span></a>", + "verified_at": "2019-11-15T10:06:15.557+00:00" + }, + { + "name": "Fan of:", + "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", + "verified_at": null + }, + { + "name": "Main topics:", + "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", + "verified_at": null + } + ] +} diff --git a/tests/components/mastodon/fixtures/instance.json b/tests/components/mastodon/fixtures/instance.json new file mode 100644 index 0000000000000000000000000000000000000000..b0e904e80ef954cead45e89c4c057f8b22d4c17a --- /dev/null +++ b/tests/components/mastodon/fixtures/instance.json @@ -0,0 +1,147 @@ +{ + "domain": "mastodon.social", + "title": "Mastodon", + "version": "4.0.0rc1", + "source_url": "https://github.com/mastodon/mastodon", + "description": "The original server operated by the Mastodon gGmbH non-profit", + "usage": { + "users": { + "active_month": 123122 + } + }, + "thumbnail": { + "url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$", + "versions": { + "@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png" + } + }, + "languages": ["en"], + "configuration": { + "urls": { + "streaming": "wss://mastodon.social" + }, + "vapid": { + "public_key": "BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=" + }, + "accounts": { + "max_featured_tags": 10, + "max_pinned_statuses": 4 + }, + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/heic", + "image/heif", + "image/webp", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "translation": { + "enabled": true + } + }, + "registrations": { + "enabled": false, + "approval_required": false, + "message": null + }, + "contact": { + "email": "staff@mastodon.social", + "account": { + "id": "1", + "username": "Gargron", + "acct": "Gargron", + "display_name": "Eugen 💀", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "<p>Founder, CEO and lead developer <span class=\"h-card\"><a href=\"https://mastodon.social/@Mastodon\" class=\"u-url mention\">@<span>Mastodon</span></a></span>, Germany.</p>", + "url": "https://mastodon.social/@Gargron", + "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", + "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "followers_count": 133026, + "following_count": 311, + "statuses_count": 72605, + "last_status_at": "2022-10-31", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "Patreon", + "value": "<a href=\"https://www.patreon.com/mastodon\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>", + "verified_at": null + } + ] + } + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in Germany" + }, + { + "id": "7", + "text": "Do not share intentionally false or misleading information" + } + ] +} diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr new file mode 100644 index 0000000000000000000000000000000000000000..f0b650076befc94b355ceb5b0a36631616c1c63c --- /dev/null +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': <DeviceEntryType.SERVICE: 'service'>, + 'hw_version': None, + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'mastodon', + 'client_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Mastodon gGmbH', + 'model': '@trwnh@mastodon.social', + 'model_id': None, + 'name': 'Mastodon', + 'name_by_user': None, + 'primary_config_entry': <ANY>, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.0.0rc1', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..01cdc061d3ef27f50ccf23c89c0a9bf2c978683c --- /dev/null +++ b/tests/components/mastodon/test_config_flow.py @@ -0,0 +1,179 @@ +"""Tests for the Mastodon config flow.""" + +from unittest.mock import AsyncMock + +from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +import pytest + +from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "@trwnh@mastodon.social" + assert result["data"] == { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + } + assert result["result"].unique_id == "client_id" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MastodonNetworkError, "network_error"), + (MastodonUnauthorizedError, "unauthorized_error"), + (Exception, "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_mastodon_client.account_verify_credentials.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_mastodon_client.account_verify_credentials.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test importing yaml config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "import_client_id", + CONF_CLIENT_SECRET: "import_client_secret", + CONF_ACCESS_TOKEN: "import_access_token", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MastodonNetworkError, "network_error"), + (MastodonUnauthorizedError, "unauthorized_error"), + (Exception, "unknown"), + ], +) +async def test_import_flow_abort( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test importing yaml config abort.""" + mock_mastodon_client.account_verify_credentials.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "import_client_id", + CONF_CLIENT_SECRET: "import_client_secret", + CONF_ACCESS_TOKEN: "import_access_token", + }, + ) + assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/mastodon/test_init.py b/tests/components/mastodon/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..53796e39782be8bc0beec50b7b2659709e365a6b --- /dev/null +++ b/tests/components/mastodon/test_init.py @@ -0,0 +1,25 @@ +"""Tests for the Mastodon integration.""" + +from unittest.mock import AsyncMock + +from mastodon.Mastodon import MastodonError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_initialization_failure( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test initialization failure.""" + mock_mastodon_client.instance.side_effect = MastodonError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py new file mode 100644 index 0000000000000000000000000000000000000000..ab2d7456baf02da240cbbfd7fcb860f011b934de --- /dev/null +++ b/tests/components/mastodon/test_notify.py @@ -0,0 +1,38 @@ +"""Tests for the Mastodon notify platform.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +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_notify( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sending a message.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(NOTIFY_DOMAIN, "trwnh_mastodon_social") + + await hass.services.async_call( + NOTIFY_DOMAIN, + "trwnh_mastodon_social", + { + "message": "test toot", + }, + blocking=True, + return_response=False, + ) + + assert mock_mastodon_client.status_post.assert_called_once