From c74c3179229e3ac481f176bdc1d0a8679bf619cb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:06:40 +0200 Subject: [PATCH 1/4] Update slixmpp to 1.12.0 (#154872) --- homeassistant/components/harmony/__init__.py | 11 ++------- .../components/harmony/manifest.json | 2 +- homeassistant/components/xmpp/manifest.json | 5 +--- homeassistant/components/xmpp/notify.py | 24 +++++++------------ requirements_all.txt | 6 ++--- requirements_test_all.txt | 2 +- tests/components/harmony/conftest.py | 9 +------ 7 files changed, 17 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 17b6cf08910217..ed956b07183690 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -3,18 +3,15 @@ from __future__ import annotations import logging -import sys from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -if sys.version_info < (3, 14): - from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS - from .data import HarmonyConfigEntry, HarmonyData +from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS +from .data import HarmonyConfigEntry, HarmonyData _LOGGER = logging.getLogger(__name__) @@ -25,10 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HarmonyConfigEntry) -> b # when setting up a config entry, we fallback to adding # the options to the config entry and pull them out here if # they are missing from the options - if sys.version_info >= (3, 14): - raise HomeAssistantError( - "Logitech Harmony Hub is not supported on Python 3.14. Please use Python 3.13." - ) _async_import_options_from_data_if_missing(hass, entry) address = entry.data[CONF_HOST] diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 795c9c5ed882fa..f74bff314a454e 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.5.3;python_version<'3.14'"], + "requirements": ["aioharmony==0.5.3"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index 8c3a56da2bf0e7..4070a31689b2a0 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -6,8 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], "quality_scale": "legacy", - "requirements": [ - "slixmpp==1.10.0;python_version<'3.14'", - "emoji==2.8.0;python_version<'3.14'" - ] + "requirements": ["slixmpp==1.12.0", "emoji==2.8.0"] } diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index e1d6b9b116091e..d44a826e50c372 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -9,9 +9,16 @@ import pathlib import random import string -import sys import requests +import slixmpp +from slixmpp.exceptions import IqError, IqTimeout, XMPPError +from slixmpp.plugins.xep_0363.http_upload import ( + FileTooBig, + FileUploadError, + UploadServiceNotFound, +) +from slixmpp.xmlstream.xmlstream import NotConnectedError import voluptuous as vol from homeassistant.components.notify import ( @@ -30,20 +37,9 @@ CONF_SENDER, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -if sys.version_info < (3, 14): - import slixmpp - from slixmpp.exceptions import IqError, IqTimeout, XMPPError - from slixmpp.plugins.xep_0363.http_upload import ( - FileTooBig, - FileUploadError, - UploadServiceNotFound, - ) - from slixmpp.xmlstream.xmlstream import NotConnectedError - _LOGGER = logging.getLogger(__name__) ATTR_PATH = "path" @@ -79,10 +75,6 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> XmppNotificationService: """Get the Jabber (XMPP) notification service.""" - if sys.version_info >= (3, 14): - raise HomeAssistantError( - "Jabber (XMPP) is not supported on Python 3.14. Please use Python 3.13." - ) return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_RESOURCE), diff --git a/requirements_all.txt b/requirements_all.txt index 8559e9c25b4357..1e3184452798ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -268,7 +268,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.5.3;python_version<'3.14' +aioharmony==0.5.3 # homeassistant.components.hassio aiohasupervisor==0.3.3 @@ -883,7 +883,7 @@ elmax-api==0.0.6.4rc0 elvia==0.1.0 # homeassistant.components.xmpp -emoji==2.8.0;python_version<'3.14' +emoji==2.8.0 # homeassistant.components.emulated_roku emulated-roku==0.3.0 @@ -2849,7 +2849,7 @@ skyboxremote==0.0.6 slack_sdk==3.33.4 # homeassistant.components.xmpp -slixmpp==1.10.0;python_version<'3.14' +slixmpp==1.12.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3a757301b5113..65a5e05b424b3d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -253,7 +253,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.5.3;python_version<'3.14' +aioharmony==0.5.3 # homeassistant.components.hassio aiohasupervisor==0.3.3 diff --git a/tests/components/harmony/conftest.py b/tests/components/harmony/conftest.py index 701a6410e739c4..65cedf568d60e3 100644 --- a/tests/components/harmony/conftest.py +++ b/tests/components/harmony/conftest.py @@ -3,9 +3,9 @@ from __future__ import annotations from collections.abc import Generator -import sys from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from aioharmony.const import ClientCallbackType import pytest from homeassistant.components.harmony.const import ACTIVITY_POWER_OFF, DOMAIN @@ -20,13 +20,6 @@ from tests.common import MockConfigEntry -if sys.version_info < (3, 14): - from aioharmony.const import ClientCallbackType - -if sys.version_info >= (3, 14): - collect_ignore_glob = ["test_*.py"] - - ACTIVITIES_TO_IDS = { ACTIVITY_POWER_OFF: -1, "Watch TV": WATCH_TV_ACTIVITY_ID, From b08eb3a201f1f7346945443dbf125011ad5228f6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 21 Oct 2025 14:45:10 +0200 Subject: [PATCH 2/4] Refactor NextDNS tests (#154901) --- .../components/nextdns/manifest.json | 2 +- .../components/nextdns/quality_scale.yaml | 4 +- tests/components/nextdns/__init__.py | 153 +----------------- tests/components/nextdns/conftest.py | 59 ++++++- .../nextdns/fixtures/analytics.json | 19 +++ .../nextdns/fixtures/connection_status.json | 4 + .../components/nextdns/fixtures/profiles.json | 1 + .../components/nextdns/fixtures/settings.json | 78 +++++++++ .../components/nextdns/test_binary_sensor.py | 23 +-- tests/components/nextdns/test_button.py | 64 ++++---- tests/components/nextdns/test_config_flow.py | 113 ++++++------- tests/components/nextdns/test_coordinator.py | 50 ++---- tests/components/nextdns/test_diagnostics.py | 3 + tests/components/nextdns/test_init.py | 33 ++-- tests/components/nextdns/test_sensor.py | 49 +++--- tests/components/nextdns/test_switch.py | 115 ++++++------- 16 files changed, 371 insertions(+), 399 deletions(-) create mode 100644 tests/components/nextdns/fixtures/analytics.json create mode 100644 tests/components/nextdns/fixtures/connection_status.json create mode 100644 tests/components/nextdns/fixtures/profiles.json create mode 100644 tests/components/nextdns/fixtures/settings.json diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 27c663aedc792e..e2cbb8612733fb 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["nextdns==4.1.0"] } diff --git a/homeassistant/components/nextdns/quality_scale.yaml b/homeassistant/components/nextdns/quality_scale.yaml index 898a9b3055a4af..375b1bc273acf7 100644 --- a/homeassistant/components/nextdns/quality_scale.yaml +++ b/homeassistant/components/nextdns/quality_scale.yaml @@ -37,9 +37,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: - status: todo - comment: Patch NextDns object instead of functions. + test-coverage: done # Gold devices: done diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index ef46eecaa66860..125e044a12ffdc 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -1,157 +1,9 @@ """Tests for the NextDNS integration.""" -from contextlib import contextmanager -from unittest.mock import patch - -from nextdns import ( - AnalyticsDnssec, - AnalyticsEncryption, - AnalyticsIpVersions, - AnalyticsProtocols, - AnalyticsStatus, - ConnectionStatus, - Settings, -) - from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -CONNECTION_STATUS = ConnectionStatus(connected=True, profile_id="abcdef") -PROFILES = [{"id": "xyz12", "fingerprint": "aabbccdd123", "name": "Fake Profile"}] -STATUS = AnalyticsStatus( - default_queries=40, allowed_queries=30, blocked_queries=20, relayed_queries=10 -) -DNSSEC = AnalyticsDnssec(not_validated_queries=25, validated_queries=75) -ENCRYPTION = AnalyticsEncryption(encrypted_queries=60, unencrypted_queries=40) -IP_VERSIONS = AnalyticsIpVersions(ipv4_queries=90, ipv6_queries=10) -PROTOCOLS = AnalyticsProtocols( - doh_queries=20, - doh3_queries=15, - doq_queries=10, - dot_queries=30, - tcp_queries=0, - udp_queries=40, -) -SETTINGS = Settings( - ai_threat_detection=True, - allow_affiliate=True, - anonymized_ecs=True, - bav=True, - block_bypass_methods=True, - block_csam=True, - block_ddns=True, - block_disguised_trackers=True, - block_nrd=True, - block_page=False, - block_parked_domains=True, - cache_boost=True, - cname_flattening=True, - cryptojacking_protection=True, - dga_protection=True, - dns_rebinding_protection=True, - google_safe_browsing=False, - idn_homograph_attacks_protection=True, - logs=True, - logs_location="ch", - logs_retention=720, - safesearch=False, - threat_intelligence_feeds=True, - typosquatting_protection=True, - web3=True, - youtube_restricted_mode=False, - block_9gag=True, - block_amazon=True, - block_bereal=True, - block_blizzard=True, - block_chatgpt=True, - block_dailymotion=True, - block_discord=True, - block_disneyplus=True, - block_ebay=True, - block_facebook=True, - block_fortnite=True, - block_google_chat=True, - block_hbomax=True, - block_hulu=True, - block_imgur=True, - block_instagram=True, - block_leagueoflegends=True, - block_mastodon=True, - block_messenger=True, - block_minecraft=True, - block_netflix=True, - block_pinterest=True, - block_playstation_network=True, - block_primevideo=True, - block_reddit=True, - block_roblox=True, - block_signal=True, - block_skype=True, - block_snapchat=True, - block_spotify=True, - block_steam=True, - block_telegram=True, - block_tiktok=True, - block_tinder=True, - block_tumblr=True, - block_twitch=True, - block_twitter=True, - block_vimeo=True, - block_vk=True, - block_whatsapp=True, - block_xboxlive=True, - block_youtube=True, - block_zoom=True, - block_dating=True, - block_gambling=True, - block_online_gaming=True, - block_piracy=True, - block_porn=True, - block_social_networks=True, - block_video_streaming=True, -) - - -@contextmanager -def mock_nextdns(): - """Mock the NextDNS class.""" - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_status", - return_value=STATUS, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_encryption", - return_value=ENCRYPTION, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_dnssec", - return_value=DNSSEC, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_ip_versions", - return_value=IP_VERSIONS, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_protocols", - return_value=PROTOCOLS, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_settings", - return_value=SETTINGS, - ), - patch( - "homeassistant.components.nextdns.NextDns.connection_status", - return_value=CONNECTION_STATUS, - ), - ): - yield - async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -159,6 +11,5 @@ async def init_integration( """Set up the NextDNS integration in Home Assistant.""" mock_config_entry.add_to_hass(hass) - with mock_nextdns(): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py index b46c51d673cf85..01eb34e9564ddd 100644 --- a/tests/components/nextdns/conftest.py +++ b/tests/components/nextdns/conftest.py @@ -1,14 +1,40 @@ """Common fixtures for the NextDNS tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from nextdns import ( + AnalyticsDnssec, + AnalyticsEncryption, + AnalyticsIpVersions, + AnalyticsProtocols, + AnalyticsStatus, + ConnectionStatus, + ProfileInfo, + Settings, +) import pytest from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.const import CONF_API_KEY -from tests.common import MockConfigEntry +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +ANALYTICS = load_json_object_fixture("analytics.json", DOMAIN) +ANALYTICS_DNSSEC = AnalyticsDnssec(**ANALYTICS["dnssec"]) +ANALYTICS_ENCRYPTION = AnalyticsEncryption(**ANALYTICS["encryption"]) +ANALYTICS_IP_VERSIONS = AnalyticsIpVersions(**ANALYTICS["ip_versions"]) +ANALYTICS_PROTOCOLS = AnalyticsProtocols(**ANALYTICS["protocols"]) +ANALYTICS_STATUS = AnalyticsStatus(**ANALYTICS["status"]) +CONNECTION_STATUS = ConnectionStatus( + **load_json_object_fixture("connection_status.json", DOMAIN) +) +PROFILES = load_json_array_fixture("profiles.json", DOMAIN) +SETTINGS = Settings(**load_json_object_fixture("settings.json", DOMAIN)) @pytest.fixture @@ -30,3 +56,32 @@ def mock_config_entry() -> MockConfigEntry: data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, entry_id="d9aa37407ddac7b964a99e86312288d6", ) + + +@pytest.fixture +def mock_nextdns_client() -> Generator[AsyncMock]: + """Mock a NextDNS client.""" + + with ( + patch("homeassistant.components.nextdns.NextDns", autospec=True) as mock_client, + patch( + "homeassistant.components.nextdns.config_flow.NextDns", + new=mock_client, + ), + ): + client = mock_client.create.return_value + client.clear_logs.return_value = True + client.connection_status.return_value = CONNECTION_STATUS + client.get_analytics_dnssec.return_value = ANALYTICS_DNSSEC + client.get_analytics_encryption.return_value = ANALYTICS_ENCRYPTION + client.get_analytics_ip_versions.return_value = ANALYTICS_IP_VERSIONS + client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS + client.get_analytics_status.return_value = ANALYTICS_STATUS + client.get_profile_id = Mock(return_value="xyz12") + client.get_profile_name = Mock(return_value="Fake Profile") + client.get_profiles.return_value = PROFILES + client.get_settings.return_value = SETTINGS + client.set_setting.return_value = True + client.profiles = [ProfileInfo(**PROFILES[0])] + + yield client diff --git a/tests/components/nextdns/fixtures/analytics.json b/tests/components/nextdns/fixtures/analytics.json new file mode 100644 index 00000000000000..64bf3c5b36d9f7 --- /dev/null +++ b/tests/components/nextdns/fixtures/analytics.json @@ -0,0 +1,19 @@ +{ + "protocols": { + "doh_queries": 20, + "doh3_queries": 15, + "doq_queries": 10, + "dot_queries": 30, + "tcp_queries": 0, + "udp_queries": 40 + }, + "status": { + "default_queries": 40, + "allowed_queries": 30, + "blocked_queries": 20, + "relayed_queries": 10 + }, + "ip_versions": { "ipv4_queries": 90, "ipv6_queries": 10 }, + "encryption": { "encrypted_queries": 60, "unencrypted_queries": 40 }, + "dnssec": { "not_validated_queries": 25, "validated_queries": 75 } +} diff --git a/tests/components/nextdns/fixtures/connection_status.json b/tests/components/nextdns/fixtures/connection_status.json new file mode 100644 index 00000000000000..63429bc152a432 --- /dev/null +++ b/tests/components/nextdns/fixtures/connection_status.json @@ -0,0 +1,4 @@ +{ + "connected": true, + "profile_id": "abcdef" +} diff --git a/tests/components/nextdns/fixtures/profiles.json b/tests/components/nextdns/fixtures/profiles.json new file mode 100644 index 00000000000000..7debf81ae42871 --- /dev/null +++ b/tests/components/nextdns/fixtures/profiles.json @@ -0,0 +1 @@ +[{ "id": "xyz12", "fingerprint": "aabbccdd123", "name": "Fake Profile" }] diff --git a/tests/components/nextdns/fixtures/settings.json b/tests/components/nextdns/fixtures/settings.json new file mode 100644 index 00000000000000..a7591cfcc7653b --- /dev/null +++ b/tests/components/nextdns/fixtures/settings.json @@ -0,0 +1,78 @@ +{ + "ai_threat_detection": true, + "allow_affiliate": true, + "anonymized_ecs": true, + "bav": true, + "block_bypass_methods": true, + "block_csam": true, + "block_ddns": true, + "block_disguised_trackers": true, + "block_nrd": true, + "block_page": false, + "block_parked_domains": true, + "cache_boost": true, + "cname_flattening": true, + "cryptojacking_protection": true, + "dga_protection": true, + "dns_rebinding_protection": true, + "google_safe_browsing": false, + "idn_homograph_attacks_protection": true, + "logs": true, + "logs_location": "ch", + "logs_retention": 720, + "safesearch": false, + "threat_intelligence_feeds": true, + "typosquatting_protection": true, + "web3": true, + "youtube_restricted_mode": false, + "block_9gag": true, + "block_amazon": true, + "block_bereal": true, + "block_blizzard": true, + "block_chatgpt": true, + "block_dailymotion": true, + "block_discord": true, + "block_disneyplus": true, + "block_ebay": true, + "block_facebook": true, + "block_fortnite": true, + "block_google_chat": true, + "block_hbomax": true, + "block_hulu": true, + "block_imgur": true, + "block_instagram": true, + "block_leagueoflegends": true, + "block_mastodon": true, + "block_messenger": true, + "block_minecraft": true, + "block_netflix": true, + "block_pinterest": true, + "block_playstation_network": true, + "block_primevideo": true, + "block_reddit": true, + "block_roblox": true, + "block_signal": true, + "block_skype": true, + "block_snapchat": true, + "block_spotify": true, + "block_steam": true, + "block_telegram": true, + "block_tiktok": true, + "block_tinder": true, + "block_tumblr": true, + "block_twitch": true, + "block_twitter": true, + "block_vimeo": true, + "block_vk": true, + "block_whatsapp": true, + "block_xboxlive": true, + "block_youtube": true, + "block_zoom": true, + "block_dating": true, + "block_gambling": true, + "block_online_gaming": true, + "block_piracy": true, + "block_porn": true, + "block_social_networks": true, + "block_video_streaming": true +} diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py index c9ad0d6e209b78..639274f02c2dd5 100644 --- a/tests/components/nextdns/test_binary_sensor.py +++ b/tests/components/nextdns/test_binary_sensor.py @@ -1,7 +1,7 @@ """Test binary sensor of NextDNS integration.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration, mock_nextdns +from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -21,6 +21,7 @@ async def test_binary_sensor( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test states of the binary sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): @@ -34,6 +35,7 @@ async def test_availability( freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_nextdns_client: AsyncMock, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): @@ -47,21 +49,20 @@ async def test_availability( for entity_id in entity_ids: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + mock_nextdns_client.connection_status.side_effect = ApiError("API Error") + freezer.tick(timedelta(minutes=10)) - with patch( - "homeassistant.components.nextdns.NextDns.connection_status", - side_effect=ApiError("API Error"), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass) + await hass.async_block_till_done() for entity_id in entity_ids: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + mock_nextdns_client.connection_status.side_effect = None + freezer.tick(timedelta(minutes=10)) - with mock_nextdns(): - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass) + await hass.async_block_till_done() for entity_id in entity_ids: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 03108e8198464a..08d017802b0029 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -1,6 +1,6 @@ """Test button of NextDNS integration.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError @@ -26,6 +26,7 @@ async def test_button( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test states of the button.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BUTTON]): @@ -36,23 +37,22 @@ async def test_button( @pytest.mark.freeze_time("2023-10-21") async def test_button_press( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test button press.""" await init_integration(hass, mock_config_entry) - with ( - patch("homeassistant.components.nextdns.NextDns.clear_logs") as mock_clear_logs, - ): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + await hass.async_block_till_done() - mock_clear_logs.assert_called_once() + mock_nextdns_client.clear_logs.assert_called_once() state = hass.states.get("button.fake_profile_clear_logs") assert state @@ -69,17 +69,19 @@ async def test_button_press( ], ) async def test_button_failure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, + exc: Exception, ) -> None: """Tests that the press action throws HomeAssistantError.""" await init_integration(hass, mock_config_entry) - with ( - patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), - pytest.raises( - HomeAssistantError, - match="An error occurred while calling the NextDNS API method for button.fake_profile_clear_logs", - ), + mock_nextdns_client.clear_logs.side_effect = exc + + with pytest.raises( + HomeAssistantError, + match="An error occurred while calling the NextDNS API method for button.fake_profile_clear_logs", ): await hass.services.async_call( BUTTON_DOMAIN, @@ -90,21 +92,21 @@ async def test_button_failure( async def test_button_auth_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Tests that the press action starts re-auth flow.""" await init_integration(hass, mock_config_entry) - with patch( - "homeassistant.components.nextdns.NextDns.clear_logs", - side_effect=InvalidApiKeyError, - ): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, - blocking=True, - ) + mock_nextdns_client.clear_logs.side_effect = InvalidApiKeyError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index d577fb21845c28..5ad76820241752 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -12,13 +12,15 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import PROFILES, init_integration, mock_nextdns +from . import init_integration from tests.common import MockConfigEntry async def test_form_create_entry( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_nextdns_client: AsyncMock, ) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( @@ -28,21 +30,17 @@ async def test_form_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "fake_api_key"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "profiles" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "profiles" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Fake Profile" @@ -63,7 +61,11 @@ async def test_form_create_entry( ], ) async def test_form_errors( - hass: HomeAssistant, mock_setup_entry: AsyncMock, exc: Exception, base_error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_nextdns_client: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( @@ -73,7 +75,8 @@ async def test_form_errors( assert result["errors"] == {} with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc + "homeassistant.components.nextdns.NextDns.create", + side_effect=exc, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -83,21 +86,17 @@ async def test_form_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": base_error} - with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "fake_api_key"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "profiles" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "profiles" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Fake Profile" @@ -108,7 +107,9 @@ async def test_form_errors( async def test_form_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test that errors are shown when duplicates are added.""" await init_integration(hass, mock_config_entry) @@ -117,13 +118,10 @@ async def test_form_already_configured( DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", return_value=PROFILES - ): - await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "fake_api_key"}, - ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} @@ -134,7 +132,9 @@ async def test_form_already_configured( async def test_reauth_successful( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test starting a reauthentication flow.""" await init_integration(hass, mock_config_entry) @@ -143,17 +143,10 @@ async def test_reauth_successful( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ), - mock_nextdns(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_API_KEY: "new_api_key"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -174,6 +167,7 @@ async def test_reauth_errors( exc: Exception, base_error: str, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test reauthentication flow with errors.""" await init_integration(hass, mock_config_entry) @@ -182,9 +176,7 @@ async def test_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc - ): + with patch("homeassistant.components.nextdns.NextDns.create", side_effect=exc): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, @@ -192,17 +184,10 @@ async def test_reauth_errors( assert result["errors"] == {"base": base_error} - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ), - mock_nextdns(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_API_KEY: "new_api_key"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/nextdns/test_coordinator.py b/tests/components/nextdns/test_coordinator.py index 83748f836b54ba..840757d456f6aa 100644 --- a/tests/components/nextdns/test_coordinator.py +++ b/tests/components/nextdns/test_coordinator.py @@ -1,7 +1,7 @@ """Tests for NextDNS coordinator.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from nextdns import InvalidApiKeyError @@ -19,49 +19,25 @@ async def test_auth_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test authentication error when polling data.""" await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED + mock_nextdns_client.get_profiles.side_effect = InvalidApiKeyError + mock_nextdns_client.get_analytics_status.side_effect = InvalidApiKeyError + mock_nextdns_client.get_analytics_dnssec.side_effect = InvalidApiKeyError + mock_nextdns_client.get_analytics_encryption.side_effect = InvalidApiKeyError + mock_nextdns_client.get_analytics_ip_versions.side_effect = InvalidApiKeyError + mock_nextdns_client.get_analytics_protocols.side_effect = InvalidApiKeyError + mock_nextdns_client.get_settings.side_effect = InvalidApiKeyError + mock_nextdns_client.connection_status.side_effect = InvalidApiKeyError + freezer.tick(timedelta(minutes=10)) - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_status", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_encryption", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_dnssec", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_ip_versions", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_protocols", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.get_settings", - side_effect=InvalidApiKeyError, - ), - patch( - "homeassistant.components.nextdns.NextDns.connection_status", - side_effect=InvalidApiKeyError, - ), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done() + async_fire_time_changed(hass) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 2b0c0564649b8f..5d870c03471cb6 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -1,5 +1,7 @@ """Test NextDNS diagnostics.""" +from unittest.mock import AsyncMock + from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -17,6 +19,7 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test config entry diagnostics.""" await init_integration(hass, mock_config_entry) diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 217e75ca70128c..9532f5a13e0fc6 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -1,6 +1,6 @@ """Test init of NextDNS integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nextdns import ApiError, InvalidApiKeyError import pytest @@ -17,7 +17,9 @@ async def test_async_setup_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test a successful setup entry.""" await init_integration(hass, mock_config_entry) @@ -32,20 +34,25 @@ async def test_async_setup_entry( "exc", [ApiError("API Error"), RetryError("Retry Error"), TimeoutError] ) async def test_config_not_ready( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, + exc: Exception, ) -> None: """Test for setup failure if the connection to the service fails.""" with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", + "homeassistant.components.nextdns.NextDns.create", side_effect=exc, ): - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_unload_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test successful unload of entry.""" await init_integration(hass, mock_config_entry) @@ -61,16 +68,16 @@ async def test_unload_entry( async def test_config_auth_failed( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test for setup failure if the auth fails.""" - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.nextdns.NextDns.get_profiles", + "homeassistant.components.nextdns.NextDns.create", side_effect=InvalidApiKeyError, ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index 3ef1ab55f9fef2..ba9cab30468cb5 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -1,7 +1,7 @@ """Test sensor of NextDNS integration.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration, mock_nextdns +from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -23,6 +23,7 @@ async def test_sensor( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test states of sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): @@ -37,6 +38,7 @@ async def test_availability( freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_nextdns_client: AsyncMock, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): @@ -50,39 +52,28 @@ async def test_availability( for entity_id in entity_ids: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + mock_nextdns_client.get_analytics_status.side_effect = ApiError("API Error") + mock_nextdns_client.get_analytics_dnssec.side_effect = ApiError("API Error") + mock_nextdns_client.get_analytics_encryption.side_effect = ApiError("API Error") + mock_nextdns_client.get_analytics_ip_versions.side_effect = ApiError("API Error") + mock_nextdns_client.get_analytics_protocols.side_effect = ApiError("API Error") + freezer.tick(timedelta(minutes=10)) - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_status", - side_effect=ApiError("API Error"), - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_dnssec", - side_effect=ApiError("API Error"), - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_encryption", - side_effect=ApiError("API Error"), - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_ip_versions", - side_effect=ApiError("API Error"), - ), - patch( - "homeassistant.components.nextdns.NextDns.get_analytics_protocols", - side_effect=ApiError("API Error"), - ), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass) + await hass.async_block_till_done() for entity_id in entity_ids: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + mock_nextdns_client.get_analytics_status.side_effect = None + mock_nextdns_client.get_analytics_dnssec.side_effect = None + mock_nextdns_client.get_analytics_encryption.side_effect = None + mock_nextdns_client.get_analytics_ip_versions.side_effect = None + mock_nextdns_client.get_analytics_protocols.side_effect = None + freezer.tick(timedelta(minutes=10)) - with mock_nextdns(): - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass) + await hass.async_block_till_done() for entity_id in entity_ids: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 645ca11ac498c5..9db8cc1148b40e 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -1,7 +1,7 @@ """Test switch of NextDNS integration.""" from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError @@ -27,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration, mock_nextdns +from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -38,6 +38,7 @@ async def test_switch( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test states of the switches.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): @@ -47,7 +48,9 @@ async def test_switch( async def test_switch_on( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test the switch can be turned on.""" await init_integration(hass, mock_config_entry) @@ -56,26 +59,25 @@ async def test_switch_on( assert state assert state.state == STATE_OFF - with patch( - "homeassistant.components.nextdns.NextDns.set_setting", return_value=True - ) as mock_switch_on: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, + blocking=True, + ) + await hass.async_block_till_done() - state = hass.states.get("switch.fake_profile_block_page") - assert state - assert state.state == STATE_ON + state = hass.states.get("switch.fake_profile_block_page") + assert state + assert state.state == STATE_ON - mock_switch_on.assert_called_once() + mock_nextdns_client.set_setting.assert_called_once() async def test_switch_off( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Test the switch can be turned on.""" await init_integration(hass, mock_config_entry) @@ -84,22 +86,19 @@ async def test_switch_off( assert state assert state.state == STATE_ON - with patch( - "homeassistant.components.nextdns.NextDns.set_setting", return_value=True - ) as mock_switch_on: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.fake_profile_web3"}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.fake_profile_web3"}, + blocking=True, + ) + await hass.async_block_till_done() - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state == STATE_OFF + state = hass.states.get("switch.fake_profile_web3") + assert state + assert state.state == STATE_OFF - mock_switch_on.assert_called_once() + mock_nextdns_client.set_setting.assert_called_once() @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -117,6 +116,7 @@ async def test_availability( freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_nextdns_client: AsyncMock, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): @@ -130,21 +130,20 @@ async def test_availability( for entity_id in entity_ids: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + mock_nextdns_client.set_setting.side_effect = exc + freezer.tick(timedelta(minutes=10)) - with patch( - "homeassistant.components.nextdns.NextDns.get_settings", - side_effect=exc, - ): - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass) + await hass.async_block_till_done() for entity_id in entity_ids: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + mock_nextdns_client.set_setting.side_effect = None + freezer.tick(timedelta(minutes=10)) - with mock_nextdns(): - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass) + await hass.async_block_till_done() for entity_id in entity_ids: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE @@ -160,15 +159,17 @@ async def test_availability( ], ) async def test_switch_failure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, + exc: Exception, ) -> None: """Tests that the turn on/off service throws HomeAssistantError.""" await init_integration(hass, mock_config_entry) - with ( - patch("homeassistant.components.nextdns.NextDns.set_setting", side_effect=exc), - pytest.raises(HomeAssistantError), - ): + mock_nextdns_client.set_setting.side_effect = exc + + with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -178,21 +179,21 @@ async def test_switch_failure( async def test_switch_auth_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, ) -> None: """Tests that the turn on/off action starts re-auth flow.""" await init_integration(hass, mock_config_entry) - with patch( - "homeassistant.components.nextdns.NextDns.set_setting", - side_effect=InvalidApiKeyError, - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, - blocking=True, - ) + mock_nextdns_client.set_setting.side_effect = InvalidApiKeyError + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, + blocking=True, + ) assert mock_config_entry.state is ConfigEntryState.LOADED From 84d9fa3bd76beddfc3b76ec4560c8bad967d2a91 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:07:37 +0200 Subject: [PATCH 3/4] Refactor coordinator data update and exception handling in Xbox integration (#154848) --- .../components/xbox/binary_sensor.py | 45 ++++-- homeassistant/components/xbox/coordinator.py | 150 +++++++----------- homeassistant/components/xbox/entity.py | 4 +- homeassistant/components/xbox/sensor.py | 24 +-- homeassistant/components/xbox/strings.json | 2 +- tests/components/xbox/test_init.py | 30 ++++ 6 files changed, 141 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index e754b4d79aaeb8..b3c1fc7ce63510 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -7,6 +7,7 @@ from enum import StrEnum from functools import partial +from xbox.webapi.api.provider.people.models import Person from yarl import URL from homeassistant.components.binary_sensor import ( @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import PresenceData, XboxConfigEntry, XboxUpdateCoordinator +from .coordinator import XboxConfigEntry, XboxUpdateCoordinator from .entity import XboxBaseEntity @@ -34,11 +35,11 @@ class XboxBinarySensor(StrEnum): class XboxBinarySensorEntityDescription(BinarySensorEntityDescription): """Xbox binary sensor description.""" - is_on_fn: Callable[[PresenceData], bool | None] - entity_picture_fn: Callable[[PresenceData], str | None] | None = None + is_on_fn: Callable[[Person], bool | None] + entity_picture_fn: Callable[[Person], str | None] | None = None -def profile_pic(data: PresenceData) -> str | None: +def profile_pic(person: Person) -> str | None: """Return the gamer pic.""" # Xbox sometimes returns a domain that uses a wrong certificate which @@ -47,7 +48,7 @@ def profile_pic(data: PresenceData) -> str | None: # to point to the correct image, with the correct domain and certificate. # We need to also remove the 'mode=Padding' query because with it, # it results in an error 400. - url = URL(data.display_pic) + url = URL(person.display_pic_raw) if url.host == "images-eds.xboxlive.com": url = url.with_host("images-eds-ssl.xboxlive.com").with_scheme("https") query = dict(url.query) @@ -55,35 +56,59 @@ def profile_pic(data: PresenceData) -> str | None: return str(url.with_query(query)) +def in_game(person: Person) -> bool: + """True if person is in a game.""" + + active_app = ( + next( + (presence for presence in person.presence_details if presence.is_primary), + None, + ) + if person.presence_details + else None + ) + return ( + active_app is not None and active_app.is_game and active_app.state == "Active" + ) + + SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = ( XboxBinarySensorEntityDescription( key=XboxBinarySensor.ONLINE, translation_key=XboxBinarySensor.ONLINE, - is_on_fn=lambda x: x.online, + is_on_fn=lambda x: x.presence_state == "Online", name=None, entity_picture_fn=profile_pic, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_PARTY, translation_key=XboxBinarySensor.IN_PARTY, - is_on_fn=lambda x: x.in_party, + is_on_fn=( + lambda x: bool(x.multiplayer_summary.in_party) + if x.multiplayer_summary + else None + ), entity_registry_enabled_default=False, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_GAME, translation_key=XboxBinarySensor.IN_GAME, - is_on_fn=lambda x: x.in_game, + is_on_fn=in_game, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_MULTIPLAYER, translation_key=XboxBinarySensor.IN_MULTIPLAYER, - is_on_fn=lambda x: x.in_multiplayer, + is_on_fn=( + lambda x: bool(x.multiplayer_summary.in_multiplayer_session) + if x.multiplayer_summary + else None + ), entity_registry_enabled_default=False, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.HAS_GAME_PASS, translation_key=XboxBinarySensor.HAS_GAME_PASS, - is_on_fn=lambda x: x.has_game_pass, + is_on_fn=lambda x: x.detail.has_game_pass if x.detail else None, ), ) diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 17040e65464753..651e08f52a0e17 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -3,18 +3,14 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import timedelta import logging from httpx import HTTPStatusError, RequestError, TimeoutException from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.const import SYSTEM_PFN_ID_MAP from xbox.webapi.api.provider.catalog.models import AlternateIdType, Product -from xbox.webapi.api.provider.people.models import ( - PeopleResponse, - Person, - PresenceDetail, -) +from xbox.webapi.api.provider.people.models import Person from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleList, SmartglassConsoleStatus, @@ -25,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import api from .const import DOMAIN @@ -43,33 +39,12 @@ class ConsoleData: app_details: Product | None -@dataclass -class PresenceData: - """Xbox user presence data.""" - - xuid: str - gamertag: str - display_pic: str - online: bool - status: str - in_party: bool - in_game: bool - in_multiplayer: bool - gamer_score: str - gold_tenure: str | None - account_tier: str - last_seen: datetime | None - following_count: int - follower_count: int - has_game_pass: bool - - @dataclass class XboxData: """Xbox dataclass for update coordinator.""" consoles: dict[str, ConsoleData] = field(default_factory=dict) - presence: dict[str, PresenceData] = field(default_factory=dict) + presence: dict[str, Person] = field(default_factory=dict) class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): @@ -107,7 +82,6 @@ async def _async_setup(self) -> None: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="request_exception", - translation_placeholders={"error": str(e)}, ) from e session = config_entry_oauth2_flow.OAuth2Session( @@ -129,7 +103,6 @@ async def _async_setup(self) -> None: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="request_exception", - translation_placeholders={"error": str(e)}, ) from e _LOGGER.debug( @@ -143,11 +116,20 @@ async def _async_update_data(self) -> XboxData: # Update Console Status new_console_data: dict[str, ConsoleData] = {} for console in self.consoles.result: - current_state: ConsoleData | None = self.data.consoles.get(console.id) - status: SmartglassConsoleStatus = ( - await self.client.smartglass.get_console_status(console.id) - ) - + current_state = self.data.consoles.get(console.id) + try: + status = await self.client.smartglass.get_console_status(console.id) + except TimeoutException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e _LOGGER.debug( "%s status: %s", console.name, @@ -169,13 +151,26 @@ async def _async_update_data(self) -> XboxData: if app_id in SYSTEM_PFN_ID_MAP: id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID app_id = SYSTEM_PFN_ID_MAP[app_id][id_type] - catalog_result = ( - await self.client.catalog.get_product_from_alternate_id( - app_id, id_type + try: + catalog_result = ( + await self.client.catalog.get_product_from_alternate_id( + app_id, id_type + ) ) - ) - if catalog_result and catalog_result.products: - app_details = catalog_result.products[0] + except TimeoutException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e + else: + if catalog_result.products: + app_details = catalog_result.products[0] else: app_details = None @@ -184,19 +179,25 @@ async def _async_update_data(self) -> XboxData: ) # Update user presence - presence_data: dict[str, PresenceData] = {} - batch: PeopleResponse = await self.client.people.get_friends_own_batch( - [self.client.xuid] - ) - own_presence: Person = batch.people[0] - presence_data[own_presence.xuid] = _build_presence_data(own_presence) - - friends: PeopleResponse = await self.client.people.get_friends_own() - for friend in friends.people: - if not friend.is_favorite: - continue - - presence_data[friend.xuid] = _build_presence_data(friend) + try: + batch = await self.client.people.get_friends_own_batch([self.client.xuid]) + friends = await self.client.people.get_friends_own() + except TimeoutException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e + else: + presence_data = {self.client.xuid: batch.people[0]} + presence_data.update( + {friend.xuid: friend for friend in friends.people if friend.is_favorite} + ) if ( self.current_friends @@ -208,11 +209,11 @@ async def _async_update_data(self) -> XboxData: return XboxData(new_console_data, presence_data) - def remove_stale_devices(self, presence_data: dict[str, PresenceData]) -> None: + def remove_stale_devices(self, presence_data: dict[str, Person]) -> None: """Remove stale devices from registry.""" device_reg = dr.async_get(self.hass) - identifiers = {(DOMAIN, person.xuid) for person in presence_data.values()} | { + identifiers = {(DOMAIN, xuid) for xuid in set(presence_data)} | { (DOMAIN, console.id) for console in self.consoles.result } @@ -224,38 +225,3 @@ def remove_stale_devices(self, presence_data: dict[str, PresenceData]) -> None: device_reg.async_update_device( device.id, remove_config_entry_id=self.config_entry.entry_id ) - - -def _build_presence_data(person: Person) -> PresenceData: - """Build presence data from a person.""" - active_app: PresenceDetail | None = None - - active_app = next( - (presence for presence in person.presence_details if presence.is_primary), - None, - ) - in_game = ( - active_app is not None and active_app.is_game and active_app.state == "Active" - ) - - return PresenceData( - xuid=person.xuid, - gamertag=person.gamertag, - display_pic=person.display_pic_raw, - online=person.presence_state == "Online", - status=person.presence_text, - in_party=person.multiplayer_summary.in_party > 0, - in_game=in_game, - in_multiplayer=person.multiplayer_summary.in_multiplayer_session, - gamer_score=person.gamer_score, - gold_tenure=person.detail.tenure, - account_tier=person.detail.account_tier, - last_seen=( - person.last_seen_date_time_utc.replace(tzinfo=UTC) - if person.last_seen_date_time_utc - else None - ), - follower_count=person.detail.follower_count, - following_count=person.detail.following_count, - has_game_pass=person.detail.has_game_pass, - ) diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index 40917da792f428..410ef7306ed544 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -7,7 +7,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PresenceData, XboxUpdateCoordinator +from .coordinator import Person, XboxUpdateCoordinator class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): @@ -37,6 +37,6 @@ def __init__( ) @property - def data(self) -> PresenceData: + def data(self) -> Person: """Return coordinator data for this console.""" return self.coordinator.data.presence[self.xuid] diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 3b064797532584..d2fd639645e525 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -4,10 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from enum import StrEnum from functools import partial +from xbox.webapi.api.provider.people.models import Person + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -17,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import PresenceData, XboxConfigEntry, XboxUpdateCoordinator +from .coordinator import XboxConfigEntry, XboxUpdateCoordinator from .entity import XboxBaseEntity @@ -37,14 +39,14 @@ class XboxSensor(StrEnum): class XboxSensorEntityDescription(SensorEntityDescription): """Xbox sensor description.""" - value_fn: Callable[[PresenceData], StateType | datetime] + value_fn: Callable[[Person], StateType | datetime] SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = ( XboxSensorEntityDescription( key=XboxSensor.STATUS, translation_key=XboxSensor.STATUS, - value_fn=lambda x: x.status, + value_fn=lambda x: x.presence_text, ), XboxSensorEntityDescription( key=XboxSensor.GAMER_SCORE, @@ -55,29 +57,33 @@ class XboxSensorEntityDescription(SensorEntityDescription): key=XboxSensor.ACCOUNT_TIER, translation_key=XboxSensor.ACCOUNT_TIER, entity_registry_enabled_default=False, - value_fn=lambda x: x.account_tier, + value_fn=lambda x: x.detail.account_tier if x.detail else None, ), XboxSensorEntityDescription( key=XboxSensor.GOLD_TENURE, translation_key=XboxSensor.GOLD_TENURE, entity_registry_enabled_default=False, - value_fn=lambda x: x.gold_tenure, + value_fn=lambda x: x.detail.tenure if x.detail else None, ), XboxSensorEntityDescription( key=XboxSensor.LAST_ONLINE, translation_key=XboxSensor.LAST_ONLINE, - value_fn=(lambda x: x.last_seen), + value_fn=( + lambda x: x.last_seen_date_time_utc.replace(tzinfo=UTC) + if x.last_seen_date_time_utc + else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), XboxSensorEntityDescription( key=XboxSensor.FOLLOWING, translation_key=XboxSensor.FOLLOWING, - value_fn=lambda x: x.following_count, + value_fn=lambda x: x.detail.following_count if x.detail else None, ), XboxSensorEntityDescription( key=XboxSensor.FOLLOWER, translation_key=XboxSensor.FOLLOWER, - value_fn=lambda x: x.follower_count, + value_fn=lambda x: x.detail.follower_count if x.detail else None, ), ) diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 2df1546f22cef2..5780aec398d57c 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -68,7 +68,7 @@ }, "exceptions": { "request_exception": { - "message": "Failed to connect to Xbox Network: {error}" + "message": "Failed to connect to Xbox Network" }, "timeout_exception": { "message": "Failed to connect to Xbox Network due to a connection timeout" diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index 3a787476386437..cda608d5ac5866 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -64,3 +64,33 @@ async def test_config_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("exception", [ConnectTimeout, HTTPStatusError, ProtocolError]) +@pytest.mark.parametrize( + ("provider", "method"), + [ + ("smartglass", "get_console_status"), + ("catalog", "get_product_from_alternate_id"), + ("people", "get_friends_own_batch"), + ("people", "get_friends_own"), + ], +) +async def test_coordinator_update_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + xbox_live_client: AsyncMock, + exception: Exception, + provider: str, + method: str, +) -> None: + """Test coordinator update failed.""" + + provider = getattr(xbox_live_client, provider) + getattr(provider, method).side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From d6db50fcc736109f3d74b125790fef2ed8ea54f2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:29:49 +0200 Subject: [PATCH 4/4] Add discovery support to Xbox integration (#154912) --- homeassistant/components/xbox/manifest.json | 17 ++++++++++++++++- homeassistant/components/xbox/strings.json | 4 ++++ homeassistant/generated/dhcp.py | 4 ++++ homeassistant/generated/ssdp.py | 10 ++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 3fc2071e66bf79..067052f010b70b 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -4,7 +4,22 @@ "codeowners": ["@hunterjm"], "config_flow": true, "dependencies": ["auth", "application_credentials"], + "dhcp": [ + { + "hostname": "xbox*" + } + ], "documentation": "https://www.home-assistant.io/integrations/xbox", "iot_class": "cloud_polling", - "requirements": ["xbox-webapi==2.1.0"] + "requirements": ["xbox-webapi==2.1.0"], + "ssdp": [ + { + "modelName": "Xbox 360", + "manufacturer": "Microsoft Corporation" + }, + { + "modelName": "Xbox One", + "manufacturer": "Microsoft Corporation" + } + ] } diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 5780aec398d57c..d3b33b145896f6 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -9,6 +9,10 @@ "data_description": { "implementation": "[%key:common::config_flow::description::implementation%]" } + }, + "oauth_discovery": { + "description": "Home Assistant has found an Xbox device on your network. Press **Submit** to continue setting up the Xbox integration.", + "title": "Discovered Xbox device" } }, "abort": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 0ae6875702cfc0..a1bc052b50d75c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1362,6 +1362,10 @@ "domain": "wmspro", "registered_devices": True, }, + { + "domain": "xbox", + "hostname": "xbox*", + }, { "domain": "yale", "hostname": "yale-connect-plus", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index acbb74645a3478..7ab04febb754fa 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -400,6 +400,16 @@ "manufacturer": "All Automacao Ltda", }, ], + "xbox": [ + { + "manufacturer": "Microsoft Corporation", + "modelName": "Xbox 360", + }, + { + "manufacturer": "Microsoft Corporation", + "modelName": "Xbox One", + }, + ], "yamaha_musiccast": [ { "manufacturer": "Yamaha Corporation",