mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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
This commit is contained in:
@ -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
|
||||
|
@ -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
|
||||
|
168
homeassistant/components/mastodon/config_flow.py
Normal file
168
homeassistant/components/mastodon/config_flow.py
Normal file
@ -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)
|
@ -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"
|
||||
|
@ -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"]
|
||||
|
@ -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,
|
||||
)
|
||||
mastodon.account_verify_credentials()
|
||||
except MastodonUnauthorizedError:
|
||||
LOGGER.warning("Authentication failed")
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
|
39
homeassistant/components/mastodon/strings.json
Normal file
39
homeassistant/components/mastodon/strings.json
Normal file
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
32
homeassistant/components/mastodon/utils.py
Normal file
32
homeassistant/components/mastodon/utils.py
Normal file
@ -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
|
@ -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
|
||||
|
13
tests/components/mastodon/__init__.py
Normal file
13
tests/components/mastodon/__init__.py
Normal file
@ -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()
|
57
tests/components/mastodon/conftest.py
Normal file
57
tests/components/mastodon/conftest.py
Normal file
@ -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",
|
||||
)
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
147
tests/components/mastodon/fixtures/instance.json
Normal file
147
tests/components/mastodon/fixtures/instance.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
33
tests/components/mastodon/snapshots/test_init.ambr
Normal file
33
tests/components/mastodon/snapshots/test_init.ambr
Normal file
@ -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,
|
||||
})
|
||||
# ---
|
179
tests/components/mastodon/test_config_flow.py
Normal file
179
tests/components/mastodon/test_config_flow.py
Normal file
@ -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
|
25
tests/components/mastodon/test_init.py
Normal file
25
tests/components/mastodon/test_init.py
Normal file
@ -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
|
38
tests/components/mastodon/test_notify.py
Normal file
38
tests/components/mastodon/test_notify.py
Normal file
@ -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
|
Reference in New Issue
Block a user