diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 24a0b8fd8e0..6232fc2272d 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -168,36 +168,40 @@ class XboxConsoleStatusCoordinator(XboxBaseCoordinator[dict[str, ConsoleData]]): _LOGGER.debug("%s status: %s", console.name, status.model_dump()) # Setup focus app - app_details: Product | None = None - if (current_state := self.data.get(console.id)) is not None: - app_details = current_state.app_details + app_details = ( + current_state.app_details + if (current_state := self.data.get(console.id)) is not None + and status.focus_app_aumid + else None + ) - if status.focus_app_aumid: - if ( - not current_state - or status.focus_app_aumid != current_state.status.focus_app_aumid - ): - app_id = status.focus_app_aumid.split("!")[0] - id_type = AlternateIdType.PACKAGE_FAMILY_NAME - 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 - ) + if status.focus_app_aumid and ( + not current_state + or status.focus_app_aumid != current_state.status.focus_app_aumid + ): + catalog_result = ( + await self.client.catalog.get_product_from_alternate_id( + *self._resolve_app_id(status.focus_app_aumid) ) + ) - if catalog_result.products: - app_details = catalog_result.products[0] - else: - app_details = None + if catalog_result.products: + app_details = catalog_result.products[0] data[console.id] = ConsoleData(status=status, app_details=app_details) return data + def _resolve_app_id(self, focus_app_aumid: str) -> tuple[str, AlternateIdType]: + app_id = focus_app_aumid.split("!", maxsplit=1)[0] + id_type = AlternateIdType.PACKAGE_FAMILY_NAME + + 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] + + return app_id, id_type + class XboxPresenceCoordinator(XboxBaseCoordinator[XboxData]): """Update list of Xbox consoles.""" diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 424ee58ffcf..dcb68dba9ae 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -148,10 +148,12 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity): @property def media_content_type(self) -> MediaType: """Media content type.""" - app_details = self.data.app_details - if app_details and app_details.product_family == "Games": - return MediaType.GAME - return MediaType.APP + + return ( + MediaType.GAME + if self.data.app_details and self.data.app_details.product_family == "Games" + else MediaType.APP + ) @property def media_content_id(self) -> str | None: @@ -161,11 +163,13 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - if not (app_details := self.data.app_details): - return None return ( - app_details.localized_properties[0].product_title - or app_details.localized_properties[0].short_title + ( + app_details.localized_properties[0].product_title + or app_details.localized_properties[0].short_title + ) + if (app_details := self.data.app_details) + else None ) @property diff --git a/tests/components/xbox/fixtures/catalog_product_lookup_game.json b/tests/components/xbox/fixtures/catalog_product_lookup_game.json new file mode 100644 index 00000000000..13bbea623d6 --- /dev/null +++ b/tests/components/xbox/fixtures/catalog_product_lookup_game.json @@ -0,0 +1,37 @@ +{ + "BigIds": ["9WZDNCRFJ3TJ"], + "HasMorePages": false, + "Products": [ + { + "LocalizedProperties": [ + { + "Images": [ + { + "FileId": "2000000000037288315", + "EISListingIdentifier": null, + "BackgroundColor": "", + "Caption": "", + "FileSizeInBytes": 2514491, + "ForegroundColor": "", + "Height": 1080, + "ImagePositionInfo": "", + "ImagePurpose": "FeaturePromotionalSquareArt", + "UnscaledImageSHA256Hash": "i7GhC2HdYXf69+X/mLHtm8B36zR1gEOfuG2g2bXAAnY=", + "Uri": "//store-images.s-microsoft.com/image/apps.45451.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.3abf2cc3-00cc-417d-a93d-97110cdfb261", + "Width": 1080 + } + ], + "ProductTitle": "Blue Dragon" + } + ], + "MarketProperties": [], + "ProductBSchema": "ProductGame;1", + "ProductId": "C2HGK9J5367F", + "PartD": "", + "ProductFamily": "Games", + "ProductKind": "Game", + "DisplaySkuAvailabilities": [] + } + ], + "TotalResultCount": 1 +} diff --git a/tests/components/xbox/fixtures/catalog_product_lookup_livetv.json b/tests/components/xbox/fixtures/catalog_product_lookup_livetv.json new file mode 100644 index 00000000000..8bc05e19d43 --- /dev/null +++ b/tests/components/xbox/fixtures/catalog_product_lookup_livetv.json @@ -0,0 +1,68 @@ +{ + "BigIds": ["9VWGNH0VBZJX"], + "HasMorePages": false, + "Products": [ + { + "LastModifiedDate": "2014-10-21T17:30:01.0000000+00:00", + "LocalizedProperties": [ + { + "Images": [ + { + "FileSizeInBytes": 0, + "Height": 1080, + "ImagePositionInfo": "Xbox", + "ImagePurpose": "FeaturePromotionalSquareArt", + "SortOrder": "0", + "Uri": "https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-", + "Width": 1080 + } + ], + "Language": "en-US", + "PublisherName": "Microsoft", + "SearchTitles": [ + { "SearchTitleString": "TV", "SearchTitleType": "SearchHint" } + ], + "ShortTitle": "TV", + "SortTitle": "TV", + "Videos": [], + "VoiceTitle": "TV", + "ProductDescription": "", + "ProductTitle": "TV", + "Markets": [] + } + ], + "MarketProperties": [], + "ProductASchema": "Product;3", + "ProductBSchema": "ProductUnifiedApp;3", + "ProductId": "9VWGNH0VBZJX", + "Properties": { + "AllowedUrls": [], + "Categories": ["Video"], + "PublisherId": "", + "SkuDisplayGroups": [], + "RevisionId": "2014-10-21T17:30:01.0000000+00:00" + }, + "AlternateIds": [ + { + "IdType": "LegacyXboxProductId", + "Value": "71e7df12-89e0-4dc7-a5ff-a182fc2df94f" + }, + { "IdType": "XboxTitleId", "Value": "371594669" } + ], + "DomainDataVersion": "", + "IngestionSource": "Bingbox App", + "IsMicrosoftProduct": false, + "ProductType": "Application", + "ValidationData": null, + "MerchandizingTags": null, + "SandboxId": "RETAIL", + "ProductFamily": "Apps", + "SchemaVersion": "1", + "IsSandboxedProduct": true, + "ProductKind": "Application", + "ProductPolicies": {}, + "DisplaySkuAvailabilities": [] + } + ], + "TotalResultCount": 2 +} diff --git a/tests/components/xbox/fixtures/people_friends_own.json b/tests/components/xbox/fixtures/people_friends_own.json index a19fb9eb924..0466df0cd99 100644 --- a/tests/components/xbox/fixtures/people_friends_own.json +++ b/tests/components/xbox/fixtures/people_friends_own.json @@ -118,7 +118,7 @@ }, { "xuid": "2533274913657542", - "isFavorite": true, + "isFavorite": false, "isFollowingCaller": true, "isFollowedByCaller": true, "isIdentityShared": false, diff --git a/tests/components/xbox/fixtures/smartglass_console_status_idle.json b/tests/components/xbox/fixtures/smartglass_console_status_idle.json new file mode 100644 index 00000000000..c5a48cbf843 --- /dev/null +++ b/tests/components/xbox/fixtures/smartglass_console_status_idle.json @@ -0,0 +1,14 @@ +{ + "status": { + "errorCode": "OK", + "errorMessage": null + }, + "powerState": "On", + "playbackState": "Stopped", + "loginState": null, + "focusAppAumid": "", + "isTvConfigured": true, + "digitalAssistantRemoteControlEnabled": true, + "consoleStreamingEnabled": false, + "remoteManagementEnabled": true +} diff --git a/tests/components/xbox/fixtures/smartglass_console_status_livetv.json b/tests/components/xbox/fixtures/smartglass_console_status_livetv.json new file mode 100644 index 00000000000..d66d1cf05af --- /dev/null +++ b/tests/components/xbox/fixtures/smartglass_console_status_livetv.json @@ -0,0 +1,14 @@ +{ + "status": { + "errorCode": "OK", + "errorMessage": null + }, + "powerState": "On", + "playbackState": "Stopped", + "loginState": null, + "focusAppAumid": "Microsoft.Xbox.LiveTV_8wekyb3d8bbwe!Microsoft.Xbox.LiveTV.Application", + "isTvConfigured": true, + "digitalAssistantRemoteControlEnabled": true, + "consoleStreamingEnabled": false, + "remoteManagementEnabled": true +} diff --git a/tests/components/xbox/snapshots/test_diagnostics.ambr b/tests/components/xbox/snapshots/test_diagnostics.ambr index bf34129860c..4937e24ef0f 100644 --- a/tests/components/xbox/snapshots/test_diagnostics.ambr +++ b/tests/components/xbox/snapshots/test_diagnostics.ambr @@ -9588,7 +9588,7 @@ 'gamertag': '**REDACTED**', 'is_broadcasting': False, 'is_cloaked': None, - 'is_favorite': True, + 'is_favorite': False, 'is_followed_by_caller': True, 'is_following_caller': True, 'is_friend': True, diff --git a/tests/components/xbox/snapshots/test_media_player.ambr b/tests/components/xbox/snapshots/test_media_player.ambr index 3a5564af8ef..45a90e6bd0f 100644 --- a/tests/components/xbox/snapshots/test_media_player.ambr +++ b/tests/components/xbox/snapshots/test_media_player.ambr @@ -124,7 +124,7 @@ 'title': 'Installed Applications', }) # --- -# name: test_media_players[media_player.xone-entry] +# name: test_media_players[app][media_player.xone-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -161,7 +161,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_media_players[media_player.xone-state] +# name: test_media_players[app][media_player.xone-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279', @@ -180,7 +180,7 @@ 'state': 'on', }) # --- -# name: test_media_players[media_player.xonex-entry] +# name: test_media_players[app][media_player.xonex-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -217,7 +217,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_media_players[media_player.xonex-state] +# name: test_media_players[app][media_player.xonex-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279', @@ -236,3 +236,221 @@ 'state': 'on', }) # --- +# name: test_media_players[idle][media_player.xone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.xone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'xbox', + 'unique_id': 'HIJKLMN', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_players[idle][media_player.xone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture_local': None, + 'friendly_name': 'XONE', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.xone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_media_players[idle][media_player.xonex-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.xonex', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'xbox', + 'unique_id': 'ABCDEFG', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_players[idle][media_player.xonex-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture_local': None, + 'friendly_name': 'XONEX', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.xonex', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_media_players[livetvapp][media_player.xone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.xone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'xbox', + 'unique_id': 'HIJKLMN', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_players[livetvapp][media_player.xone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-', + 'entity_picture_local': '/api/media_player_proxy/media_player.xone?token=mock_token&cache=cf419ddd9fb966d6', + 'friendly_name': 'XONE', + 'media_content_id': '9VWGNH0VBZJX', + 'media_content_type': , + 'media_title': 'TV', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.xone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_media_players[livetvapp][media_player.xonex-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.xonex', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'xbox', + 'unique_id': 'ABCDEFG', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_players[livetvapp][media_player.xonex-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-', + 'entity_picture_local': '/api/media_player_proxy/media_player.xonex?token=mock_token&cache=cf419ddd9fb966d6', + 'friendly_name': 'XONEX', + 'media_content_id': '9VWGNH0VBZJX', + 'media_content_type': , + 'media_title': 'TV', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.xonex', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index bb20281ccf6..314ecc685f5 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -2,8 +2,9 @@ from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from httpx import HTTPStatusError, RequestError, TimeoutException import pytest from pythonxbox.api.provider.people.models import PeopleResponse @@ -22,7 +23,10 @@ from homeassistant.config_entries import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo @@ -576,7 +580,10 @@ async def test_add_friend_flow_config_entry_not_loaded( @pytest.mark.usefixtures("xbox_live_client", "authentication_manager") -async def test_unique_id_and_friends_migration(hass: HomeAssistant) -> None: +async def test_unique_id_and_friends_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test config entry unique_id migration and favorite to subentry migration.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -601,6 +608,17 @@ async def test_unique_id_and_friends_migration(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) + device_own = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "xbox_live")}, + ) + + device_friend = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "2533274838782903")}, + ) + assert device_friend.config_entries_subentries[config_entry.entry_id] == {None} + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -611,14 +629,111 @@ async def test_unique_id_and_friends_migration(hass: HomeAssistant) -> None: assert config_entry.title == "GSR Ae" # Assert favorite friends migrated to subentries - assert len(config_entry.subentries) == 2 + assert len(config_entry.subentries) == 1 subentries = list(config_entry.subentries.values()) assert subentries[0].unique_id == "2533274838782903" assert subentries[0].title == "Ikken Hissatsuu" assert subentries[0].subentry_type == "friend" - assert subentries[1].unique_id == "2533274913657542" - assert subentries[1].title == "erics273" - assert subentries[1].subentry_type == "friend" + + ## Assert devices have been migrated + assert (device_own := device_registry.async_get(device_own.id)) + assert device_own.identifiers == {(DOMAIN, "271958441785640")} + + assert (device_friend := device_registry.async_get(device_friend.id)) + assert device_friend.config_entries_subentries[config_entry.entry_id] == { + subentries[0].subentry_id + } + + +@pytest.mark.parametrize( + ("provider", "method"), + [ + ("people", "get_friends_by_xuid"), + ("people", "get_friends_own"), + ], +) +@pytest.mark.parametrize( + "exception", + [ + TimeoutException(""), + RequestError("", request=Mock()), + HTTPStatusError("", request=Mock(), response=Mock()), + ], +) +@pytest.mark.usefixtures("authentication_manager") +async def test_migration_exceptions( + hass: HomeAssistant, + xbox_live_client: AsyncMock, + provider: str, + method: str, + exception: Exception, +) -> None: + """Test exceptions during migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant Cloud", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + 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 config_entries.ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.usefixtures("xbox_live_client", "authentication_manager") +async def test_migration_implementation_unavailable(hass: HomeAssistant) -> None: + """Test implementation unavailable exception during migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant Cloud", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.xbox.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR @pytest.mark.usefixtures( diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index a9ee9f53138..ebac15c8e91 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -1,7 +1,7 @@ """Tests for the Xbox integration.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory from httpx import ConnectTimeout, HTTPStatusError, ProtocolError @@ -42,7 +42,11 @@ async def test_entry_setup_unload( @pytest.mark.parametrize( "exception", - [ConnectTimeout, HTTPStatusError, ProtocolError], + [ + ConnectTimeout(""), + HTTPStatusError("", request=Mock(), response=Mock()), + ProtocolError(""), + ], ) async def test_config_entry_not_ready( hass: HomeAssistant, @@ -78,7 +82,14 @@ async def test_config_implementation_not_available( assert config_entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("exception", [ConnectTimeout, HTTPStatusError, ProtocolError]) +@pytest.mark.parametrize( + "exception", + [ + ConnectTimeout(""), + HTTPStatusError("", request=Mock(), response=Mock()), + ProtocolError(""), + ], +) @pytest.mark.parametrize( ("provider", "method"), [ diff --git a/tests/components/xbox/test_media_player.py b/tests/components/xbox/test_media_player.py index 08b678fdf61..f0c48763dca 100644 --- a/tests/components/xbox/test_media_player.py +++ b/tests/components/xbox/test_media_player.py @@ -7,6 +7,7 @@ from unittest.mock import patch from httpx import HTTPStatusError, RequestError, TimeoutException import pytest +from pythonxbox.api.provider.catalog.models import CatalogResponse from pythonxbox.api.provider.smartglass.models import ( SmartglassConsoleStatus, VolumeDirection, @@ -67,15 +68,37 @@ def mock_token() -> Generator[MagicMock]: yield token -@pytest.mark.usefixtures("xbox_live_client") +@pytest.mark.parametrize( + ("fixture_status", "fixture_catalog"), + [ + ("smartglass_console_status.json", "catalog_product_lookup.json"), + ("smartglass_console_status_idle.json", "catalog_product_lookup.json"), + ("smartglass_console_status_livetv.json", "catalog_product_lookup_livetv.json"), + ], + ids=["app", "idle", "livetvapp"], +) async def test_media_players( hass: HomeAssistant, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + xbox_live_client: AsyncMock, + fixture_status: str, + fixture_catalog: str | None, ) -> None: """Test setup of the Xbox media player platform.""" + xbox_live_client.smartglass.get_console_status.return_value = ( + SmartglassConsoleStatus( + **await async_load_json_object_fixture(hass, fixture_status, DOMAIN) # pyright: ignore[reportArgumentType] + ) + ) + xbox_live_client.catalog.get_product_from_alternate_id.return_value = ( + CatalogResponse( + **await async_load_json_object_fixture(hass, fixture_catalog, DOMAIN) # pyright: ignore[reportArgumentType] + ) + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -237,11 +260,11 @@ async def test_media_player_actions( ], ) @pytest.mark.parametrize( - "exception", + ("exception", "translation_key"), [ - TimeoutException(""), - RequestError("", request=Mock()), - HTTPStatusError("", request=Mock(), response=Mock()), + (TimeoutException(""), "timeout_exception"), + (RequestError("", request=Mock()), "request_exception"), + (HTTPStatusError("", request=Mock(), response=Mock()), "request_exception"), ], ) async def test_media_player_action_exceptions( @@ -252,6 +275,7 @@ async def test_media_player_action_exceptions( service_args: dict[str, Any], call_method: str, exception: Exception, + translation_key: str, ) -> None: """Test media player action exceptions.""" @@ -271,13 +295,14 @@ async def test_media_player_action_exceptions( getattr(xbox_live_client.smartglass, call_method).side_effect = exception - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, service, target={ATTR_ENTITY_ID: "media_player.xone", **service_args}, blocking=True, ) + assert e.value.translation_key == translation_key async def test_media_player_turn_on_failed( @@ -299,10 +324,11 @@ async def test_media_player_turn_on_failed( ), ) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_TURN_ON, target={ATTR_ENTITY_ID: "media_player.xone"}, blocking=True, ) + assert e.value.translation_key == "turn_on_failed" diff --git a/tests/components/xbox/test_media_source.py b/tests/components/xbox/test_media_source.py index aed4fb93524..c7491935bc0 100644 --- a/tests/components/xbox/test_media_source.py +++ b/tests/components/xbox/test_media_source.py @@ -1,6 +1,6 @@ """Tests for the Xbox media source platform.""" -import httpx +from httpx import HTTPStatusError, RequestError, Response, TimeoutException import pytest from pythonxbox.api.provider.people.models import PeopleResponse from syrupy.assertion import SnapshotAssertion @@ -173,14 +173,30 @@ async def test_browse_media_accounts( "titlehub", "get_title_info", ), + ( + "/271958441785640/1297287135/community_gameclips", + "gameclips", + "get_recent_community_clips_by_title_id", + ), + ( + "/271958441785640/1297287135/community_screenshots", + "screenshots", + "get_recent_community_screenshots_by_title_id", + ), ], ) @pytest.mark.parametrize( - "exception", + ("exception", "translation_key"), [ - httpx.HTTPStatusError("", request=MagicMock(), response=httpx.Response(500)), - httpx.RequestError(""), - httpx.TimeoutException(""), + ( + HTTPStatusError("", request=MagicMock(), response=Response(500)), + "request_exception", + ), + ( + RequestError(""), + "request_exception", + ), + (TimeoutException(""), "timeout_exception"), ], ) async def test_browse_media_exceptions( @@ -191,6 +207,7 @@ async def test_browse_media_exceptions( provider: str, method: str, exception: Exception, + translation_key: str, ) -> None: """Test browsing media exceptions.""" @@ -203,8 +220,9 @@ async def test_browse_media_exceptions( provider = getattr(xbox_live_client, provider) getattr(provider, method).side_effect = exception - with pytest.raises(BrowseError): + with pytest.raises(BrowseError) as e: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}{media_content_id}") + assert e.value.translation_key == translation_key @pytest.mark.usefixtures("xbox_live_client") @@ -239,8 +257,9 @@ async def test_browse_media_not_configured_exception( assert config_entry.state is ConfigEntryState.NOT_LOADED - with pytest.raises(BrowseError, match="The Xbox integration is not configured"): + with pytest.raises(BrowseError) as e: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert e.value.translation_key == "xbox_not_configured" @pytest.mark.usefixtures("xbox_live_client") @@ -256,8 +275,9 @@ async def test_browse_media_account_not_configured_exception( assert config_entry.state is ConfigEntryState.LOADED - with pytest.raises(BrowseError): + with pytest.raises(BrowseError) as e: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/2533274838782903") + assert e.value.translation_key == "account_not_configured" @pytest.mark.parametrize( @@ -278,8 +298,24 @@ async def test_browse_media_account_not_configured_exception( "https://store-images.s-microsoft.com/image/apps.35725.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.c4bf34f8-ad40-4af3-914e-a85e75a76bed", "image/png", ), + ( + "/271958441785640/1297287135/community_screenshots/504a78e5-be24-4020-a245-77cb528e91ea", + "https://screenshotscontent-d5002.media.xboxlive.com/xuid-2535422966774043-private/504a78e5-be24-4020-a245-77cb528e91ea.PNG?skoid=296fcea0-0bf0-4a22-abf7-16b3524eba1b&sktid=68cd85cc-e0b3-43c8-ba3c-67686dbf8a67&skt=2025-11-06T10%3A21%3A06Z&ske=2025-11-07T10%3A21%3A06Z&sks=b&skv=2025-05-05&sv=2025-05-05&st=2025-11-06T10%3A35%3A59Z&se=2125-11-06T10%3A50%3A59Z&sr=b&sp=r&sig=TqUUNeuAzHawaXBTFfSVuUzuXbGOMgrDu0Q2VBTFd5U%3D", + "image/png", + ), + ( + "/271958441785640/1297287135/community_gameclips/6fa2731a-8b58-4aa6-848c-4bf15734358b", + "https://gameclipscontent-d3021.media.xboxlive.com/xuid-2535458333395495-private/6fa2731a-8b58-4aa6-848c-4bf15734358b.MP4?skoid=2938738c-0e58-4f21-9b82-98081ade42e2&sktid=68cd85cc-e0b3-43c8-ba3c-67686dbf8a67&skt=2025-11-06T08%3A20%3A51Z&ske=2025-11-07T08%3A20%3A51Z&sks=b&skv=2025-05-05&sv=2025-05-05&st=2025-11-06T10%3A05%3A41Z&se=2125-11-06T10%3A20%3A41Z&sr=b&sp=r&sig=s%2FWDtmE2cnAwl9iJJFcch3knbRlkxkALoinHQwCnNP0%3D&__gda__=1762438393_eb8a56c3f482d00099045aa892a2aa05", + "video/mp4", + ), + ], + ids=[ + "screenshot", + "gameclips", + "game_media", + "community_screenshots", + "community_gameclips", ], - ids=["screenshot", "gameclips", "game_media"], ) @pytest.mark.usefixtures("xbox_live_client") async def test_resolve_media( @@ -319,6 +355,16 @@ async def test_resolve_media( "gameclips", "get_recent_clips_by_xuid", ), + ( + "/271958441785640/1297287135/community_screenshots/504a78e5-be24-4020-a245-77cb528e91ea", + "screenshots", + "get_recent_community_screenshots_by_title_id", + ), + ( + "/271958441785640/1297287135/community_gameclips/6fa2731a-8b58-4aa6-848c-4bf15734358b", + "gameclips", + "get_recent_community_clips_by_title_id", + ), ( "/271958441785640/1297287135/game_media/0", "titlehub", @@ -327,11 +373,14 @@ async def test_resolve_media( ], ) @pytest.mark.parametrize( - "exception", + ("exception", "translation_key"), [ - httpx.HTTPStatusError("", request=MagicMock(), response=httpx.Response(500)), - httpx.RequestError(""), - httpx.TimeoutException(""), + ( + HTTPStatusError("", request=MagicMock(), response=Response(500)), + "request_exception", + ), + (RequestError(""), "request_exception"), + (TimeoutException(""), "timeout_exception"), ], ) async def test_resolve_media_exceptions( @@ -342,6 +391,7 @@ async def test_resolve_media_exceptions( provider: str, method: str, exception: Exception, + translation_key: str, ) -> None: """Test resolve media exceptions.""" @@ -354,12 +404,13 @@ async def test_resolve_media_exceptions( provider = getattr(xbox_live_client, provider) getattr(provider, method).side_effect = exception - with pytest.raises(Unresolvable): + with pytest.raises(Unresolvable) as e: await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}{media_content_id}", None, ) + assert e.value.translation_key == translation_key @pytest.mark.parametrize(("media_type"), ["screenshots", "gameclips", "game_media"]) @@ -377,12 +428,13 @@ async def test_resolve_media_not_found_exceptions( assert config_entry.state is ConfigEntryState.LOADED - with pytest.raises(Unresolvable, match="The requested media could not be found"): + with pytest.raises(Unresolvable) as e: await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/271958441785640/1297287135/{media_type}/12345", None, ) + assert e.value.translation_key == "media_not_found" @pytest.mark.usefixtures("xbox_live_client") @@ -416,12 +468,13 @@ async def test_resolve_media_not_configured( assert config_entry.state is ConfigEntryState.NOT_LOADED - with pytest.raises(Unresolvable, match="The Xbox integration is not configured"): + with pytest.raises(Unresolvable) as e: await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/2533274838782903", None, ) + assert e.value.translation_key == "xbox_not_configured" @pytest.mark.usefixtures("xbox_live_client") @@ -437,9 +490,10 @@ async def test_resolve_media_account_not_configured( assert config_entry.state is ConfigEntryState.LOADED - with pytest.raises(Unresolvable, match="The Xbox account is not configured"): + with pytest.raises(Unresolvable) as e: await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/2533274838782903", None, ) + assert e.value.translation_key == "account_not_configured" diff --git a/tests/components/xbox/test_remote.py b/tests/components/xbox/test_remote.py index 99221b8f883..7f8474576b2 100644 --- a/tests/components/xbox/test_remote.py +++ b/tests/components/xbox/test_remote.py @@ -248,9 +248,7 @@ async def test_send_command_exceptions( assert config_entry.state is ConfigEntryState.LOADED getattr(xbox_live_client.smartglass, call_method).side_effect = exception - with pytest.raises( - HomeAssistantError, check=lambda e: e.translation_key == translation_key - ): + with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_SEND_COMMAND, @@ -258,6 +256,7 @@ async def test_send_command_exceptions( target={ATTR_ENTITY_ID: "remote.xone"}, blocking=True, ) + assert e.value.translation_key == translation_key @pytest.mark.parametrize( @@ -290,15 +289,14 @@ async def test_turn_on_exceptions( assert config_entry.state is ConfigEntryState.LOADED xbox_live_client.smartglass.wake_up.side_effect = exception - with pytest.raises( - HomeAssistantError, check=lambda e: e.translation_key == translation_key - ): + with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_ON, target={ATTR_ENTITY_ID: "remote.xone"}, blocking=True, ) + assert e.value.translation_key == translation_key @pytest.mark.parametrize( @@ -325,12 +323,11 @@ async def test_turn_off_exceptions( assert config_entry.state is ConfigEntryState.LOADED xbox_live_client.smartglass.turn_off.side_effect = exception - with pytest.raises( - HomeAssistantError, check=lambda e: e.translation_key == translation_key - ): + with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_OFF, target={ATTR_ENTITY_ID: "remote.xone"}, blocking=True, ) + assert e.value.translation_key == translation_key