diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index b6e0d863471..6c8f53e4cb2 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,7 +2,14 @@ from __future__ import annotations -from mastodon.Mastodon import Account, Instance, InstanceV2, Mastodon, MastodonError +from mastodon.Mastodon import ( + Account, + Instance, + InstanceV2, + Mastodon, + MastodonError, + MastodonNotFoundError, +) from homeassistant.const import ( CONF_ACCESS_TOKEN, @@ -105,7 +112,11 @@ def setup_mastodon( entry.data[CONF_ACCESS_TOKEN], ) - instance = client.instance() + try: + instance = client.instance_v2() + except MastodonNotFoundError: + instance = client.instance_v1() + 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 index 1ae1e6b229e..dbd617eca5f 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -7,7 +7,9 @@ from typing import Any from mastodon.Mastodon import ( Account, Instance, + InstanceV2, MastodonNetworkError, + MastodonNotFoundError, MastodonUnauthorizedError, ) import voluptuous as vol @@ -61,7 +63,7 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): client_secret: str, access_token: str, ) -> tuple[ - Instance | None, + InstanceV2 | Instance | None, Account | None, dict[str, str], ]: @@ -73,7 +75,10 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): client_secret, access_token, ) - instance = client.instance() + try: + instance = client.instance_v2() + except MastodonNotFoundError: + instance = client.instance_v1() account = client.account_verify_credentials() except MastodonNetworkError: diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py index 31444413dfd..434f6c0acac 100644 --- a/homeassistant/components/mastodon/diagnostics.py +++ b/homeassistant/components/mastodon/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from mastodon.Mastodon import Account, Instance +from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonNotFoundError from homeassistant.core import HomeAssistant @@ -27,11 +27,16 @@ async def async_get_config_entry_diagnostics( } -def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[Instance, Account]: +def get_diagnostics( + config_entry: MastodonConfigEntry, +) -> tuple[InstanceV2 | Instance, Account]: """Get mastodon diagnostics.""" client = config_entry.runtime_data.client - instance = client.instance() + try: + instance = client.instance_v2() + except MastodonNotFoundError: + instance = client.instance_v1() account = client.account_verify_credentials() return instance, account diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index d8979083de9..0a0e203bf28 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -32,12 +32,16 @@ def mock_mastodon_client() -> Generator[AsyncMock]: ) as mock_client, ): client = mock_client.return_value - client.instance.return_value = InstanceV2.from_json( + client.instance_v1.return_value = InstanceV2.from_json( + load_fixture("instance.json", DOMAIN) + ) + client.instance_v2.return_value = InstanceV2.from_json( load_fixture("instance.json", DOMAIN) ) client.account_verify_credentials.return_value = Account.from_json( load_fixture("account_verify_credentials.json", DOMAIN) ) + client.mastodon_api_version = 2 client.status_post.return_value = None yield client diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index ec9da1836bc..81abc77e21f 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -83,3 +83,87 @@ }), }) # --- +# name: test_entry_diagnostics_fallback_to_instance_v1 + dict({ + 'account': dict({ + 'acct': 'trwnh', + 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'bot': True, + 'created_at': '2016-11-24T00:00:00+00:00', + 'discoverable': True, + 'display_name': 'infinite love ⴳ', + 'emojis': list([ + ]), + 'fields': list([ + dict({ + 'name': 'Website', + 'value': 'trwnh.com', + 'verified_at': '2019-08-29T04:14:55.571+00:00', + }), + dict({ + 'name': 'Portfolio', + 'value': 'abdullahtarawneh.com', + 'verified_at': '2021-02-11T20:34:13.574+00:00', + }), + dict({ + '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': None, + }), + dict({ + 'name': 'What to expect:', + 'value': 'talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.', + 'verified_at': None, + }), + ]), + 'followers_count': 3169, + 'following_count': 328, + 'group': False, + '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', + 'hide_collections': True, + 'id': '14715', + 'indexable': False, + 'last_status_at': '2025-03-04T00:00:00', + 'limited': None, + 'locked': False, + 'memorial': None, + 'moved': None, + 'moved_to_account': None, + 'mute_expires_at': None, + 'noindex': False, + 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live:
- donate.stripe.com/4gwcPCaMpcQ1
- liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

', + 'role': None, + 'roles': list([ + ]), + 'source': None, + 'statuses_count': 69523, + 'suspended': None, + 'uri': 'https://mastodon.social/users/trwnh', + 'url': 'https://mastodon.social/@trwnh', + 'username': 'trwnh', + }), + 'instance': dict({ + 'api_versions': None, + 'configuration': None, + 'contact': None, + 'description': 'The original server operated by the Mastodon gGmbH non-profit', + 'domain': 'mastodon.social', + 'icon': None, + 'languages': None, + 'registrations': None, + 'rules': None, + 'source_url': 'https://github.com/mastodon/mastodon', + 'thumbnail': None, + 'title': 'Mastodon', + 'uri': 'mastodon.social', + 'usage': dict({ + 'users': dict({ + 'active_month': 380143, + }), + }), + 'version': '4.4.0-nightly.2025-02-07', + }), + }) +# --- diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py index 4b022df2ca2..5f1014c31d3 100644 --- a/tests/components/mastodon/test_config_flow.py +++ b/tests/components/mastodon/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import AsyncMock -from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +from mastodon.Mastodon import ( + MastodonNetworkError, + MastodonNotFoundError, + MastodonUnauthorizedError, +) import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN @@ -80,6 +84,46 @@ async def test_full_flow_with_path( assert result["result"].unique_id == "trwnh_mastodon_social" +async def test_full_flow_fallback_to_instance_v1( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow where instance_v2 fails and falls back to instance_v1.""" + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError( + "Instance API v2 not found" + ) + + 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 == "trwnh_mastodon_social" + + mock_mastodon_client.instance_v2.assert_called_once() + mock_mastodon_client.instance_v1.assert_called_once() + + @pytest.mark.parametrize( ("exception", "error"), [ diff --git a/tests/components/mastodon/test_diagnostics.py b/tests/components/mastodon/test_diagnostics.py index 531543ee65d..a3ee1b8eea3 100644 --- a/tests/components/mastodon/test_diagnostics.py +++ b/tests/components/mastodon/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonNotFoundError from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -26,3 +27,26 @@ async def test_entry_diagnostics( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot ) + + +async def test_entry_diagnostics_fallback_to_instance_v1( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics with fallback to instance_v1 when instance_v2 raises MastodonNotFoundError.""" + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError( + "Instance v2 not found" + ) + + await setup_integration(hass, mock_config_entry) + + diagnostics_result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + mock_mastodon_client.instance_v1.assert_called() + + assert diagnostics_result == snapshot diff --git a/tests/components/mastodon/test_init.py b/tests/components/mastodon/test_init.py index c3d0728fe08..b4808792f66 100644 --- a/tests/components/mastodon/test_init.py +++ b/tests/components/mastodon/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from mastodon.Mastodon import MastodonError +from mastodon.Mastodon import MastodonNotFoundError from syrupy.assertion import SnapshotAssertion from homeassistant.components.mastodon.config_flow import MastodonConfigFlow @@ -39,13 +39,30 @@ async def test_initialization_failure( mock_config_entry: MockConfigEntry, ) -> None: """Test initialization failure.""" - mock_mastodon_client.instance.side_effect = MastodonError + mock_mastodon_client.instance_v1.side_effect = MastodonNotFoundError + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_integration_fallback_to_instance_v1( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test full flow where instance_v2 fails and falls back to instance_v1.""" + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError( + "Instance API v2 not found" + ) + + await setup_integration(hass, mock_config_entry) + + mock_mastodon_client.instance_v2.assert_called_once() + mock_mastodon_client.instance_v1.assert_called_once() + + async def test_migrate( hass: HomeAssistant, mock_mastodon_client: AsyncMock,