Skip to content
Snippets Groups Projects
Unverified Commit cb4a48ca authored by Andrew Jackson's avatar Andrew Jackson Committed by GitHub
Browse files

Migrate Mastodon integration to config flow (#122376)

* Migrate to config flow

* Fixes & add code owner

* Add codeowners

* Import within notify module

* Fixes from review

* Fixes

* Remove config schema
parent 64f99771
No related branches found
No related tags found
No related merge requests found
Showing
with 954 additions and 29 deletions
......@@ -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
......
"""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
"""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)
......@@ -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"
{
"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"]
......
......@@ -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}")
......
{
"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."
}
}
}
"""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
......@@ -330,6 +330,7 @@ FLOWS = {
"lyric",
"madvr",
"mailgun",
"mastodon",
"matter",
"mealie",
"meater",
......
......@@ -3495,8 +3495,8 @@
},
"mastodon": {
"name": "Mastodon",
"integration_type": "hub",
"config_flow": false,
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_push"
},
"matrix": {
......
......@@ -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
......
"""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()
"""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",
)
{
"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&apos;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&apos;m just here to hang out and talk to cool people!",
"verified_at": null
}
]
}
{
"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"
}
]
}
# 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,
})
# ---
"""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
"""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
"""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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment