From d4e72ad2cf47118ece02f6cc4acefd7fe5af9219 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:18:56 +0200 Subject: [PATCH] Refactor Xbox integration setup and exception handling (#154823) --- homeassistant/components/xbox/__init__.py | 25 +------- homeassistant/components/xbox/coordinator.py | 63 ++++++++++++++++--- homeassistant/components/xbox/strings.json | 8 +++ tests/components/xbox/conftest.py | 6 +- tests/components/xbox/test_init.py | 66 ++++++++++++++++++++ 5 files changed, 133 insertions(+), 35 deletions(-) create mode 100644 tests/components/xbox/test_init.py diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 7889fafcc9e..be42f140e9e 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -4,15 +4,10 @@ from __future__ import annotations import logging -from xbox.webapi.api.client import XboxLiveClient -from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList -from xbox.webapi.common.signed_session import SignedSession - from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import config_validation as cv -from . import api from .const import DOMAIN from .coordinator import XboxConfigEntry, XboxUpdateCoordinator @@ -30,24 +25,8 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: """Set up xbox from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - signed_session = await hass.async_add_executor_job(SignedSession) - auth = api.AsyncConfigEntryAuth(signed_session, session) - client = XboxLiveClient(auth) - consoles: SmartglassConsoleList = await client.smartglass.get_console_list() - _LOGGER.debug( - "Found %d consoles: %s", - len(consoles.result), - consoles.model_dump(), - ) - - coordinator = XboxUpdateCoordinator(hass, entry, client, consoles) + coordinator = XboxUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index df31aa77f40..5ef21c96672 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field 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 @@ -18,12 +19,15 @@ from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleList, SmartglassConsoleStatus, ) +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +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 . import api from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -60,21 +64,21 @@ class PresenceData: class XboxData: """Xbox dataclass for update coordinator.""" - consoles: dict[str, ConsoleData] - presence: dict[str, PresenceData] + consoles: dict[str, ConsoleData] = field(default_factory=dict) + presence: dict[str, PresenceData] = field(default_factory=dict) class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): """Store Xbox Console Status.""" config_entry: ConfigEntry + consoles: SmartglassConsoleList + client: XboxLiveClient def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, - client: XboxLiveClient, - consoles: SmartglassConsoleList, ) -> None: """Initialize.""" super().__init__( @@ -84,11 +88,52 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): name=DOMAIN, update_interval=timedelta(seconds=10), ) - self.data = XboxData({}, {}) - self.client: XboxLiveClient = client - self.consoles: SmartglassConsoleList = consoles + self.data = XboxData() self.current_friends: set[str] = set() + async def _async_setup(self) -> None: + """Set up coordinator.""" + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + self.hass, self.config_entry + ) + ) + except ValueError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="request_exception", + translation_placeholders={"error": str(e)}, + ) from e + + session = config_entry_oauth2_flow.OAuth2Session( + self.hass, self.config_entry, implementation + ) + signed_session = await self.hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) + self.client = XboxLiveClient(auth) + + try: + self.consoles = await self.client.smartglass.get_console_list() + except TimeoutException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="request_exception", + translation_placeholders={"error": str(e)}, + ) from e + + _LOGGER.debug( + "Found %d consoles: %s", + len(self.consoles.result), + self.consoles.model_dump(), + ) + async def _async_update_data(self) -> XboxData: """Fetch the latest console status.""" # Update Console Status diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 2942aaf2f67..c5b424da6cf 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -51,5 +51,13 @@ "name": "In multiplayer" } } + }, + "exceptions": { + "request_exception": { + "message": "Failed to connect Xbox Network: {error}" + }, + "timeout_exception": { + "message": "Failed to connect Xbox Network due to a connection timeout" + } } } diff --git a/tests/components/xbox/conftest.py b/tests/components/xbox/conftest.py index e6ad579e0c2..a028111a804 100644 --- a/tests/components/xbox/conftest.py +++ b/tests/components/xbox/conftest.py @@ -38,7 +38,7 @@ async def setup_credentials(hass: HomeAssistant) -> None: def mock_oauth2_implementation() -> Generator[AsyncMock]: """Mock config entry oauth2 implementation.""" with patch( - "homeassistant.components.xbox.config_entry_oauth2_flow.async_get_config_entry_implementation", + "homeassistant.components.xbox.coordinator.config_entry_oauth2_flow.async_get_config_entry_implementation", return_value=AsyncMock(), ) as mock_client: client = mock_client.return_value @@ -89,7 +89,7 @@ def mock_signed_session() -> Generator[AsyncMock]: with ( patch( - "homeassistant.components.xbox.SignedSession", autospec=True + "homeassistant.components.xbox.coordinator.SignedSession", autospec=True ) as mock_client, patch( "homeassistant.components.xbox.config_flow.SignedSession", new=mock_client @@ -106,7 +106,7 @@ def mock_xbox_live_client(signed_session) -> Generator[AsyncMock]: with ( patch( - "homeassistant.components.xbox.XboxLiveClient", autospec=True + "homeassistant.components.xbox.coordinator.XboxLiveClient", autospec=True ) as mock_client, patch( "homeassistant.components.xbox.config_flow.XboxLiveClient", new=mock_client diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py new file mode 100644 index 00000000000..3a787476386 --- /dev/null +++ b/tests/components/xbox/test_init.py @@ -0,0 +1,66 @@ +"""Tests for the Xbox integration.""" + +from unittest.mock import AsyncMock, patch + +from httpx import ConnectTimeout, HTTPStatusError, ProtocolError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("xbox_live_client") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", + [ConnectTimeout, HTTPStatusError, ProtocolError], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + xbox_live_client: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready.""" + + xbox_live_client.smartglass.get_console_list.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 + + +@pytest.mark.usefixtures("xbox_live_client") +async def test_config_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test implementation not available.""" + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.xbox.coordinator.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ValueError("Implementation not available"), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY