diff --git a/homeassistant/components/dlna_dms/__init__.py b/homeassistant/components/dlna_dms/__init__.py index b09547e07c8..5cd6321a5df 100644 --- a/homeassistant/components/dlna_dms/__init__.py +++ b/homeassistant/components/dlna_dms/__init__.py @@ -8,14 +8,22 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import LOGGER +from .const import CONF_SOURCE_ID, LOGGER from .dms import get_domain_data +from .util import generate_source_id async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DLNA DMS device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.unique_id) + # Soft-migrate entry if it's missing data keys + if CONF_SOURCE_ID not in entry.data: + LOGGER.debug("Adding CONF_SOURCE_ID to entry %s", entry.data) + data = dict(entry.data) + data[CONF_SOURCE_ID] = generate_source_id(hass, entry.title) + hass.config_entries.async_update_entry(entry, data=data) + # Forward setup to this domain's data manager return await get_domain_data(hass).async_setup_entry(entry) diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index 7ae3a104fc1..bf310f7b234 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -13,17 +13,13 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.data_entry_flow import AbortFlow, FlowResult -from homeassistant.exceptions import IntegrationError -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_SOURCE_ID, CONFIG_VERSION, DEFAULT_NAME, DOMAIN +from .util import generate_source_id LOGGER = logging.getLogger(__name__) -class ConnectError(IntegrationError): - """Error occurred when trying to connect to a device.""" - - class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a DLNA DMS config flow. @@ -32,7 +28,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): the DMS is an embedded device. """ - VERSION = 1 + VERSION = CONFIG_VERSION def __init__(self) -> None: """Initialize flow.""" @@ -50,7 +46,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None and (host := user_input.get(CONF_HOST)): # User has chosen a device discovery = self._discoveries[host] - await self._async_parse_discovery(discovery) + await self._async_parse_discovery(discovery, raise_on_progress=False) return self._create_entry() if not (discoveries := await self._async_get_discoveries()): @@ -100,8 +96,6 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Allow the user to confirm adding the device.""" - LOGGER.debug("async_step_confirm: %s", user_input) - if user_input is not None: return self._create_entry() @@ -111,17 +105,24 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def _create_entry(self) -> FlowResult: """Create a config entry, assuming all required information is now known.""" LOGGER.debug( - "_async_create_entry: location: %s, USN: %s", self._location, self._usn + "_create_entry: name: %s, location: %s, USN: %s", + self._name, + self._location, + self._usn, ) assert self._name assert self._location assert self._usn - data = {CONF_URL: self._location, CONF_DEVICE_ID: self._usn} + data = { + CONF_URL: self._location, + CONF_DEVICE_ID: self._usn, + CONF_SOURCE_ID: generate_source_id(self.hass, self._name), + } return self.async_create_entry(title=self._name, data=data) async def _async_parse_discovery( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True ) -> None: """Get required details from an SSDP discovery. @@ -140,7 +141,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._location = discovery_info.ssdp_location self._usn = discovery_info.ssdp_usn - await self.async_set_unique_id(self._usn) + await self.async_set_unique_id(self._usn, raise_on_progress=raise_on_progress) # Abort if already configured, but update the last-known location self._abort_if_unique_id_configured( @@ -155,8 +156,6 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" - LOGGER.debug("_get_discoveries") - # Get all compatible devices from ssdp's cache discoveries: list[ssdp.SsdpServiceInfo] = [] for udn_st in DmsDevice.DEVICE_TYPES: diff --git a/homeassistant/components/dlna_dms/const.py b/homeassistant/components/dlna_dms/const.py index 8c260272d5f..5d1c887fd49 100644 --- a/homeassistant/components/dlna_dms/const.py +++ b/homeassistant/components/dlna_dms/const.py @@ -12,6 +12,9 @@ LOGGER = logging.getLogger(__package__) DOMAIN: Final = "dlna_dms" DEFAULT_NAME: Final = "DLNA Media Server" +CONF_SOURCE_ID: Final = "source_id" +CONFIG_VERSION: Final = 1 + SOURCE_SEP: Final = "/" ROOT_OBJECT_ID: Final = "0" PATH_SEP: Final = "/" diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 32125101974..d47f480132b 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -11,7 +11,6 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.client import UpnpRequester from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import NotificationSubType -from async_upnp_client.event_handler import UpnpEventHandler, UpnpNotifyServer from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, UpnpError from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite @@ -26,9 +25,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.util import slugify from .const import ( + CONF_SOURCE_ID, DLNA_BROWSE_FILTER, DLNA_PATH_FILTER, DLNA_RESOLVE_FILTER, @@ -51,10 +50,8 @@ class DlnaDmsData: """Storage class for domain global data.""" hass: HomeAssistant - lock: asyncio.Lock requester: UpnpRequester upnp_factory: UpnpFactory - event_handler: UpnpEventHandler devices: dict[str, DmsDeviceSource] # Indexed by config_entry.unique_id sources: dict[str, DmsDeviceSource] # Indexed by source_id @@ -64,69 +61,32 @@ class DlnaDmsData: ) -> None: """Initialize global data.""" self.hass = hass - self.lock = asyncio.Lock() session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) self.requester = AiohttpSessionRequester(session, with_sleep=True) self.upnp_factory = UpnpFactory(self.requester, non_strict=True) - # NOTE: event_handler is not actually used, and is only created to - # satisfy the DmsDevice.__init__ signature - self.event_handler = UpnpEventHandler(UpnpNotifyServer(), self.requester) self.devices = {} self.sources = {} async def async_setup_entry(self, config_entry: ConfigEntry) -> bool: """Create a DMS device connection from a config entry.""" assert config_entry.unique_id - async with self.lock: - source_id = self._generate_source_id(config_entry.title) - device = DmsDeviceSource(self.hass, config_entry, source_id) - self.devices[config_entry.unique_id] = device - self.sources[device.source_id] = device - - # Update the device when the associated config entry is modified - config_entry.async_on_unload( - config_entry.add_update_listener(self.async_update_entry) - ) - + device = DmsDeviceSource(self.hass, config_entry) + self.devices[config_entry.unique_id] = device + # source_id must be unique, which generate_source_id should guarantee. + # Ensure this is the case, for debugging purposes. + assert device.source_id not in self.sources + self.sources[device.source_id] = device await device.async_added_to_hass() return True async def async_unload_entry(self, config_entry: ConfigEntry) -> bool: """Unload a config entry and disconnect the corresponding DMS device.""" assert config_entry.unique_id - async with self.lock: - device = self.devices.pop(config_entry.unique_id) - del self.sources[device.source_id] + device = self.devices.pop(config_entry.unique_id) + del self.sources[device.source_id] await device.async_will_remove_from_hass() return True - async def async_update_entry( - self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> None: - """Update a DMS device when the config entry changes.""" - assert config_entry.unique_id - async with self.lock: - device = self.devices[config_entry.unique_id] - # Update the source_id to match the new name - del self.sources[device.source_id] - device.source_id = self._generate_source_id(config_entry.title) - self.sources[device.source_id] = device - - def _generate_source_id(self, name: str) -> str: - """Generate a unique source ID. - - Caller should hold self.lock when calling this method. - """ - source_id_base = slugify(name) - if source_id_base not in self.sources: - return source_id_base - - tries = 1 - while (suggested_source_id := f"{source_id_base}_{tries}") in self.sources: - tries += 1 - - return suggested_source_id - @callback def get_domain_data(hass: HomeAssistant) -> DlnaDmsData: @@ -202,12 +162,6 @@ def catch_request_errors( class DmsDeviceSource: """DMS Device wrapper, providing media files as a media_source.""" - hass: HomeAssistant - config_entry: ConfigEntry - - # Unique slug used for media-source URIs - source_id: str - # Last known URL for the device, used when adding this wrapper to hass to # try to connect before SSDP has rediscovered it, or when SSDP discovery # fails. @@ -222,13 +176,10 @@ class DmsDeviceSource: # Track BOOTID in SSDP advertisements for device changes _bootid: int | None = None - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, source_id: str - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize a DMS Source.""" self.hass = hass self.config_entry = config_entry - self.source_id = source_id self.location = self.config_entry.data[CONF_URL] self._device_lock = asyncio.Lock() @@ -336,16 +287,13 @@ class DmsDeviceSource: async def device_connect(self) -> None: """Connect to the device now that it's available.""" LOGGER.debug("Connecting to device at %s", self.location) + assert self.location async with self._device_lock: if self._device: LOGGER.debug("Trying to connect when device already connected") return - if not self.location: - LOGGER.debug("Not connecting because location is not known") - return - domain_data = get_domain_data(self.hass) # Connect to the base UPNP device @@ -354,7 +302,7 @@ class DmsDeviceSource: ) # Create profile wrapper - self._device = DmsDevice(upnp_device, domain_data.event_handler) + self._device = DmsDevice(upnp_device, event_handler=None) # Update state variables. We don't care if they change, so this is # only done once, here. @@ -396,13 +344,15 @@ class DmsDeviceSource: """Return a name for the media server.""" return self.config_entry.title + @property + def source_id(self) -> str: + """Return a unique ID (slug) for this source for people to use in URLs.""" + return self.config_entry.data[CONF_SOURCE_ID] + @property def icon(self) -> str | None: """Return an URL to an icon for the media server.""" - if not self._device: - return None - - return self._device.icon + return self._device.icon if self._device else None # MediaSource methods @@ -411,6 +361,8 @@ class DmsDeviceSource: LOGGER.debug("async_resolve_media(%s)", identifier) action, parameters = _parse_identifier(identifier) + assert action is not None, f"Invalid identifier: {identifier}" + if action is Action.OBJECT: return await self.async_resolve_object(parameters) @@ -418,11 +370,8 @@ class DmsDeviceSource: object_id = await self.async_resolve_path(parameters) return await self.async_resolve_object(object_id) - if action is Action.SEARCH: - return await self.async_resolve_search(parameters) - - LOGGER.debug("Invalid identifier %s", identifier) - raise Unresolvable(f"Invalid identifier {identifier}") + assert action is Action.SEARCH + return await self.async_resolve_search(parameters) async def async_browse_media(self, identifier: str | None) -> BrowseMediaSource: """Browse media.""" @@ -577,9 +526,6 @@ class DmsDeviceSource: children=children, ) - if media_source.children: - media_source.calculate_children_class() - return media_source def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia: @@ -648,9 +594,6 @@ class DmsDeviceSource: thumbnail=self._didl_thumbnail_url(item), ) - if media_source.children: - media_source.calculate_children_class() - return media_source def _didl_thumbnail_url(self, item: didl_lite.DidlObject) -> str | None: diff --git a/homeassistant/components/dlna_dms/util.py b/homeassistant/components/dlna_dms/util.py new file mode 100644 index 00000000000..74c5bd2e01b --- /dev/null +++ b/homeassistant/components/dlna_dms/util.py @@ -0,0 +1,27 @@ +"""Small utility functions for the dlna_dms integration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.util import slugify + +from .const import CONF_SOURCE_ID, DOMAIN + + +def generate_source_id(hass: HomeAssistant, name: str) -> str: + """Generate a unique source ID.""" + other_entries = hass.config_entries.async_entries(DOMAIN) + other_source_ids: set[str] = { + other_source_id + for entry in other_entries + if (other_source_id := entry.data.get(CONF_SOURCE_ID)) + } + + source_id_base = slugify(name) + if source_id_base not in other_source_ids: + return source_id_base + + tries = 1 + while (suggested_source_id := f"{source_id_base}_{tries}") in other_source_ids: + tries += 1 + + return suggested_source_id diff --git a/tests/components/dlna_dms/conftest.py b/tests/components/dlna_dms/conftest.py index 8c1a6f3dc43..4dcd135ea86 100644 --- a/tests/components/dlna_dms/conftest.py +++ b/tests/components/dlna_dms/conftest.py @@ -1,18 +1,23 @@ """Fixtures for DLNA DMS tests.""" from __future__ import annotations -from collections.abc import AsyncGenerator, Iterable -from typing import Final +from collections.abc import AsyncIterable, Iterable +from typing import Final, cast from unittest.mock import Mock, create_autospec, patch, seal from async_upnp_client.client import UpnpDevice, UpnpService from async_upnp_client.utils import absolute_url import pytest -from homeassistant.components.dlna_dms.const import DOMAIN -from homeassistant.components.dlna_dms.dms import DlnaDmsData, get_domain_data +from homeassistant.components.dlna_dms.const import ( + CONF_SOURCE_ID, + CONFIG_VERSION, + DOMAIN, +) +from homeassistant.components.dlna_dms.dms import DlnaDmsData from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -31,6 +36,12 @@ EVENT_CALLBACK_URL: Final = "http://192.88.99.1/notify" NEW_DEVICE_LOCATION: Final = "http://192.88.99.7" + "/dmr_description.xml" +@pytest.fixture +async def setup_media_source(hass) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + @pytest.fixture def upnp_factory_mock() -> Iterable[Mock]: """Mock the UpnpFactory class to construct DMS-style UPnP devices.""" @@ -69,21 +80,13 @@ def upnp_factory_mock() -> Iterable[Mock]: yield upnp_factory_instance -@pytest.fixture -async def domain_data_mock( - hass: HomeAssistant, aioclient_mock, upnp_factory_mock -) -> AsyncGenerator[DlnaDmsData, None]: - """Mock some global data used by this component. - - This includes network clients and library object factories. Mocking it - prevents network use. - - Yields the actual domain data, for ease of access - """ +@pytest.fixture(autouse=True, scope="module") +def aiohttp_session_requester_mock() -> Iterable[Mock]: + """Mock the AiohttpSessionRequester to prevent network use.""" with patch( "homeassistant.components.dlna_dms.dms.AiohttpSessionRequester", autospec=True - ): - yield get_domain_data(hass) + ) as requester_mock: + yield requester_mock @pytest.fixture @@ -92,9 +95,11 @@ def config_entry_mock() -> MockConfigEntry: mock_entry = MockConfigEntry( unique_id=MOCK_DEVICE_USN, domain=DOMAIN, + version=CONFIG_VERSION, data={ CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_USN, + CONF_SOURCE_ID: MOCK_SOURCE_ID, }, title=MOCK_DEVICE_NAME, ) @@ -129,3 +134,40 @@ def ssdp_scanner_mock() -> Iterable[Mock]: reg_callback = mock_scanner.return_value.async_register_callback reg_callback.return_value = Mock(return_value=None) yield mock_scanner.return_value + + +@pytest.fixture +async def device_source_mock( + hass: HomeAssistant, + config_entry_mock: MockConfigEntry, + ssdp_scanner_mock: Mock, + dms_device_mock: Mock, +) -> AsyncIterable[None]: + """Fixture to set up a DmsDeviceSource in a connected state and cleanup at completion.""" + config_entry_mock.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_mock.entry_id) + await hass.async_block_till_done() + + # Check the DmsDeviceSource has registered all needed listeners + assert len(config_entry_mock.update_listeners) == 0 + assert ssdp_scanner_mock.async_register_callback.await_count == 2 + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 + + # Run the test + yield None + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Check DmsDeviceSource has cleaned up its resources + assert not config_entry_mock.update_listeners + assert ( + ssdp_scanner_mock.async_register_callback.await_count + == ssdp_scanner_mock.async_register_callback.return_value.call_count + ) + + domain_data = cast(DlnaDmsData, hass.data[DOMAIN]) + assert MOCK_DEVICE_USN not in domain_data.devices + assert MOCK_SOURCE_ID not in domain_data.sources diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 72e42e9102e..521c3169aa5 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -1,16 +1,17 @@ """Test the DLNA DMS config flow.""" from __future__ import annotations +from collections.abc import Iterable import dataclasses from typing import Final -from unittest.mock import Mock +from unittest.mock import Mock, patch from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp -from homeassistant.components.dlna_dms.const import DOMAIN +from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant @@ -21,17 +22,12 @@ from .conftest import ( MOCK_DEVICE_TYPE, MOCK_DEVICE_UDN, MOCK_DEVICE_USN, + MOCK_SOURCE_ID, NEW_DEVICE_LOCATION, ) from tests.common import MockConfigEntry -# Auto-use the domain_data_mock and dms_device_mock fixtures for every test in this module -pytestmark = [ - pytest.mark.usefixtures("domain_data_mock"), - pytest.mark.usefixtures("dms_device_mock"), -] - WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE" @@ -68,6 +64,16 @@ MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo( ) +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Iterable[Mock]: + """Avoid setting up the entire integration.""" + with patch( + "homeassistant.components.dlna_dms.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + async def test_user_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: """Test user-init'd flow, user selects discovered device.""" ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ @@ -87,17 +93,17 @@ async def test_user_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: MOCK_DEVICE_HOST} ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_USN, + CONF_SOURCE_ID: MOCK_SOURCE_ID, } assert result["options"] == {} - await hass.async_block_till_done() - async def test_user_flow_no_devices( hass: HomeAssistant, ssdp_scanner_mock: Mock @@ -137,12 +143,13 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None: assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_USN, + CONF_SOURCE_ID: MOCK_SOURCE_ID, } assert result["options"] == {} async def test_ssdp_flow_unavailable( - hass: HomeAssistant, domain_data_mock: Mock + hass: HomeAssistant, upnp_factory_mock: Mock ) -> None: """Test that SSDP discovery with an unavailable device still succeeds. @@ -157,7 +164,7 @@ async def test_ssdp_flow_unavailable( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "confirm" - domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + upnp_factory_mock.async_create_device.side_effect = UpnpError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -169,6 +176,7 @@ async def test_ssdp_flow_unavailable( assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_USN, + CONF_SOURCE_ID: MOCK_SOURCE_ID, } assert result["options"] == {} @@ -213,9 +221,7 @@ async def test_ssdp_flow_duplicate_location( assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION -async def test_ssdp_flow_bad_data( - hass: HomeAssistant, config_entry_mock: MockConfigEntry -) -> None: +async def test_ssdp_flow_bad_data(hass: HomeAssistant) -> None: """Test bad SSDP discovery information is rejected cleanly.""" # Missing location discovery = dataclasses.replace(MOCK_DISCOVERY, ssdp_location="") @@ -241,15 +247,16 @@ async def test_ssdp_flow_bad_data( async def test_duplicate_name( hass: HomeAssistant, config_entry_mock: MockConfigEntry ) -> None: - """Test device with name same as another results in no error.""" + """Test device with name same as other devices results in no error.""" + # Add two entries to test generate_source_id() tries for no collisions config_entry_mock.add_to_hass(hass) - mock_entry_1 = MockConfigEntry( unique_id="mock_entry_1", domain=DOMAIN, data={ CONF_URL: "not-important", CONF_DEVICE_ID: "not-important", + CONF_SOURCE_ID: f"{MOCK_SOURCE_ID}_1", }, title=MOCK_DEVICE_NAME, ) @@ -286,6 +293,7 @@ async def test_duplicate_name( assert result["data"] == { CONF_URL: new_device_location, CONF_DEVICE_ID: new_device_usn, + CONF_SOURCE_ID: f"{MOCK_SOURCE_ID}_2", } assert result["options"] == {} diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index a0cfb3ab2d2..67ad1024709 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -4,81 +4,56 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterable import logging -from unittest.mock import ANY, DEFAULT, Mock, patch +from typing import Final +from unittest.mock import ANY, DEFAULT, Mock from async_upnp_client.exceptions import UpnpConnectionError, UpnpError from didl_lite import didl_lite import pytest -from homeassistant.components import ssdp +from homeassistant.components import media_source, ssdp from homeassistant.components.dlna_dms.const import DOMAIN -from homeassistant.components.dlna_dms.dms import DmsDeviceSource, get_domain_data +from homeassistant.components.dlna_dms.dms import get_domain_data from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_source.error import Unresolvable from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .conftest import ( MOCK_DEVICE_LOCATION, - MOCK_DEVICE_NAME, MOCK_DEVICE_TYPE, MOCK_DEVICE_UDN, MOCK_DEVICE_USN, + MOCK_SOURCE_ID, NEW_DEVICE_LOCATION, ) from tests.common import MockConfigEntry -# Auto-use the domain_data_mock for every test in this module +DUMMY_OBJECT_ID: Final = "123" + +# Auto-use a few fixtures from conftest pytestmark = [ - pytest.mark.usefixtures("domain_data_mock"), + # Block network access + pytest.mark.usefixtures("aiohttp_session_requester_mock"), + pytest.mark.usefixtures("dms_device_mock"), + # Setup the media_source platform + pytest.mark.usefixtures("setup_media_source"), ] -async def setup_mock_component( - hass: HomeAssistant, mock_entry: MockConfigEntry -) -> DmsDeviceSource: - """Set up a mock DlnaDmrEntity with the given configuration.""" - mock_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) is True - await hass.async_block_till_done() - - domain_data = get_domain_data(hass) - return next(iter(domain_data.devices.values())) - - @pytest.fixture async def connected_source_mock( - hass: HomeAssistant, - config_entry_mock: MockConfigEntry, - ssdp_scanner_mock: Mock, - dms_device_mock: Mock, -) -> AsyncIterable[DmsDeviceSource]: - """Fixture to set up a mock DmsDeviceSource in a connected state. - - Yields the entity. Cleans up the entity after the test is complete. - """ - entity = await setup_mock_component(hass, config_entry_mock) - - # Check the entity has registered all needed listeners - assert len(config_entry_mock.update_listeners) == 1 - assert ssdp_scanner_mock.async_register_callback.await_count == 2 - assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 - - # Run the test - yield entity - - # Unload config entry to clean up - assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { - "require_restart": False - } - - # Check entity has cleaned up its resources - assert not config_entry_mock.update_listeners - assert ( - ssdp_scanner_mock.async_register_callback.await_count - == ssdp_scanner_mock.async_register_callback.return_value.call_count + dms_device_mock: Mock, device_source_mock: None +) -> None: + """Fixture to set up a mock DmsDeviceSource in a connected state.""" + # Make async_browse_metadata work for assert_source_available + didl_item = didl_lite.Item( + id=DUMMY_OBJECT_ID, + restricted=False, + title="Object", + res=[didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:")], ) + dms_device_mock.async_browse_metadata.return_value = didl_item @pytest.fixture @@ -88,30 +63,39 @@ async def disconnected_source_mock( config_entry_mock: MockConfigEntry, ssdp_scanner_mock: Mock, dms_device_mock: Mock, -) -> AsyncIterable[DmsDeviceSource]: - """Fixture to set up a mock DmsDeviceSource in a disconnected state. - - Yields the entity. Cleans up the entity after the test is complete. - """ +) -> AsyncIterable[None]: + """Fixture to set up a mock DmsDeviceSource in a disconnected state.""" # Cause the connection attempt to fail upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError - entity = await setup_mock_component(hass, config_entry_mock) + config_entry_mock.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_mock.entry_id) + await hass.async_block_till_done() - # Check the entity has registered all needed listeners - assert len(config_entry_mock.update_listeners) == 1 + # Check the DmsDeviceSource has registered all needed listeners + assert len(config_entry_mock.update_listeners) == 0 assert ssdp_scanner_mock.async_register_callback.await_count == 2 assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 + # Make async_browse_metadata work for assert_source_available when this + # source is connected + didl_item = didl_lite.Item( + id=DUMMY_OBJECT_ID, + restricted=False, + title="Object", + res=[didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:")], + ) + dms_device_mock.async_browse_metadata.return_value = didl_item + # Run the test - yield entity + yield # Unload config entry to clean up assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { "require_restart": False } - # Check entity has cleaned up its resources + # Check device source has cleaned up its resources assert not config_entry_mock.update_listeners assert ( ssdp_scanner_mock.async_register_callback.await_count @@ -119,24 +103,28 @@ async def disconnected_source_mock( ) +async def assert_source_available(hass: HomeAssistant) -> None: + """Assert that the DmsDeviceSource under test can be used.""" + assert await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{DUMMY_OBJECT_ID}" + ) + + +async def assert_source_unavailable(hass: HomeAssistant) -> None: + """Assert that the DmsDeviceSource under test cannot be used.""" + with pytest.raises(Unresolvable, match="DMS is not connected"): + await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{DUMMY_OBJECT_ID}" + ) + + async def test_unavailable_device( hass: HomeAssistant, upnp_factory_mock: Mock, ssdp_scanner_mock: Mock, - config_entry_mock: MockConfigEntry, + disconnected_source_mock: None, ) -> None: """Test a DlnaDmsEntity with out a connected DmsDevice.""" - # Cause connection attempts to fail - upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError - - with patch( - "homeassistant.components.dlna_dms.dms.DmsDevice", autospec=True - ) as dms_device_constructor_mock: - connected_source_mock = await setup_mock_component(hass, config_entry_mock) - - # Check device is not created - dms_device_constructor_mock.assert_not_called() - # Check attempt was made to create a device from the supplied URL upnp_factory_mock.async_create_device.assert_awaited_once_with(MOCK_DEVICE_LOCATION) # Check SSDP notifications are registered @@ -147,46 +135,42 @@ async def test_unavailable_device( ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} ) # Quick check of the state to verify the entity has no connected DmsDevice - assert not connected_source_mock.available - # Check the name matches that supplied - assert connected_source_mock.name == MOCK_DEVICE_NAME + await assert_source_unavailable(hass) # Check attempts to browse and resolve media give errors - with pytest.raises(BrowseError): - await connected_source_mock.async_browse_media("/browse_path") - with pytest.raises(BrowseError): - await connected_source_mock.async_browse_media(":browse_object") - with pytest.raises(BrowseError): - await connected_source_mock.async_browse_media("?browse_search") + with pytest.raises(BrowseError, match="DMS is not connected"): + await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//browse_path" + ) + with pytest.raises(BrowseError, match="DMS is not connected"): + await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:browse_object" + ) + with pytest.raises(BrowseError, match="DMS is not connected"): + await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?browse_search" + ) + with pytest.raises(Unresolvable, match="DMS is not connected"): + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//resolve_path" + ) + with pytest.raises(Unresolvable, match="DMS is not connected"): + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:resolve_object" + ) with pytest.raises(Unresolvable): - await connected_source_mock.async_resolve_media("/resolve_path") - with pytest.raises(Unresolvable): - await connected_source_mock.async_resolve_media(":resolve_object") - with pytest.raises(Unresolvable): - await connected_source_mock.async_resolve_media("?resolve_search") - - # Unload config entry to clean up - assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { - "require_restart": False - } - - # Confirm SSDP notifications unregistered - assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search" + ) async def test_become_available( hass: HomeAssistant, upnp_factory_mock: Mock, ssdp_scanner_mock: Mock, - config_entry_mock: MockConfigEntry, - dms_device_mock: Mock, + disconnected_source_mock: None, ) -> None: """Test a device becoming available after the entity is constructed.""" - # Cause connection attempts to fail before adding the entity - upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError - connected_source_mock = await setup_mock_component(hass, config_entry_mock) - assert not connected_source_mock.available - # Mock device is now available. upnp_factory_mock.async_create_device.side_effect = None upnp_factory_mock.async_create_device.reset_mock() @@ -207,22 +191,14 @@ async def test_become_available( # Check device was created from the supplied URL upnp_factory_mock.async_create_device.assert_awaited_once_with(NEW_DEVICE_LOCATION) # Quick check of the state to verify the entity has a connected DmsDevice - assert connected_source_mock.available - - # Unload config entry to clean up - assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { - "require_restart": False - } - - # Confirm SSDP notifications unregistered - assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + await assert_source_available(hass) async def test_alive_but_gone( hass: HomeAssistant, upnp_factory_mock: Mock, ssdp_scanner_mock: Mock, - disconnected_source_mock: DmsDeviceSource, + disconnected_source_mock: None, ) -> None: """Test a device sending an SSDP alive announcement, but not being connectable.""" upnp_factory_mock.async_create_device.side_effect = UpnpError @@ -245,7 +221,7 @@ async def test_alive_but_gone( upnp_factory_mock.async_create_device.assert_awaited() # Device should still be unavailable - assert not disconnected_source_mock.available + await assert_source_unavailable(hass) # Send the same SSDP notification, expecting no extra connection attempts upnp_factory_mock.async_create_device.reset_mock() @@ -262,7 +238,7 @@ async def test_alive_but_gone( await hass.async_block_till_done() upnp_factory_mock.async_create_device.assert_not_called() upnp_factory_mock.async_create_device.assert_not_awaited() - assert not disconnected_source_mock.available + await assert_source_unavailable(hass) # Send an SSDP notification with a new BOOTID, indicating the device has rebooted upnp_factory_mock.async_create_device.reset_mock() @@ -280,7 +256,7 @@ async def test_alive_but_gone( # Rebooted device (seen via BOOTID) should mean a new connection attempt upnp_factory_mock.async_create_device.assert_awaited() - assert not disconnected_source_mock.available + await assert_source_unavailable(hass) # Send byebye message to indicate device is going away. Next alive message # should result in a reconnect attempt even with same BOOTID. @@ -307,14 +283,14 @@ async def test_alive_but_gone( # Rebooted device (seen via byebye/alive) should mean a new connection attempt upnp_factory_mock.async_create_device.assert_awaited() - assert not disconnected_source_mock.available + await assert_source_unavailable(hass) async def test_multiple_ssdp_alive( hass: HomeAssistant, upnp_factory_mock: Mock, ssdp_scanner_mock: Mock, - disconnected_source_mock: DmsDeviceSource, + disconnected_source_mock: None, ) -> None: """Test multiple SSDP alive notifications is ok, only connects to device once.""" upnp_factory_mock.async_create_device.reset_mock() @@ -356,13 +332,13 @@ async def test_multiple_ssdp_alive( upnp_factory_mock.async_create_device.assert_awaited_once_with(NEW_DEVICE_LOCATION) # Device should be available - assert disconnected_source_mock.available + await assert_source_available(hass) async def test_ssdp_byebye( hass: HomeAssistant, ssdp_scanner_mock: Mock, - connected_source_mock: DmsDeviceSource, + connected_source_mock: None, ) -> None: """Test device is disconnected when byebye is received.""" # First byebye will cause a disconnect @@ -379,7 +355,7 @@ async def test_ssdp_byebye( ) # Device should be gone - assert not connected_source_mock.available + await assert_source_unavailable(hass) # Second byebye will do nothing await ssdp_callback( @@ -398,12 +374,11 @@ async def test_ssdp_update_seen_bootid( hass: HomeAssistant, ssdp_scanner_mock: Mock, upnp_factory_mock: Mock, - disconnected_source_mock: DmsDeviceSource, + disconnected_source_mock: None, ) -> None: """Test device does not reconnect when it gets ssdp:update with next bootid.""" # Start with a disconnected device - entity = disconnected_source_mock - assert not entity.available + await assert_source_unavailable(hass) # "Reconnect" the device upnp_factory_mock.async_create_device.reset_mock() @@ -424,7 +399,7 @@ async def test_ssdp_update_seen_bootid( await hass.async_block_till_done() # Device should be connected - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send SSDP update with next boot ID @@ -445,7 +420,7 @@ async def test_ssdp_update_seen_bootid( await hass.async_block_till_done() # Device was not reconnected, even with a new boot ID - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send SSDP update with same next boot ID, again @@ -466,7 +441,7 @@ async def test_ssdp_update_seen_bootid( await hass.async_block_till_done() # Nothing should change - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send SSDP update with bad next boot ID @@ -487,7 +462,7 @@ async def test_ssdp_update_seen_bootid( await hass.async_block_till_done() # Nothing should change - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send a new SSDP alive with the new boot ID, device should not reconnect @@ -503,7 +478,7 @@ async def test_ssdp_update_seen_bootid( ) await hass.async_block_till_done() - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 @@ -511,12 +486,11 @@ async def test_ssdp_update_missed_bootid( hass: HomeAssistant, ssdp_scanner_mock: Mock, upnp_factory_mock: Mock, - disconnected_source_mock: DmsDeviceSource, + disconnected_source_mock: None, ) -> None: """Test device disconnects when it gets ssdp:update bootid it wasn't expecting.""" # Start with a disconnected device - entity = disconnected_source_mock - assert not entity.available + await assert_source_unavailable(hass) # "Reconnect" the device upnp_factory_mock.async_create_device.reset_mock() @@ -537,7 +511,7 @@ async def test_ssdp_update_missed_bootid( await hass.async_block_till_done() # Device should be connected - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send SSDP update with skipped boot ID (not previously seen) @@ -558,7 +532,7 @@ async def test_ssdp_update_missed_bootid( await hass.async_block_till_done() # Device should not *re*-connect yet - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send a new SSDP alive with the new boot ID, device should reconnect @@ -574,7 +548,7 @@ async def test_ssdp_update_missed_bootid( ) await hass.async_block_till_done() - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 2 @@ -582,12 +556,11 @@ async def test_ssdp_bootid( hass: HomeAssistant, upnp_factory_mock: Mock, ssdp_scanner_mock: Mock, - disconnected_source_mock: DmsDeviceSource, + disconnected_source_mock: None, ) -> None: """Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect.""" # Start with a disconnected device - entity = disconnected_source_mock - assert not entity.available + await assert_source_unavailable(hass) # "Reconnect" the device upnp_factory_mock.async_create_device.side_effect = None @@ -607,7 +580,7 @@ async def test_ssdp_bootid( ) await hass.async_block_till_done() - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send SSDP alive with same boot ID, nothing should happen @@ -623,7 +596,7 @@ async def test_ssdp_bootid( ) await hass.async_block_till_done() - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 1 # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected @@ -639,44 +612,32 @@ async def test_ssdp_bootid( ) await hass.async_block_till_done() - assert entity.available + await assert_source_available(hass) assert upnp_factory_mock.async_create_device.await_count == 2 async def test_repeated_connect( caplog: pytest.LogCaptureFixture, - connected_source_mock: DmsDeviceSource, + hass: HomeAssistant, upnp_factory_mock: Mock, + connected_source_mock: None, ) -> None: """Test trying to connect an already connected device is safely ignored.""" upnp_factory_mock.async_create_device.reset_mock() - # Calling internal function directly to skip trying to time 2 SSDP messages carefully - with caplog.at_level(logging.DEBUG): - await connected_source_mock.device_connect() - assert ( - "Trying to connect when device already connected" == caplog.records[-1].message - ) - assert not upnp_factory_mock.async_create_device.await_count - -async def test_connect_no_location( - caplog: pytest.LogCaptureFixture, - disconnected_source_mock: DmsDeviceSource, - upnp_factory_mock: Mock, -) -> None: - """Test trying to connect without a location is safely ignored.""" - disconnected_source_mock.location = "" - upnp_factory_mock.async_create_device.reset_mock() # Calling internal function directly to skip trying to time 2 SSDP messages carefully + domain_data = get_domain_data(hass) + device_source = domain_data.sources[MOCK_SOURCE_ID] with caplog.at_level(logging.DEBUG): - await disconnected_source_mock.device_connect() - assert "Not connecting because location is not known" == caplog.records[-1].message + await device_source.device_connect() + assert not upnp_factory_mock.async_create_device.await_count + await assert_source_available(hass) async def test_become_unavailable( hass: HomeAssistant, - connected_source_mock: DmsDeviceSource, + connected_source_mock: None, dms_device_mock: Mock, ) -> None: """Test a device becoming unavailable.""" @@ -689,17 +650,18 @@ async def test_become_unavailable( ) # Check async_resolve_object currently works - await connected_source_mock.async_resolve_media(":object_id") + assert await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id" + ) # Now break the network connection dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError - # The device should be considered available until next contacted - assert connected_source_mock.available - # async_resolve_object should fail with pytest.raises(Unresolvable): - await connected_source_mock.async_resolve_media(":object_id") + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id" + ) # The device should now be unavailable - assert not connected_source_mock.available + await assert_source_unavailable(hass) diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index d6fcdb267d6..5e4021a5dda 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -1,5 +1,6 @@ -"""Test the interface methods of DmsDeviceSource, except availability.""" -from collections.abc import AsyncIterable +"""Test the browse and resolve methods of DmsDeviceSource.""" +from __future__ import annotations + from typing import Final, Union from unittest.mock import ANY, Mock, call @@ -8,215 +9,155 @@ from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite import pytest +from homeassistant.components import media_source, ssdp from homeassistant.components.dlna_dms.const import DLNA_SORT_CRITERIA, DOMAIN -from homeassistant.components.dlna_dms.dms import ( - ActionError, - DeviceConnectionError, - DlnaDmsData, - DmsDeviceSource, -) +from homeassistant.components.dlna_dms.dms import DidlPlayMedia from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_source.error import Unresolvable from homeassistant.components.media_source.models import BrowseMediaSource -from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant from .conftest import ( MOCK_DEVICE_BASE_URL, MOCK_DEVICE_NAME, MOCK_DEVICE_TYPE, + MOCK_DEVICE_UDN, MOCK_DEVICE_USN, MOCK_SOURCE_ID, ) -from tests.common import MockConfigEntry +# Auto-use a few fixtures from conftest +pytestmark = [ + # Block network access + pytest.mark.usefixtures("aiohttp_session_requester_mock"), + # Setup the media_source platform + pytest.mark.usefixtures("setup_media_source"), + # Have a connected device so that test can successfully call browse and resolve + pytest.mark.usefixtures("device_source_mock"), +] + BrowseResultList = list[Union[didl_lite.DidlObject, didl_lite.Descriptor]] -@pytest.fixture -async def device_source_mock( - hass: HomeAssistant, - config_entry_mock: MockConfigEntry, - ssdp_scanner_mock: Mock, - dms_device_mock: Mock, - domain_data_mock: DlnaDmsData, -) -> AsyncIterable[DmsDeviceSource]: - """Fixture to set up a DmsDeviceSource in a connected state and cleanup at completion.""" - await hass.config_entries.async_add(config_entry_mock) - await hass.async_block_till_done() - - mock_entity = domain_data_mock.devices[MOCK_DEVICE_USN] - - # Check the DmsDeviceSource has registered all needed listeners - assert len(config_entry_mock.update_listeners) == 1 - assert ssdp_scanner_mock.async_register_callback.await_count == 2 - assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 - - # Run the test - yield mock_entity - - # Unload config entry to clean up - assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { - "require_restart": False - } - - # Check DmsDeviceSource has cleaned up its resources - assert not config_entry_mock.update_listeners - assert ( - ssdp_scanner_mock.async_register_callback.await_count - == ssdp_scanner_mock.async_register_callback.return_value.call_count +async def async_resolve_media( + hass: HomeAssistant, media_content_id: str +) -> DidlPlayMedia: + """Call media_source.async_resolve_media with the test source's ID.""" + result = await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}" ) - assert MOCK_DEVICE_USN not in domain_data_mock.devices - assert MOCK_SOURCE_ID not in domain_data_mock.sources + assert isinstance(result, DidlPlayMedia) + return result -async def test_update_source_id( +async def async_browse_media( hass: HomeAssistant, - config_entry_mock: MockConfigEntry, - device_source_mock: DmsDeviceSource, - domain_data_mock: DlnaDmsData, -) -> None: - """Test the config listener updates the source_id and source list upon title change.""" - new_title: Final = "New Name" - new_source_id: Final = "new_name" - assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID} - hass.config_entries.async_update_entry(config_entry_mock, title=new_title) - await hass.async_block_till_done() - - assert device_source_mock.source_id == new_source_id - assert domain_data_mock.sources.keys() == {new_source_id} - - -async def test_update_existing_source_id( - caplog: pytest.LogCaptureFixture, - hass: HomeAssistant, - config_entry_mock: MockConfigEntry, - device_source_mock: DmsDeviceSource, - domain_data_mock: DlnaDmsData, -) -> None: - """Test the config listener gracefully handles colliding source_id.""" - new_title: Final = "New Name" - new_source_id: Final = "new_name" - new_source_id_2: Final = "new_name_1" - # Set up another config entry to collide with the new source_id - colliding_entry = MockConfigEntry( - unique_id=f"different-udn::{MOCK_DEVICE_TYPE}", - domain=DOMAIN, - data={ - CONF_URL: "http://192.88.99.22/dms_description.xml", - CONF_DEVICE_ID: f"different-udn::{MOCK_DEVICE_TYPE}", - }, - title=new_title, + media_content_id: str | None, +) -> BrowseMediaSource: + """Call media_source.async_browse_media with the test source's ID.""" + return await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}" ) - await hass.config_entries.async_add(colliding_entry) - await hass.async_block_till_done() - - assert device_source_mock.source_id == MOCK_SOURCE_ID - assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID, new_source_id} - assert domain_data_mock.sources[MOCK_SOURCE_ID] is device_source_mock - - # Update the existing entry to match the other entry's name - hass.config_entries.async_update_entry(config_entry_mock, title=new_title) - await hass.async_block_till_done() - - # The existing device's source ID should be a newly generated slug - assert device_source_mock.source_id == new_source_id_2 - assert domain_data_mock.sources.keys() == {new_source_id, new_source_id_2} - assert domain_data_mock.sources[new_source_id_2] is device_source_mock - - # Changing back to the old name should not cause issues - hass.config_entries.async_update_entry(config_entry_mock, title=MOCK_DEVICE_NAME) - await hass.async_block_till_done() - - assert device_source_mock.source_id == MOCK_SOURCE_ID - assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID, new_source_id} - assert domain_data_mock.sources[MOCK_SOURCE_ID] is device_source_mock - - # Remove the collision and try again - await hass.config_entries.async_remove(colliding_entry.entry_id) - assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID} - - hass.config_entries.async_update_entry(config_entry_mock, title=new_title) - await hass.async_block_till_done() - - assert device_source_mock.source_id == new_source_id - assert domain_data_mock.sources.keys() == {new_source_id} async def test_catch_request_error_unavailable( - device_source_mock: DmsDeviceSource, + hass: HomeAssistant, ssdp_scanner_mock: Mock ) -> None: """Test the device is checked for availability before trying requests.""" - device_source_mock._device = None + # DmsDevice notifies of disconnect via SSDP + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={"NTS": "ssdp:byebye"}, + ssdp_st=MOCK_DEVICE_TYPE, + upnp={}, + ), + ssdp.SsdpChange.BYEBYE, + ) - with pytest.raises(DeviceConnectionError): - await device_source_mock.async_resolve_object("id") - with pytest.raises(DeviceConnectionError): - await device_source_mock.async_resolve_path("path") - with pytest.raises(DeviceConnectionError): - await device_source_mock.async_resolve_search("query") - with pytest.raises(DeviceConnectionError): - await device_source_mock.async_browse_object("object_id") - with pytest.raises(DeviceConnectionError): - await device_source_mock.async_browse_search("query") + # All attempts to use the device should give an error + with pytest.raises(Unresolvable, match="DMS is not connected"): + # Resolve object + await async_resolve_media(hass, ":id") + with pytest.raises(Unresolvable, match="DMS is not connected"): + # Resolve path + await async_resolve_media(hass, "/path") + with pytest.raises(Unresolvable, match="DMS is not connected"): + # Resolve search + await async_resolve_media(hass, "?query") + with pytest.raises(BrowseError, match="DMS is not connected"): + # Browse object + await async_browse_media(hass, ":id") + with pytest.raises(BrowseError, match="DMS is not connected"): + # Browse path + await async_browse_media(hass, "/path") + with pytest.raises(BrowseError, match="DMS is not connected"): + # Browse search + await async_browse_media(hass, "?query") -async def test_catch_request_error( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_catch_request_error(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test errors when making requests to the device are handled.""" dms_device_mock.async_browse_metadata.side_effect = UpnpActionError( error_code=ContentDirectoryErrorCode.NO_SUCH_OBJECT ) - with pytest.raises(ActionError, match="No such object: bad_id"): - await device_source_mock.async_resolve_media(":bad_id") + with pytest.raises(Unresolvable, match="No such object: bad_id"): + await async_resolve_media(hass, ":bad_id") dms_device_mock.async_search_directory.side_effect = UpnpActionError( error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA ) - with pytest.raises(ActionError, match="Invalid query: bad query"): - await device_source_mock.async_resolve_media("?bad query") + with pytest.raises(Unresolvable, match="Invalid query: bad query"): + await async_resolve_media(hass, "?bad query") dms_device_mock.async_browse_metadata.side_effect = UpnpActionError( error_code=ContentDirectoryErrorCode.CANNOT_PROCESS_REQUEST ) - with pytest.raises(DeviceConnectionError, match="Server failure: "): - await device_source_mock.async_resolve_media(":good_id") + with pytest.raises(BrowseError, match="Server failure: "): + await async_resolve_media(hass, ":good_id") dms_device_mock.async_browse_metadata.side_effect = UpnpError with pytest.raises( - DeviceConnectionError, match="Server communication failure: UpnpError(.*)" + BrowseError, match="Server communication failure: UpnpError(.*)" ): - await device_source_mock.async_resolve_media(":bad_id") + await async_resolve_media(hass, ":bad_id") - # UpnpConnectionErrors will cause the device_source_mock to disconnect from the device - assert device_source_mock.available + +async def test_catch_upnp_connection_error( + hass: HomeAssistant, dms_device_mock: Mock +) -> None: + """Test UpnpConnectionError causes the device source to disconnect from the device.""" + # First check the source can be used + object_id = "foo" + didl_item = didl_lite.Item( + id=object_id, + restricted="false", + title="Object", + res=[didl_lite.Resource(uri="foo", protocol_info="http-get:*:audio/mpeg")], + ) + dms_device_mock.async_browse_metadata.return_value = didl_item + await async_browse_media(hass, f":{object_id}") + dms_device_mock.async_browse_metadata.assert_awaited_once_with( + object_id, metadata_filter=ANY + ) + + # Cause a UpnpConnectionError when next browsing dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError with pytest.raises( - DeviceConnectionError, match="Server disconnected: UpnpConnectionError(.*)" + BrowseError, match="Server disconnected: UpnpConnectionError(.*)" ): - await device_source_mock.async_resolve_media(":bad_id") - assert not device_source_mock.available + await async_browse_media(hass, f":{object_id}") + + # Clear the error, but the device should be disconnected + dms_device_mock.async_browse_metadata.side_effect = None + with pytest.raises(BrowseError, match="DMS is not connected"): + await async_browse_media(hass, f":{object_id}") -async def test_icon(device_source_mock: DmsDeviceSource, dms_device_mock: Mock) -> None: - """Test the device's icon URL is returned.""" - assert device_source_mock.icon == dms_device_mock.icon - - device_source_mock._device = None - assert device_source_mock.icon is None - - -async def test_resolve_media_invalid(device_source_mock: DmsDeviceSource) -> None: - """Test async_resolve_media will raise Unresolvable when an identifier isn't supplied.""" - with pytest.raises(Unresolvable, match="Invalid identifier.*"): - await device_source_mock.async_resolve_media("") - - -async def test_resolve_media_object( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_resolve_media_object(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test the async_resolve_object method via async_resolve_media.""" object_id: Final = "123" res_url: Final = "foo/bar" @@ -230,7 +171,7 @@ async def test_resolve_media_object( res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], ) dms_device_mock.async_browse_metadata.return_value = didl_item - result = await device_source_mock.async_resolve_media(f":{object_id}") + result = await async_resolve_media(hass, f":{object_id}") dms_device_mock.async_browse_metadata.assert_awaited_once_with( object_id, metadata_filter="*" ) @@ -251,7 +192,7 @@ async def test_resolve_media_object( ], ) dms_device_mock.async_browse_metadata.return_value = didl_item - result = await device_source_mock.async_resolve_media(f":{object_id}") + result = await async_resolve_media(hass, f":{object_id}") assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item @@ -268,7 +209,7 @@ async def test_resolve_media_object( ], ) dms_device_mock.async_browse_metadata.return_value = didl_item - result = await device_source_mock.async_resolve_media(f":{object_id}") + result = await async_resolve_media(hass, f":{object_id}") assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item @@ -282,7 +223,7 @@ async def test_resolve_media_object( ) dms_device_mock.async_browse_metadata.return_value = didl_item with pytest.raises(Unresolvable, match="Object has no resources"): - await device_source_mock.async_resolve_media(f":{object_id}") + await async_resolve_media(hass, f":{object_id}") # Failure case: resources are not playable didl_item = didl_lite.Item( @@ -293,13 +234,13 @@ async def test_resolve_media_object( ) dms_device_mock.async_browse_metadata.return_value = didl_item with pytest.raises(Unresolvable, match="Object has no playable resources"): - await device_source_mock.async_resolve_media(f":{object_id}") + await async_resolve_media(hass, f":{object_id}") -async def test_resolve_media_path( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test the async_resolve_path method via async_resolve_media.""" + # Path resolution involves searching each component of the path, then + # browsing the metadata of the final object found. path: Final = "path/to/thing" object_ids: Final = ["path_id", "to_id", "thing_id"] res_url: Final = "foo/bar" @@ -324,7 +265,7 @@ async def test_resolve_media_path( title="thing", res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], ) - result = await device_source_mock.async_resolve_media(f"/{path}") + result = await async_resolve_media(hass, f"/{path}") assert dms_device_mock.async_search_directory.await_args_list == [ call( parent_id, @@ -340,7 +281,7 @@ async def test_resolve_media_path( # Test a path starting with a / (first / is path action, second / is root of path) dms_device_mock.async_search_directory.reset_mock() dms_device_mock.async_search_directory.side_effect = search_directory_result - result = await device_source_mock.async_resolve_media(f"//{path}") + result = await async_resolve_media(hass, f"//{path}") assert dms_device_mock.async_search_directory.await_args_list == [ call( parent_id, @@ -354,44 +295,14 @@ async def test_resolve_media_path( assert result.mime_type == res_mime -async def test_resolve_path_simple( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: - """Test async_resolve_path for simple success as for test_resolve_media_path.""" - path: Final = "path/to/thing" - object_ids: Final = ["path_id", "to_id", "thing_id"] - search_directory_result = [] - for ob_id, ob_title in zip(object_ids, path.split("/")): - didl_item = didl_lite.Item( - id=ob_id, - restricted="false", - title=ob_title, - res=[], - ) - search_directory_result.append(DmsDevice.BrowseResult([didl_item], 1, 1, 0)) - - dms_device_mock.async_search_directory.side_effect = search_directory_result - result = await device_source_mock.async_resolve_path(path) - assert dms_device_mock.async_search_directory.call_args_list == [ - call( - parent_id, - search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"', - metadata_filter=["id", "upnp:class", "dc:title"], - requested_count=1, - ) - for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) - ] - assert result == object_ids[-1] - assert not dms_device_mock.async_browse_direct_children.await_count - - -async def test_resolve_path_browsed( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_resolve_path_browsed(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_resolve_path: action error results in browsing.""" path: Final = "path/to/thing" object_ids: Final = ["path_id", "to_id", "thing_id"] + res_url: Final = "foo/bar" + res_mime: Final = "audio/mpeg" + # Setup expected calls search_directory_result = [] for ob_id, ob_title in zip(object_ids, path.split("/")): didl_item = didl_lite.Item( @@ -417,7 +328,15 @@ async def test_resolve_path_browsed( DmsDevice.BrowseResult(browse_children_result, 3, 3, 0) ] - result = await device_source_mock.async_resolve_path(path) + dms_device_mock.async_browse_metadata.return_value = didl_lite.Item( + id=object_ids[-1], + restricted="false", + title="thing", + res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], + ) + + # Perform the action to test + result = await async_resolve_media(hass, path) # All levels should have an attempted search assert dms_device_mock.async_search_directory.await_args_list == [ call( @@ -428,7 +347,7 @@ async def test_resolve_path_browsed( ) for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) ] - assert result == object_ids[-1] + assert result.didl_metadata.id == object_ids[-1] # 2nd level should also be browsed assert dms_device_mock.async_browse_direct_children.await_args_list == [ call("path_id", metadata_filter=["id", "upnp:class", "dc:title"]) @@ -436,7 +355,7 @@ async def test_resolve_path_browsed( async def test_resolve_path_browsed_nothing( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test async_resolve_path: action error results in browsing, but nothing found.""" dms_device_mock.async_search_directory.side_effect = UpnpActionError() @@ -445,7 +364,7 @@ async def test_resolve_path_browsed_nothing( DmsDevice.BrowseResult([], 0, 0, 0) ] with pytest.raises(Unresolvable, match="No contents for thing in thing/other"): - await device_source_mock.async_resolve_path(r"thing/other") + await async_resolve_media(hass, "thing/other") # There are children, but they don't match dms_device_mock.async_browse_direct_children.side_effect = [ @@ -461,12 +380,10 @@ async def test_resolve_path_browsed_nothing( ) ] with pytest.raises(Unresolvable, match="Nothing found for thing in thing/other"): - await device_source_mock.async_resolve_path(r"thing/other") + await async_resolve_media(hass, "thing/other") -async def test_resolve_path_quoted( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_resolve_path_quoted(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_resolve_path: quotes and backslashes in the path get escaped correctly.""" dms_device_mock.async_search_directory.side_effect = [ DmsDevice.BrowseResult( @@ -484,8 +401,8 @@ async def test_resolve_path_quoted( ), UpnpError("Quick abort"), ] - with pytest.raises(DeviceConnectionError): - await device_source_mock.async_resolve_path(r'path/quote"back\slash') + with pytest.raises(Unresolvable): + await async_resolve_media(hass, r'path/quote"back\slash') assert dms_device_mock.async_search_directory.await_args_list == [ call( "0", @@ -503,7 +420,7 @@ async def test_resolve_path_quoted( async def test_resolve_path_ambiguous( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test async_resolve_path: ambiguous results (too many matches) gives error.""" dms_device_mock.async_search_directory.side_effect = [ @@ -530,23 +447,21 @@ async def test_resolve_path_ambiguous( with pytest.raises( Unresolvable, match="Too many items found for thing in thing/other" ): - await device_source_mock.async_resolve_path(r"thing/other") + await async_resolve_media(hass, "thing/other") async def test_resolve_path_no_such_container( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test async_resolve_path: Explicit check for NO_SUCH_CONTAINER.""" dms_device_mock.async_search_directory.side_effect = UpnpActionError( error_code=ContentDirectoryErrorCode.NO_SUCH_CONTAINER ) with pytest.raises(Unresolvable, match="No such container: 0"): - await device_source_mock.async_resolve_path(r"thing/other") + await async_resolve_media(hass, "thing/other") -async def test_resolve_media_search( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_resolve_media_search(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test the async_resolve_search method via async_resolve_media.""" res_url: Final = "foo/bar" res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}" @@ -557,7 +472,7 @@ async def test_resolve_media_search( [], 0, 0, 0 ) with pytest.raises(Unresolvable, match='Nothing found for dc:title="thing"'): - await device_source_mock.async_resolve_media('?dc:title="thing"') + await async_resolve_media(hass, '?dc:title="thing"') assert dms_device_mock.async_search_directory.await_args_list == [ call( container_id="0", @@ -578,7 +493,7 @@ async def test_resolve_media_search( dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [didl_item], 1, 1, 0 ) - result = await device_source_mock.async_resolve_media('?dc:title="thing"') + result = await async_resolve_media(hass, '?dc:title="thing"') assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item @@ -590,7 +505,7 @@ async def test_resolve_media_search( dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [didl_item], 1, 2, 0 ) - result = await device_source_mock.async_resolve_media('?dc:title="thing"') + result = await async_resolve_media(hass, '?dc:title="thing"') assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item @@ -600,12 +515,10 @@ async def test_resolve_media_search( [didl_lite.Descriptor("id", "namespace")], 1, 1, 0 ) with pytest.raises(Unresolvable, match="Descriptor.* is not a DidlObject"): - await device_source_mock.async_resolve_media('?dc:title="thing"') + await async_resolve_media(hass, '?dc:title="thing"') -async def test_browse_media_root( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_browse_media_root(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_browse_media with no identifier will browse the root of the device.""" dms_device_mock.async_browse_metadata.return_value = didl_lite.DidlObject( id="0", restricted="false", title="root" @@ -613,8 +526,25 @@ async def test_browse_media_root( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) + # No identifier (first opened in media browser) - result = await device_source_mock.async_browse_media(None) + result = await media_source.async_browse_media(hass, f"media-source://{DOMAIN}") + assert result.identifier == f"{MOCK_SOURCE_ID}/:0" + assert result.title == MOCK_DEVICE_NAME + dms_device_mock.async_browse_metadata.assert_awaited_once_with( + "0", metadata_filter=ANY + ) + dms_device_mock.async_browse_direct_children.assert_awaited_once_with( + "0", metadata_filter=ANY, sort_criteria=ANY + ) + + dms_device_mock.async_browse_metadata.reset_mock() + dms_device_mock.async_browse_direct_children.reset_mock() + + # Only source ID, no object ID + result = await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}" + ) assert result.identifier == f"{MOCK_SOURCE_ID}/:0" assert result.title == MOCK_DEVICE_NAME dms_device_mock.async_browse_metadata.assert_awaited_once_with( @@ -627,7 +557,9 @@ async def test_browse_media_root( dms_device_mock.async_browse_metadata.reset_mock() dms_device_mock.async_browse_direct_children.reset_mock() # Empty string identifier - result = await device_source_mock.async_browse_media("") + result = await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/" + ) assert result.identifier == f"{MOCK_SOURCE_ID}/:0" assert result.title == MOCK_DEVICE_NAME dms_device_mock.async_browse_metadata.assert_awaited_once_with( @@ -638,9 +570,7 @@ async def test_browse_media_root( ) -async def test_browse_media_object( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_browse_media_object(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_browse_object via async_browse_media.""" object_id = "1234" child_titles = ("Item 1", "Thing", "Item 2") @@ -663,7 +593,7 @@ async def test_browse_media_object( ) dms_device_mock.async_browse_direct_children.return_value = children_result - result = await device_source_mock.async_browse_media(f":{object_id}") + result = await async_browse_media(hass, f":{object_id}") dms_device_mock.async_browse_metadata.assert_awaited_once_with( object_id, metadata_filter=ANY ) @@ -687,7 +617,7 @@ async def test_browse_media_object( async def test_browse_object_sort_anything( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test sort criteria for children where device allows anything.""" dms_device_mock.sort_capabilities = ["*"] @@ -699,7 +629,7 @@ async def test_browse_object_sort_anything( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) - await device_source_mock.async_browse_object("0") + await async_browse_media(hass, ":0") # Sort criteria should be dlna_dms's default dms_device_mock.async_browse_direct_children.assert_awaited_once_with( @@ -708,7 +638,7 @@ async def test_browse_object_sort_anything( async def test_browse_object_sort_superset( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test sorting where device allows superset of integration's criteria.""" dms_device_mock.sort_capabilities = [ @@ -727,7 +657,7 @@ async def test_browse_object_sort_superset( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) - await device_source_mock.async_browse_object("0") + await async_browse_media(hass, ":0") # Sort criteria should be dlna_dms's default dms_device_mock.async_browse_direct_children.assert_awaited_once_with( @@ -736,7 +666,7 @@ async def test_browse_object_sort_superset( async def test_browse_object_sort_subset( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test sorting where device allows subset of integration's criteria.""" dms_device_mock.sort_capabilities = [ @@ -751,7 +681,7 @@ async def test_browse_object_sort_subset( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) - await device_source_mock.async_browse_object("0") + await async_browse_media(hass, ":0") # Sort criteria should be reduced to only those allowed, # and in the order specified by DLNA_SORT_CRITERIA @@ -761,9 +691,7 @@ async def test_browse_object_sort_subset( ) -async def test_browse_media_path( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_browse_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_browse_media with a path.""" title = "folder" con_id = "123" @@ -776,7 +704,7 @@ async def test_browse_media_path( [], 0, 0, 0 ) - result = await device_source_mock.async_browse_media(f"{title}") + result = await async_browse_media(hass, title) assert result.identifier == f"{MOCK_SOURCE_ID}/:{con_id}" assert result.title == title @@ -794,9 +722,7 @@ async def test_browse_media_path( ) -async def test_browse_media_search( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_browse_media_search(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_browse_media with a search query.""" query = 'dc:title contains "FooBar"' object_details = (("111", "FooBar baz"), ("432", "Not FooBar"), ("99", "FooBar")) @@ -814,7 +740,7 @@ async def test_browse_media_search( 1, didl_lite.Descriptor("id", "name_space") ) - result = await device_source_mock.async_browse_media(f"?{query}") + result = await async_browse_media(hass, f"?{query}") assert result.identifier == f"{MOCK_SOURCE_ID}/?{query}" assert result.title == "Search results" assert result.children @@ -827,7 +753,7 @@ async def test_browse_media_search( async def test_browse_search_invalid( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test searching with an invalid query gives a BrowseError.""" query = "title == FooBar" @@ -835,11 +761,11 @@ async def test_browse_search_invalid( error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA ) with pytest.raises(BrowseError, match=f"Invalid query: {query}"): - await device_source_mock.async_browse_media(f"?{query}") + await async_browse_media(hass, f"?{query}") async def test_browse_search_no_results( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test a search with no results does not give an error.""" query = 'dc:title contains "FooBar"' @@ -847,15 +773,13 @@ async def test_browse_search_no_results( [], 0, 0, 0 ) - result = await device_source_mock.async_browse_media(f"?{query}") + result = await async_browse_media(hass, f"?{query}") assert result.identifier == f"{MOCK_SOURCE_ID}/?{query}" assert result.title == "Search results" assert not result.children -async def test_thumbnail( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_thumbnail(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test getting thumbnails URLs for items.""" # Use browse_search to get multiple items at once for least effort dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( @@ -901,16 +825,14 @@ async def test_thumbnail( 0, ) - result = await device_source_mock.async_browse_media("?query") + result = await async_browse_media(hass, "?query") assert result.children assert result.children[0].thumbnail == f"{MOCK_DEVICE_BASE_URL}/a_thumb.jpg" assert result.children[1].thumbnail == f"{MOCK_DEVICE_BASE_URL}/b_thumb.png" assert result.children[2].thumbnail is None -async def test_can_play( - device_source_mock: DmsDeviceSource, dms_device_mock: Mock -) -> None: +async def test_can_play(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test determination of playability for items.""" protocol_infos = [ # No protocol info for resource @@ -945,7 +867,7 @@ async def test_can_play( search_results, len(search_results), len(search_results), 0 ) - result = await device_source_mock.async_browse_media("?query") + result = await async_browse_media(hass, "?query") assert result.children assert not result.children[0].can_play for idx, info_can_play in enumerate(protocol_infos): diff --git a/tests/components/dlna_dms/test_init.py b/tests/components/dlna_dms/test_init.py index 16254adca89..a5bac73efb2 100644 --- a/tests/components/dlna_dms/test_init.py +++ b/tests/components/dlna_dms/test_init.py @@ -1,18 +1,32 @@ """Test the DLNA DMS component setup, cleanup, and module-level functions.""" +from typing import cast from unittest.mock import Mock -from homeassistant.components.dlna_dms.const import DOMAIN +from homeassistant.components.dlna_dms.const import ( + CONF_SOURCE_ID, + CONFIG_VERSION, + DOMAIN, +) from homeassistant.components.dlna_dms.dms import DlnaDmsData +from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import ( + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_TYPE, + MOCK_DEVICE_USN, + MOCK_SOURCE_ID, +) + from tests.common import MockConfigEntry async def test_resource_lifecycle( hass: HomeAssistant, - domain_data_mock: DlnaDmsData, + aiohttp_session_requester_mock: Mock, config_entry_mock: MockConfigEntry, ssdp_scanner_mock: Mock, dms_device_mock: Mock, @@ -23,14 +37,15 @@ async def test_resource_lifecycle( assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_block_till_done() - # Check the entity is created and working - assert len(domain_data_mock.devices) == 1 - assert len(domain_data_mock.sources) == 1 - entity = next(iter(domain_data_mock.devices.values())) + # Check the device source is created and working + domain_data = cast(DlnaDmsData, hass.data[DOMAIN]) + assert len(domain_data.devices) == 1 + assert len(domain_data.sources) == 1 + entity = next(iter(domain_data.devices.values())) assert entity.available is True - # Check update listeners are subscribed - assert len(config_entry_mock.update_listeners) == 1 + # Check listener subscriptions + assert len(config_entry_mock.update_listeners) == 0 assert ssdp_scanner_mock.async_register_callback.await_count == 2 assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 @@ -54,6 +69,63 @@ async def test_resource_lifecycle( assert dms_device_mock.async_unsubscribe_services.await_count == 0 assert dms_device_mock.on_event is None - # Check entity is gone - assert not domain_data_mock.devices - assert not domain_data_mock.sources + # Check device source is gone + assert not domain_data.devices + assert not domain_data.sources + + +async def test_migrate_entry(hass: HomeAssistant) -> None: + """Test migrating a config entry from version 1 to version 2.""" + # Create mock entry with version 1 + mock_entry = MockConfigEntry( + unique_id=MOCK_DEVICE_USN, + domain=DOMAIN, + version=1, + data={ + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_USN, + }, + title=MOCK_DEVICE_NAME, + ) + + # Set it up + mock_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + # Check that it has a source_id now + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert updated_entry + assert updated_entry.version == CONFIG_VERSION + assert updated_entry.data.get(CONF_SOURCE_ID) == MOCK_SOURCE_ID + + +async def test_migrate_entry_collision( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test migrating a config entry with a potentially colliding source ID.""" + # Use existing mock entry + config_entry_mock.add_to_hass(hass) + + # Create mock entry with same name, and old version, that needs migrating + mock_entry = MockConfigEntry( + unique_id=f"udn-migrating::{MOCK_DEVICE_TYPE}", + domain=DOMAIN, + version=1, + data={ + CONF_URL: "http://192.88.99.22/dms_description.xml", + CONF_DEVICE_ID: f"different-udn::{MOCK_DEVICE_TYPE}", + }, + title=MOCK_DEVICE_NAME, + ) + mock_entry.add_to_hass(hass) + + # Set the integration up + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + # Check that it has a source_id now + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert updated_entry + assert updated_entry.version == CONFIG_VERSION + assert updated_entry.data.get(CONF_SOURCE_ID) == f"{MOCK_SOURCE_ID}_1" diff --git a/tests/components/dlna_dms/test_media_source.py b/tests/components/dlna_dms/test_media_source.py index 4b43402ecbd..f2c3011e274 100644 --- a/tests/components/dlna_dms/test_media_source.py +++ b/tests/components/dlna_dms/test_media_source.py @@ -5,8 +5,9 @@ from async_upnp_client.exceptions import UpnpError from didl_lite import didl_lite import pytest +from homeassistant.components import media_source from homeassistant.components.dlna_dms.const import DOMAIN -from homeassistant.components.dlna_dms.dms import DlnaDmsData, DmsDeviceSource +from homeassistant.components.dlna_dms.dms import DidlPlayMedia from homeassistant.components.dlna_dms.media_source import ( DmsMediaSource, async_get_media_source, @@ -24,30 +25,18 @@ from .conftest import ( MOCK_DEVICE_BASE_URL, MOCK_DEVICE_NAME, MOCK_DEVICE_TYPE, - MOCK_DEVICE_USN, MOCK_SOURCE_ID, ) from tests.common import MockConfigEntry - -@pytest.fixture -async def entity( - hass: HomeAssistant, - config_entry_mock: MockConfigEntry, - dms_device_mock: Mock, - domain_data_mock: DlnaDmsData, -) -> DmsDeviceSource: - """Fixture to set up a DmsDeviceSource in a connected state and cleanup at completion.""" - await hass.config_entries.async_add(config_entry_mock) - await hass.async_block_till_done() - return domain_data_mock.devices[MOCK_DEVICE_USN] - - -@pytest.fixture -async def dms_source(hass: HomeAssistant, entity: DmsDeviceSource) -> DmsMediaSource: - """Fixture providing a pre-constructed DmsMediaSource with a single device.""" - return DmsMediaSource(hass) +# Auto-use a few fixtures from conftest +pytestmark = [ + # Block network access + pytest.mark.usefixtures("aiohttp_session_requester_mock"), + # Setup the media_source platform + pytest.mark.usefixtures("setup_media_source"), +] async def test_get_media_source(hass: HomeAssistant) -> None: @@ -66,41 +55,44 @@ async def test_resolve_media_unconfigured(hass: HomeAssistant) -> None: async def test_resolve_media_bad_identifier( - hass: HomeAssistant, dms_source: DmsMediaSource + hass: HomeAssistant, device_source_mock: None ) -> None: """Test trying to resolve an item that has an unresolvable identifier.""" # Empty identifier - item = MediaSourceItem(hass, DOMAIN, "") with pytest.raises(Unresolvable, match="No source ID.*"): - await dms_source.async_resolve_media(item) + await media_source.async_resolve_media(hass, f"media-source://{DOMAIN}") # Identifier has media_id but no source_id - item = MediaSourceItem(hass, DOMAIN, "/media_id") - with pytest.raises(Unresolvable, match="No source ID.*"): - await dms_source.async_resolve_media(item) + # media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms + with pytest.raises(Unresolvable, match="Invalid media source URI"): + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}//media_id" + ) # Identifier has source_id but no media_id - item = MediaSourceItem(hass, DOMAIN, "source_id/") with pytest.raises(Unresolvable, match="No media ID.*"): - await dms_source.async_resolve_media(item) + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/source_id/" + ) # Identifier is missing source_id/media_id separator - item = MediaSourceItem(hass, DOMAIN, "source_id") with pytest.raises(Unresolvable, match="No media ID.*"): - await dms_source.async_resolve_media(item) + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/source_id" + ) # Identifier has an unknown source_id - item = MediaSourceItem(hass, DOMAIN, "unknown_source/media_id") with pytest.raises(Unresolvable, match="Unknown source ID: unknown_source"): - await dms_source.async_resolve_media(item) + await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/unknown_source/media_id" + ) async def test_resolve_media_success( - hass: HomeAssistant, dms_source: DmsMediaSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None ) -> None: """Test resolving an item via a DmsDeviceSource.""" object_id = "123" - item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:{object_id}") res_url = "foo/bar" res_mime = "audio/mpeg" @@ -112,7 +104,10 @@ async def test_resolve_media_success( ) dms_device_mock.async_browse_metadata.return_value = didl_item - result = await dms_source.async_resolve_media(item) + result = await media_source.async_resolve_media( + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}" + ) + assert isinstance(result, DidlPlayMedia) assert result.url == f"{MOCK_DEVICE_BASE_URL}/{res_url}" assert result.mime_type == res_mime assert result.didl_metadata is didl_item @@ -131,43 +126,42 @@ async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: async def test_browse_media_bad_identifier( - hass: HomeAssistant, dms_source: DmsMediaSource + hass: HomeAssistant, device_source_mock: None ) -> None: """Test browse_media with a bad source_id.""" - item = MediaSourceItem(hass, DOMAIN, "bad-id/media_id") with pytest.raises(BrowseError, match="Unknown source ID: bad-id"): - await dms_source.async_browse_media(item) + await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}/bad-id/media_id" + ) async def test_browse_media_single_source_no_identifier( - hass: HomeAssistant, dms_source: DmsMediaSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None ) -> None: """Test browse_media without a source_id, with a single device registered.""" # Fast bail-out, mock will be checked after dms_device_mock.async_browse_metadata.side_effect = UpnpError # No source_id nor media_id - item = MediaSourceItem(hass, DOMAIN, "") with pytest.raises(BrowseError): - await dms_source.async_browse_media(item) + await media_source.async_browse_media(hass, f"media-source://{DOMAIN}") # Mock device should've been browsed for the root directory dms_device_mock.async_browse_metadata.assert_awaited_once_with( "0", metadata_filter=ANY ) # No source_id but a media_id - item = MediaSourceItem(hass, DOMAIN, "/:media-item-id") + # media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms dms_device_mock.async_browse_metadata.reset_mock() - with pytest.raises(BrowseError): - await dms_source.async_browse_media(item) - # Mock device should've been browsed for the root directory - dms_device_mock.async_browse_metadata.assert_awaited_once_with( - "media-item-id", metadata_filter=ANY - ) + with pytest.raises(BrowseError, match="Invalid media source URI"): + await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}//:media-item-id" + ) + assert dms_device_mock.async_browse_metadata.await_count == 0 async def test_browse_media_multiple_sources( - hass: HomeAssistant, dms_source: DmsMediaSource, dms_device_mock: Mock + hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None ) -> None: """Test browse_media without a source_id, with multiple devices registered.""" # Set up a second source @@ -182,12 +176,12 @@ async def test_browse_media_multiple_sources( }, title=other_source_title, ) - await hass.config_entries.async_add(other_config_entry) + other_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(other_config_entry.entry_id) await hass.async_block_till_done() # No source_id nor media_id - item = MediaSourceItem(hass, DOMAIN, "") - result = await dms_source.async_browse_media(item) + result = await media_source.async_browse_media(hass, f"media-source://{DOMAIN}") # Mock device should not have been browsed assert dms_device_mock.async_browse_metadata.await_count == 0 # Result will be a list of available devices @@ -196,31 +190,28 @@ async def test_browse_media_multiple_sources( assert isinstance(result.children[0], BrowseMediaSource) assert result.children[0].identifier == f"{MOCK_SOURCE_ID}/:0" assert result.children[0].title == MOCK_DEVICE_NAME + assert result.children[0].thumbnail == dms_device_mock.icon assert isinstance(result.children[1], BrowseMediaSource) assert result.children[1].identifier == f"{other_source_id}/:0" assert result.children[1].title == other_source_title - # No source_id but a media_id - will give the exact same list of all devices - item = MediaSourceItem(hass, DOMAIN, "/:media-item-id") - result = await dms_source.async_browse_media(item) + # No source_id but a media_id + # media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms + with pytest.raises(BrowseError, match="Invalid media source URI"): + result = await media_source.async_browse_media( + hass, f"media-source://{DOMAIN}//:media-item-id" + ) # Mock device should not have been browsed assert dms_device_mock.async_browse_metadata.await_count == 0 - # Result will be a list of available devices - assert result.title == "DLNA Servers" - assert result.children - assert isinstance(result.children[0], BrowseMediaSource) - assert result.children[0].identifier == f"{MOCK_SOURCE_ID}/:0" - assert result.children[0].title == MOCK_DEVICE_NAME - assert isinstance(result.children[1], BrowseMediaSource) - assert result.children[1].identifier == f"{other_source_id}/:0" - assert result.children[1].title == other_source_title + + # Clean up, to fulfil ssdp_scanner post-condition of every callback being cleared + await hass.config_entries.async_remove(other_config_entry.entry_id) async def test_browse_media_source_id( hass: HomeAssistant, config_entry_mock: MockConfigEntry, dms_device_mock: Mock, - domain_data_mock: DlnaDmsData, ) -> None: """Test browse_media with an explicit source_id.""" # Set up a second device first, then the primary mock device. @@ -235,10 +226,13 @@ async def test_browse_media_source_id( }, title=other_source_title, ) - await hass.config_entries.async_add(other_config_entry) - await hass.async_block_till_done() - await hass.config_entries.async_add(config_entry_mock) + other_config_entry.add_to_hass(hass) + config_entry_mock.add_to_hass(hass) + + # Setting up either config entry will result in the dlna_dms component being + # loaded, and both config entries will be setup + await hass.config_entries.async_setup(other_config_entry.entry_id) await hass.async_block_till_done() # Fast bail-out, mock will be checked after