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&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
+    }
+  ]
+}
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