dlna_dms fixes from code review (#67796)

This commit is contained in:
Michael Chisholm 2022-03-30 00:32:16 +11:00 committed by GitHub
parent bdb61e0222
commit 62aa7fe10e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 610 additions and 630 deletions

View File

@ -8,14 +8,22 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import LOGGER from .const import CONF_SOURCE_ID, LOGGER
from .dms import get_domain_data from .dms import get_domain_data
from .util import generate_source_id
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up DLNA DMS device from a config entry.""" """Set up DLNA DMS device from a config entry."""
LOGGER.debug("Setting up config entry: %s", entry.unique_id) 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 # Forward setup to this domain's data manager
return await get_domain_data(hass).async_setup_entry(entry) return await get_domain_data(hass).async_setup_entry(entry)

View File

@ -13,17 +13,13 @@ from homeassistant import config_entries
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL
from homeassistant.data_entry_flow import AbortFlow, FlowResult 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__) LOGGER = logging.getLogger(__name__)
class ConnectError(IntegrationError):
"""Error occurred when trying to connect to a device."""
class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a DLNA DMS config flow. """Handle a DLNA DMS config flow.
@ -32,7 +28,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
the DMS is an embedded device. the DMS is an embedded device.
""" """
VERSION = 1 VERSION = CONFIG_VERSION
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize flow.""" """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)): if user_input is not None and (host := user_input.get(CONF_HOST)):
# User has chosen a device # User has chosen a device
discovery = self._discoveries[host] discovery = self._discoveries[host]
await self._async_parse_discovery(discovery) await self._async_parse_discovery(discovery, raise_on_progress=False)
return self._create_entry() return self._create_entry()
if not (discoveries := await self._async_get_discoveries()): 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 self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Allow the user to confirm adding the device.""" """Allow the user to confirm adding the device."""
LOGGER.debug("async_step_confirm: %s", user_input)
if user_input is not None: if user_input is not None:
return self._create_entry() return self._create_entry()
@ -111,17 +105,24 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
def _create_entry(self) -> FlowResult: def _create_entry(self) -> FlowResult:
"""Create a config entry, assuming all required information is now known.""" """Create a config entry, assuming all required information is now known."""
LOGGER.debug( 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._name
assert self._location assert self._location
assert self._usn 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) return self.async_create_entry(title=self._name, data=data)
async def _async_parse_discovery( async def _async_parse_discovery(
self, discovery_info: ssdp.SsdpServiceInfo self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True
) -> None: ) -> None:
"""Get required details from an SSDP discovery. """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._location = discovery_info.ssdp_location
self._usn = discovery_info.ssdp_usn 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 # Abort if already configured, but update the last-known location
self._abort_if_unique_id_configured( 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]: async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]:
"""Get list of unconfigured DLNA devices discovered by SSDP.""" """Get list of unconfigured DLNA devices discovered by SSDP."""
LOGGER.debug("_get_discoveries")
# Get all compatible devices from ssdp's cache # Get all compatible devices from ssdp's cache
discoveries: list[ssdp.SsdpServiceInfo] = [] discoveries: list[ssdp.SsdpServiceInfo] = []
for udn_st in DmsDevice.DEVICE_TYPES: for udn_st in DmsDevice.DEVICE_TYPES:

View File

@ -12,6 +12,9 @@ LOGGER = logging.getLogger(__package__)
DOMAIN: Final = "dlna_dms" DOMAIN: Final = "dlna_dms"
DEFAULT_NAME: Final = "DLNA Media Server" DEFAULT_NAME: Final = "DLNA Media Server"
CONF_SOURCE_ID: Final = "source_id"
CONFIG_VERSION: Final = 1
SOURCE_SEP: Final = "/" SOURCE_SEP: Final = "/"
ROOT_OBJECT_ID: Final = "0" ROOT_OBJECT_ID: Final = "0"
PATH_SEP: Final = "/" PATH_SEP: Final = "/"

View File

@ -11,7 +11,6 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester
from async_upnp_client.client import UpnpRequester from async_upnp_client.client import UpnpRequester
from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.client_factory import UpnpFactory
from async_upnp_client.const import NotificationSubType 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.exceptions import UpnpActionError, UpnpConnectionError, UpnpError
from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice
from didl_lite import didl_lite 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.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.util import slugify
from .const import ( from .const import (
CONF_SOURCE_ID,
DLNA_BROWSE_FILTER, DLNA_BROWSE_FILTER,
DLNA_PATH_FILTER, DLNA_PATH_FILTER,
DLNA_RESOLVE_FILTER, DLNA_RESOLVE_FILTER,
@ -51,10 +50,8 @@ class DlnaDmsData:
"""Storage class for domain global data.""" """Storage class for domain global data."""
hass: HomeAssistant hass: HomeAssistant
lock: asyncio.Lock
requester: UpnpRequester requester: UpnpRequester
upnp_factory: UpnpFactory upnp_factory: UpnpFactory
event_handler: UpnpEventHandler
devices: dict[str, DmsDeviceSource] # Indexed by config_entry.unique_id devices: dict[str, DmsDeviceSource] # Indexed by config_entry.unique_id
sources: dict[str, DmsDeviceSource] # Indexed by source_id sources: dict[str, DmsDeviceSource] # Indexed by source_id
@ -64,69 +61,32 @@ class DlnaDmsData:
) -> None: ) -> None:
"""Initialize global data.""" """Initialize global data."""
self.hass = hass self.hass = hass
self.lock = asyncio.Lock()
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
self.requester = AiohttpSessionRequester(session, with_sleep=True) self.requester = AiohttpSessionRequester(session, with_sleep=True)
self.upnp_factory = UpnpFactory(self.requester, non_strict=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.devices = {}
self.sources = {} self.sources = {}
async def async_setup_entry(self, config_entry: ConfigEntry) -> bool: async def async_setup_entry(self, config_entry: ConfigEntry) -> bool:
"""Create a DMS device connection from a config entry.""" """Create a DMS device connection from a config entry."""
assert config_entry.unique_id assert config_entry.unique_id
async with self.lock: device = DmsDeviceSource(self.hass, config_entry)
source_id = self._generate_source_id(config_entry.title) self.devices[config_entry.unique_id] = device
device = DmsDeviceSource(self.hass, config_entry, source_id) # source_id must be unique, which generate_source_id should guarantee.
self.devices[config_entry.unique_id] = device # Ensure this is the case, for debugging purposes.
self.sources[device.source_id] = device assert device.source_id not in self.sources
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)
)
await device.async_added_to_hass() await device.async_added_to_hass()
return True return True
async def async_unload_entry(self, config_entry: ConfigEntry) -> bool: async def async_unload_entry(self, config_entry: ConfigEntry) -> bool:
"""Unload a config entry and disconnect the corresponding DMS device.""" """Unload a config entry and disconnect the corresponding DMS device."""
assert config_entry.unique_id assert config_entry.unique_id
async with self.lock: device = self.devices.pop(config_entry.unique_id)
device = self.devices.pop(config_entry.unique_id) del self.sources[device.source_id]
del self.sources[device.source_id]
await device.async_will_remove_from_hass() await device.async_will_remove_from_hass()
return True 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 @callback
def get_domain_data(hass: HomeAssistant) -> DlnaDmsData: def get_domain_data(hass: HomeAssistant) -> DlnaDmsData:
@ -202,12 +162,6 @@ def catch_request_errors(
class DmsDeviceSource: class DmsDeviceSource:
"""DMS Device wrapper, providing media files as a media_source.""" """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 # 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 # try to connect before SSDP has rediscovered it, or when SSDP discovery
# fails. # fails.
@ -222,13 +176,10 @@ class DmsDeviceSource:
# Track BOOTID in SSDP advertisements for device changes # Track BOOTID in SSDP advertisements for device changes
_bootid: int | None = None _bootid: int | None = None
def __init__( def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
self, hass: HomeAssistant, config_entry: ConfigEntry, source_id: str
) -> None:
"""Initialize a DMS Source.""" """Initialize a DMS Source."""
self.hass = hass self.hass = hass
self.config_entry = config_entry self.config_entry = config_entry
self.source_id = source_id
self.location = self.config_entry.data[CONF_URL] self.location = self.config_entry.data[CONF_URL]
self._device_lock = asyncio.Lock() self._device_lock = asyncio.Lock()
@ -336,16 +287,13 @@ class DmsDeviceSource:
async def device_connect(self) -> None: async def device_connect(self) -> None:
"""Connect to the device now that it's available.""" """Connect to the device now that it's available."""
LOGGER.debug("Connecting to device at %s", self.location) LOGGER.debug("Connecting to device at %s", self.location)
assert self.location
async with self._device_lock: async with self._device_lock:
if self._device: if self._device:
LOGGER.debug("Trying to connect when device already connected") LOGGER.debug("Trying to connect when device already connected")
return return
if not self.location:
LOGGER.debug("Not connecting because location is not known")
return
domain_data = get_domain_data(self.hass) domain_data = get_domain_data(self.hass)
# Connect to the base UPNP device # Connect to the base UPNP device
@ -354,7 +302,7 @@ class DmsDeviceSource:
) )
# Create profile wrapper # 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 # Update state variables. We don't care if they change, so this is
# only done once, here. # only done once, here.
@ -396,13 +344,15 @@ class DmsDeviceSource:
"""Return a name for the media server.""" """Return a name for the media server."""
return self.config_entry.title 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 @property
def icon(self) -> str | None: def icon(self) -> str | None:
"""Return an URL to an icon for the media server.""" """Return an URL to an icon for the media server."""
if not self._device: return self._device.icon if self._device else None
return None
return self._device.icon
# MediaSource methods # MediaSource methods
@ -411,6 +361,8 @@ class DmsDeviceSource:
LOGGER.debug("async_resolve_media(%s)", identifier) LOGGER.debug("async_resolve_media(%s)", identifier)
action, parameters = _parse_identifier(identifier) action, parameters = _parse_identifier(identifier)
assert action is not None, f"Invalid identifier: {identifier}"
if action is Action.OBJECT: if action is Action.OBJECT:
return await self.async_resolve_object(parameters) return await self.async_resolve_object(parameters)
@ -418,11 +370,8 @@ class DmsDeviceSource:
object_id = await self.async_resolve_path(parameters) object_id = await self.async_resolve_path(parameters)
return await self.async_resolve_object(object_id) return await self.async_resolve_object(object_id)
if action is Action.SEARCH: assert action is Action.SEARCH
return await self.async_resolve_search(parameters) return await self.async_resolve_search(parameters)
LOGGER.debug("Invalid identifier %s", identifier)
raise Unresolvable(f"Invalid identifier {identifier}")
async def async_browse_media(self, identifier: str | None) -> BrowseMediaSource: async def async_browse_media(self, identifier: str | None) -> BrowseMediaSource:
"""Browse media.""" """Browse media."""
@ -577,9 +526,6 @@ class DmsDeviceSource:
children=children, children=children,
) )
if media_source.children:
media_source.calculate_children_class()
return media_source return media_source
def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia: def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia:
@ -648,9 +594,6 @@ class DmsDeviceSource:
thumbnail=self._didl_thumbnail_url(item), thumbnail=self._didl_thumbnail_url(item),
) )
if media_source.children:
media_source.calculate_children_class()
return media_source return media_source
def _didl_thumbnail_url(self, item: didl_lite.DidlObject) -> str | None: def _didl_thumbnail_url(self, item: didl_lite.DidlObject) -> str | None:

View File

@ -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

View File

@ -1,18 +1,23 @@
"""Fixtures for DLNA DMS tests.""" """Fixtures for DLNA DMS tests."""
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator, Iterable from collections.abc import AsyncIterable, Iterable
from typing import Final from typing import Final, cast
from unittest.mock import Mock, create_autospec, patch, seal from unittest.mock import Mock, create_autospec, patch, seal
from async_upnp_client.client import UpnpDevice, UpnpService from async_upnp_client.client import UpnpDevice, UpnpService
from async_upnp_client.utils import absolute_url from async_upnp_client.utils import absolute_url
import pytest import pytest
from homeassistant.components.dlna_dms.const import DOMAIN from homeassistant.components.dlna_dms.const import (
from homeassistant.components.dlna_dms.dms import DlnaDmsData, get_domain_data 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.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry 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" 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 @pytest.fixture
def upnp_factory_mock() -> Iterable[Mock]: def upnp_factory_mock() -> Iterable[Mock]:
"""Mock the UpnpFactory class to construct DMS-style UPnP devices.""" """Mock the UpnpFactory class to construct DMS-style UPnP devices."""
@ -69,21 +80,13 @@ def upnp_factory_mock() -> Iterable[Mock]:
yield upnp_factory_instance yield upnp_factory_instance
@pytest.fixture @pytest.fixture(autouse=True, scope="module")
async def domain_data_mock( def aiohttp_session_requester_mock() -> Iterable[Mock]:
hass: HomeAssistant, aioclient_mock, upnp_factory_mock """Mock the AiohttpSessionRequester to prevent network use."""
) -> 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
"""
with patch( with patch(
"homeassistant.components.dlna_dms.dms.AiohttpSessionRequester", autospec=True "homeassistant.components.dlna_dms.dms.AiohttpSessionRequester", autospec=True
): ) as requester_mock:
yield get_domain_data(hass) yield requester_mock
@pytest.fixture @pytest.fixture
@ -92,9 +95,11 @@ def config_entry_mock() -> MockConfigEntry:
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
unique_id=MOCK_DEVICE_USN, unique_id=MOCK_DEVICE_USN,
domain=DOMAIN, domain=DOMAIN,
version=CONFIG_VERSION,
data={ data={
CONF_URL: MOCK_DEVICE_LOCATION, CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN, CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
}, },
title=MOCK_DEVICE_NAME, 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 = mock_scanner.return_value.async_register_callback
reg_callback.return_value = Mock(return_value=None) reg_callback.return_value = Mock(return_value=None)
yield mock_scanner.return_value 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

View File

@ -1,16 +1,17 @@
"""Test the DLNA DMS config flow.""" """Test the DLNA DMS config flow."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
import dataclasses import dataclasses
from typing import Final from typing import Final
from unittest.mock import Mock from unittest.mock import Mock, patch
from async_upnp_client.exceptions import UpnpError from async_upnp_client.exceptions import UpnpError
import pytest import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components import ssdp 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.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -21,17 +22,12 @@ from .conftest import (
MOCK_DEVICE_TYPE, MOCK_DEVICE_TYPE,
MOCK_DEVICE_UDN, MOCK_DEVICE_UDN,
MOCK_DEVICE_USN, MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
NEW_DEVICE_LOCATION, NEW_DEVICE_LOCATION,
) )
from tests.common import MockConfigEntry 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" WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE" 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: async def test_user_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None:
"""Test user-init'd flow, user selects discovered device.""" """Test user-init'd flow, user selects discovered device."""
ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: MOCK_DEVICE_HOST} 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["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == MOCK_DEVICE_NAME assert result["title"] == MOCK_DEVICE_NAME
assert result["data"] == { assert result["data"] == {
CONF_URL: MOCK_DEVICE_LOCATION, CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN, CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
} }
assert result["options"] == {} assert result["options"] == {}
await hass.async_block_till_done()
async def test_user_flow_no_devices( async def test_user_flow_no_devices(
hass: HomeAssistant, ssdp_scanner_mock: Mock hass: HomeAssistant, ssdp_scanner_mock: Mock
@ -137,12 +143,13 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None:
assert result["data"] == { assert result["data"] == {
CONF_URL: MOCK_DEVICE_LOCATION, CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN, CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
} }
assert result["options"] == {} assert result["options"] == {}
async def test_ssdp_flow_unavailable( async def test_ssdp_flow_unavailable(
hass: HomeAssistant, domain_data_mock: Mock hass: HomeAssistant, upnp_factory_mock: Mock
) -> None: ) -> None:
"""Test that SSDP discovery with an unavailable device still succeeds. """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["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "confirm" 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={} result["flow_id"], user_input={}
@ -169,6 +176,7 @@ async def test_ssdp_flow_unavailable(
assert result["data"] == { assert result["data"] == {
CONF_URL: MOCK_DEVICE_LOCATION, CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN, CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
} }
assert result["options"] == {} assert result["options"] == {}
@ -213,9 +221,7 @@ async def test_ssdp_flow_duplicate_location(
assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION
async def test_ssdp_flow_bad_data( async def test_ssdp_flow_bad_data(hass: HomeAssistant) -> None:
hass: HomeAssistant, config_entry_mock: MockConfigEntry
) -> None:
"""Test bad SSDP discovery information is rejected cleanly.""" """Test bad SSDP discovery information is rejected cleanly."""
# Missing location # Missing location
discovery = dataclasses.replace(MOCK_DISCOVERY, ssdp_location="") discovery = dataclasses.replace(MOCK_DISCOVERY, ssdp_location="")
@ -241,15 +247,16 @@ async def test_ssdp_flow_bad_data(
async def test_duplicate_name( async def test_duplicate_name(
hass: HomeAssistant, config_entry_mock: MockConfigEntry hass: HomeAssistant, config_entry_mock: MockConfigEntry
) -> None: ) -> 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) config_entry_mock.add_to_hass(hass)
mock_entry_1 = MockConfigEntry( mock_entry_1 = MockConfigEntry(
unique_id="mock_entry_1", unique_id="mock_entry_1",
domain=DOMAIN, domain=DOMAIN,
data={ data={
CONF_URL: "not-important", CONF_URL: "not-important",
CONF_DEVICE_ID: "not-important", CONF_DEVICE_ID: "not-important",
CONF_SOURCE_ID: f"{MOCK_SOURCE_ID}_1",
}, },
title=MOCK_DEVICE_NAME, title=MOCK_DEVICE_NAME,
) )
@ -286,6 +293,7 @@ async def test_duplicate_name(
assert result["data"] == { assert result["data"] == {
CONF_URL: new_device_location, CONF_URL: new_device_location,
CONF_DEVICE_ID: new_device_usn, CONF_DEVICE_ID: new_device_usn,
CONF_SOURCE_ID: f"{MOCK_SOURCE_ID}_2",
} }
assert result["options"] == {} assert result["options"] == {}

View File

@ -4,81 +4,56 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncIterable from collections.abc import AsyncIterable
import logging 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 async_upnp_client.exceptions import UpnpConnectionError, UpnpError
from didl_lite import didl_lite from didl_lite import didl_lite
import pytest 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.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_player.errors import BrowseError
from homeassistant.components.media_source.error import Unresolvable from homeassistant.components.media_source.error import Unresolvable
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .conftest import ( from .conftest import (
MOCK_DEVICE_LOCATION, MOCK_DEVICE_LOCATION,
MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE, MOCK_DEVICE_TYPE,
MOCK_DEVICE_UDN, MOCK_DEVICE_UDN,
MOCK_DEVICE_USN, MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
NEW_DEVICE_LOCATION, NEW_DEVICE_LOCATION,
) )
from tests.common import MockConfigEntry 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 = [ 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 @pytest.fixture
async def connected_source_mock( async def connected_source_mock(
hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None
config_entry_mock: MockConfigEntry, ) -> None:
ssdp_scanner_mock: Mock, """Fixture to set up a mock DmsDeviceSource in a connected state."""
dms_device_mock: Mock, # Make async_browse_metadata work for assert_source_available
) -> AsyncIterable[DmsDeviceSource]: didl_item = didl_lite.Item(
"""Fixture to set up a mock DmsDeviceSource in a connected state. id=DUMMY_OBJECT_ID,
restricted=False,
Yields the entity. Cleans up the entity after the test is complete. title="Object",
""" res=[didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:")],
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.async_browse_metadata.return_value = didl_item
@pytest.fixture @pytest.fixture
@ -88,30 +63,39 @@ async def disconnected_source_mock(
config_entry_mock: MockConfigEntry, config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
dms_device_mock: Mock, dms_device_mock: Mock,
) -> AsyncIterable[DmsDeviceSource]: ) -> AsyncIterable[None]:
"""Fixture to set up a mock DmsDeviceSource in a disconnected state. """Fixture to set up a mock DmsDeviceSource in a disconnected state."""
Yields the entity. Cleans up the entity after the test is complete.
"""
# Cause the connection attempt to fail # Cause the connection attempt to fail
upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError 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 # Check the DmsDeviceSource has registered all needed listeners
assert len(config_entry_mock.update_listeners) == 1 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.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 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 # Run the test
yield entity yield
# Unload config entry to clean up # Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False "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 not config_entry_mock.update_listeners
assert ( assert (
ssdp_scanner_mock.async_register_callback.await_count 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( async def test_unavailable_device(
hass: HomeAssistant, hass: HomeAssistant,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
config_entry_mock: MockConfigEntry, disconnected_source_mock: None,
) -> None: ) -> None:
"""Test a DlnaDmsEntity with out a connected DmsDevice.""" """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 # 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) upnp_factory_mock.async_create_device.assert_awaited_once_with(MOCK_DEVICE_LOCATION)
# Check SSDP notifications are registered # Check SSDP notifications are registered
@ -147,46 +135,42 @@ async def test_unavailable_device(
ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"}
) )
# Quick check of the state to verify the entity has no connected DmsDevice # Quick check of the state to verify the entity has no connected DmsDevice
assert not connected_source_mock.available await assert_source_unavailable(hass)
# Check the name matches that supplied
assert connected_source_mock.name == MOCK_DEVICE_NAME
# Check attempts to browse and resolve media give errors # Check attempts to browse and resolve media give errors
with pytest.raises(BrowseError): with pytest.raises(BrowseError, match="DMS is not connected"):
await connected_source_mock.async_browse_media("/browse_path") await media_source.async_browse_media(
with pytest.raises(BrowseError): hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//browse_path"
await connected_source_mock.async_browse_media(":browse_object") )
with pytest.raises(BrowseError): with pytest.raises(BrowseError, match="DMS is not connected"):
await connected_source_mock.async_browse_media("?browse_search") 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): with pytest.raises(Unresolvable):
await connected_source_mock.async_resolve_media("/resolve_path") await media_source.async_resolve_media(
with pytest.raises(Unresolvable): hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search"
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
async def test_become_available( async def test_become_available(
hass: HomeAssistant, hass: HomeAssistant,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
config_entry_mock: MockConfigEntry, disconnected_source_mock: None,
dms_device_mock: Mock,
) -> None: ) -> None:
"""Test a device becoming available after the entity is constructed.""" """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. # Mock device is now available.
upnp_factory_mock.async_create_device.side_effect = None upnp_factory_mock.async_create_device.side_effect = None
upnp_factory_mock.async_create_device.reset_mock() 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 # Check device was created from the supplied URL
upnp_factory_mock.async_create_device.assert_awaited_once_with(NEW_DEVICE_LOCATION) 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 # Quick check of the state to verify the entity has a connected DmsDevice
assert connected_source_mock.available await assert_source_available(hass)
# 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
async def test_alive_but_gone( async def test_alive_but_gone(
hass: HomeAssistant, hass: HomeAssistant,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
disconnected_source_mock: DmsDeviceSource, disconnected_source_mock: None,
) -> None: ) -> None:
"""Test a device sending an SSDP alive announcement, but not being connectable.""" """Test a device sending an SSDP alive announcement, but not being connectable."""
upnp_factory_mock.async_create_device.side_effect = UpnpError 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() upnp_factory_mock.async_create_device.assert_awaited()
# Device should still be unavailable # 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 # Send the same SSDP notification, expecting no extra connection attempts
upnp_factory_mock.async_create_device.reset_mock() upnp_factory_mock.async_create_device.reset_mock()
@ -262,7 +238,7 @@ async def test_alive_but_gone(
await hass.async_block_till_done() await hass.async_block_till_done()
upnp_factory_mock.async_create_device.assert_not_called() upnp_factory_mock.async_create_device.assert_not_called()
upnp_factory_mock.async_create_device.assert_not_awaited() 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 # Send an SSDP notification with a new BOOTID, indicating the device has rebooted
upnp_factory_mock.async_create_device.reset_mock() 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 # Rebooted device (seen via BOOTID) should mean a new connection attempt
upnp_factory_mock.async_create_device.assert_awaited() 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 # Send byebye message to indicate device is going away. Next alive message
# should result in a reconnect attempt even with same BOOTID. # 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 # Rebooted device (seen via byebye/alive) should mean a new connection attempt
upnp_factory_mock.async_create_device.assert_awaited() 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( async def test_multiple_ssdp_alive(
hass: HomeAssistant, hass: HomeAssistant,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
disconnected_source_mock: DmsDeviceSource, disconnected_source_mock: None,
) -> None: ) -> None:
"""Test multiple SSDP alive notifications is ok, only connects to device once.""" """Test multiple SSDP alive notifications is ok, only connects to device once."""
upnp_factory_mock.async_create_device.reset_mock() 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) upnp_factory_mock.async_create_device.assert_awaited_once_with(NEW_DEVICE_LOCATION)
# Device should be available # Device should be available
assert disconnected_source_mock.available await assert_source_available(hass)
async def test_ssdp_byebye( async def test_ssdp_byebye(
hass: HomeAssistant, hass: HomeAssistant,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
connected_source_mock: DmsDeviceSource, connected_source_mock: None,
) -> None: ) -> None:
"""Test device is disconnected when byebye is received.""" """Test device is disconnected when byebye is received."""
# First byebye will cause a disconnect # First byebye will cause a disconnect
@ -379,7 +355,7 @@ async def test_ssdp_byebye(
) )
# Device should be gone # Device should be gone
assert not connected_source_mock.available await assert_source_unavailable(hass)
# Second byebye will do nothing # Second byebye will do nothing
await ssdp_callback( await ssdp_callback(
@ -398,12 +374,11 @@ async def test_ssdp_update_seen_bootid(
hass: HomeAssistant, hass: HomeAssistant,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
disconnected_source_mock: DmsDeviceSource, disconnected_source_mock: None,
) -> None: ) -> None:
"""Test device does not reconnect when it gets ssdp:update with next bootid.""" """Test device does not reconnect when it gets ssdp:update with next bootid."""
# Start with a disconnected device # Start with a disconnected device
entity = disconnected_source_mock await assert_source_unavailable(hass)
assert not entity.available
# "Reconnect" the device # "Reconnect" the device
upnp_factory_mock.async_create_device.reset_mock() 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() await hass.async_block_till_done()
# Device should be connected # Device should be connected
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with next boot ID # Send SSDP update with next boot ID
@ -445,7 +420,7 @@ async def test_ssdp_update_seen_bootid(
await hass.async_block_till_done() await hass.async_block_till_done()
# Device was not reconnected, even with a new boot ID # 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 assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with same next boot ID, again # 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() await hass.async_block_till_done()
# Nothing should change # Nothing should change
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with bad next boot ID # 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() await hass.async_block_till_done()
# Nothing should change # Nothing should change
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
# Send a new SSDP alive with the new boot ID, device should not reconnect # 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() await hass.async_block_till_done()
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
@ -511,12 +486,11 @@ async def test_ssdp_update_missed_bootid(
hass: HomeAssistant, hass: HomeAssistant,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
disconnected_source_mock: DmsDeviceSource, disconnected_source_mock: None,
) -> None: ) -> None:
"""Test device disconnects when it gets ssdp:update bootid it wasn't expecting.""" """Test device disconnects when it gets ssdp:update bootid it wasn't expecting."""
# Start with a disconnected device # Start with a disconnected device
entity = disconnected_source_mock await assert_source_unavailable(hass)
assert not entity.available
# "Reconnect" the device # "Reconnect" the device
upnp_factory_mock.async_create_device.reset_mock() 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() await hass.async_block_till_done()
# Device should be connected # Device should be connected
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with skipped boot ID (not previously seen) # 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() await hass.async_block_till_done()
# Device should not *re*-connect yet # Device should not *re*-connect yet
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
# Send a new SSDP alive with the new boot ID, device should reconnect # 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() await hass.async_block_till_done()
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 2 assert upnp_factory_mock.async_create_device.await_count == 2
@ -582,12 +556,11 @@ async def test_ssdp_bootid(
hass: HomeAssistant, hass: HomeAssistant,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
disconnected_source_mock: DmsDeviceSource, disconnected_source_mock: None,
) -> None: ) -> None:
"""Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect.""" """Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect."""
# Start with a disconnected device # Start with a disconnected device
entity = disconnected_source_mock await assert_source_unavailable(hass)
assert not entity.available
# "Reconnect" the device # "Reconnect" the device
upnp_factory_mock.async_create_device.side_effect = None upnp_factory_mock.async_create_device.side_effect = None
@ -607,7 +580,7 @@ async def test_ssdp_bootid(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP alive with same boot ID, nothing should happen # 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() await hass.async_block_till_done()
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1 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 # 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() await hass.async_block_till_done()
assert entity.available await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 2 assert upnp_factory_mock.async_create_device.await_count == 2
async def test_repeated_connect( async def test_repeated_connect(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
connected_source_mock: DmsDeviceSource, hass: HomeAssistant,
upnp_factory_mock: Mock, upnp_factory_mock: Mock,
connected_source_mock: None,
) -> None: ) -> None:
"""Test trying to connect an already connected device is safely ignored.""" """Test trying to connect an already connected device is safely ignored."""
upnp_factory_mock.async_create_device.reset_mock() 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 # 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): with caplog.at_level(logging.DEBUG):
await disconnected_source_mock.device_connect() await device_source.device_connect()
assert "Not connecting because location is not known" == caplog.records[-1].message
assert not upnp_factory_mock.async_create_device.await_count assert not upnp_factory_mock.async_create_device.await_count
await assert_source_available(hass)
async def test_become_unavailable( async def test_become_unavailable(
hass: HomeAssistant, hass: HomeAssistant,
connected_source_mock: DmsDeviceSource, connected_source_mock: None,
dms_device_mock: Mock, dms_device_mock: Mock,
) -> None: ) -> None:
"""Test a device becoming unavailable.""" """Test a device becoming unavailable."""
@ -689,17 +650,18 @@ async def test_become_unavailable(
) )
# Check async_resolve_object currently works # 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 # Now break the network connection
dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError 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 # async_resolve_object should fail
with pytest.raises(Unresolvable): 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 # The device should now be unavailable
assert not connected_source_mock.available await assert_source_unavailable(hass)

View File

@ -1,5 +1,6 @@
"""Test the interface methods of DmsDeviceSource, except availability.""" """Test the browse and resolve methods of DmsDeviceSource."""
from collections.abc import AsyncIterable from __future__ import annotations
from typing import Final, Union from typing import Final, Union
from unittest.mock import ANY, Mock, call 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 from didl_lite import didl_lite
import pytest 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.const import DLNA_SORT_CRITERIA, DOMAIN
from homeassistant.components.dlna_dms.dms import ( from homeassistant.components.dlna_dms.dms import DidlPlayMedia
ActionError,
DeviceConnectionError,
DlnaDmsData,
DmsDeviceSource,
)
from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.media_source.error import Unresolvable from homeassistant.components.media_source.error import Unresolvable
from homeassistant.components.media_source.models import BrowseMediaSource from homeassistant.components.media_source.models import BrowseMediaSource
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import ( from .conftest import (
MOCK_DEVICE_BASE_URL, MOCK_DEVICE_BASE_URL,
MOCK_DEVICE_NAME, MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE, MOCK_DEVICE_TYPE,
MOCK_DEVICE_UDN,
MOCK_DEVICE_USN, MOCK_DEVICE_USN,
MOCK_SOURCE_ID, 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]] BrowseResultList = list[Union[didl_lite.DidlObject, didl_lite.Descriptor]]
@pytest.fixture async def async_resolve_media(
async def device_source_mock( hass: HomeAssistant, media_content_id: str
hass: HomeAssistant, ) -> DidlPlayMedia:
config_entry_mock: MockConfigEntry, """Call media_source.async_resolve_media with the test source's ID."""
ssdp_scanner_mock: Mock, result = await media_source.async_resolve_media(
dms_device_mock: Mock, hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}"
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
) )
assert MOCK_DEVICE_USN not in domain_data_mock.devices assert isinstance(result, DidlPlayMedia)
assert MOCK_SOURCE_ID not in domain_data_mock.sources return result
async def test_update_source_id( async def async_browse_media(
hass: HomeAssistant, hass: HomeAssistant,
config_entry_mock: MockConfigEntry, media_content_id: str | None,
device_source_mock: DmsDeviceSource, ) -> BrowseMediaSource:
domain_data_mock: DlnaDmsData, """Call media_source.async_browse_media with the test source's ID."""
) -> None: return await media_source.async_browse_media(
"""Test the config listener updates the source_id and source list upon title change.""" hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}"
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,
) )
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( async def test_catch_request_error_unavailable(
device_source_mock: DmsDeviceSource, hass: HomeAssistant, ssdp_scanner_mock: Mock
) -> None: ) -> None:
"""Test the device is checked for availability before trying requests.""" """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): # All attempts to use the device should give an error
await device_source_mock.async_resolve_object("id") with pytest.raises(Unresolvable, match="DMS is not connected"):
with pytest.raises(DeviceConnectionError): # Resolve object
await device_source_mock.async_resolve_path("path") await async_resolve_media(hass, ":id")
with pytest.raises(DeviceConnectionError): with pytest.raises(Unresolvable, match="DMS is not connected"):
await device_source_mock.async_resolve_search("query") # Resolve path
with pytest.raises(DeviceConnectionError): await async_resolve_media(hass, "/path")
await device_source_mock.async_browse_object("object_id") with pytest.raises(Unresolvable, match="DMS is not connected"):
with pytest.raises(DeviceConnectionError): # Resolve search
await device_source_mock.async_browse_search("query") 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( async def test_catch_request_error(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test errors when making requests to the device are handled.""" """Test errors when making requests to the device are handled."""
dms_device_mock.async_browse_metadata.side_effect = UpnpActionError( dms_device_mock.async_browse_metadata.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.NO_SUCH_OBJECT error_code=ContentDirectoryErrorCode.NO_SUCH_OBJECT
) )
with pytest.raises(ActionError, match="No such object: bad_id"): with pytest.raises(Unresolvable, match="No such object: bad_id"):
await device_source_mock.async_resolve_media(":bad_id") await async_resolve_media(hass, ":bad_id")
dms_device_mock.async_search_directory.side_effect = UpnpActionError( dms_device_mock.async_search_directory.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA
) )
with pytest.raises(ActionError, match="Invalid query: bad query"): with pytest.raises(Unresolvable, match="Invalid query: bad query"):
await device_source_mock.async_resolve_media("?bad query") await async_resolve_media(hass, "?bad query")
dms_device_mock.async_browse_metadata.side_effect = UpnpActionError( dms_device_mock.async_browse_metadata.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.CANNOT_PROCESS_REQUEST error_code=ContentDirectoryErrorCode.CANNOT_PROCESS_REQUEST
) )
with pytest.raises(DeviceConnectionError, match="Server failure: "): with pytest.raises(BrowseError, match="Server failure: "):
await device_source_mock.async_resolve_media(":good_id") await async_resolve_media(hass, ":good_id")
dms_device_mock.async_browse_metadata.side_effect = UpnpError dms_device_mock.async_browse_metadata.side_effect = UpnpError
with pytest.raises( 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 dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError
with pytest.raises( with pytest.raises(
DeviceConnectionError, match="Server disconnected: UpnpConnectionError(.*)" BrowseError, match="Server disconnected: UpnpConnectionError(.*)"
): ):
await device_source_mock.async_resolve_media(":bad_id") await async_browse_media(hass, f":{object_id}")
assert not device_source_mock.available
# 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: async def test_resolve_media_object(hass: HomeAssistant, 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:
"""Test the async_resolve_object method via async_resolve_media.""" """Test the async_resolve_object method via async_resolve_media."""
object_id: Final = "123" object_id: Final = "123"
res_url: Final = "foo/bar" 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}:")], res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")],
) )
dms_device_mock.async_browse_metadata.return_value = didl_item 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( dms_device_mock.async_browse_metadata.assert_awaited_once_with(
object_id, metadata_filter="*" object_id, metadata_filter="*"
) )
@ -251,7 +192,7 @@ async def test_resolve_media_object(
], ],
) )
dms_device_mock.async_browse_metadata.return_value = didl_item 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.url == res_abs_url
assert result.mime_type == res_mime assert result.mime_type == res_mime
assert result.didl_metadata is didl_item 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 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.url == res_abs_url
assert result.mime_type == res_mime assert result.mime_type == res_mime
assert result.didl_metadata is didl_item 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 dms_device_mock.async_browse_metadata.return_value = didl_item
with pytest.raises(Unresolvable, match="Object has no resources"): 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 # Failure case: resources are not playable
didl_item = didl_lite.Item( 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 dms_device_mock.async_browse_metadata.return_value = didl_item
with pytest.raises(Unresolvable, match="Object has no playable resources"): 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( async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test the async_resolve_path method via async_resolve_media.""" """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" path: Final = "path/to/thing"
object_ids: Final = ["path_id", "to_id", "thing_id"] object_ids: Final = ["path_id", "to_id", "thing_id"]
res_url: Final = "foo/bar" res_url: Final = "foo/bar"
@ -324,7 +265,7 @@ async def test_resolve_media_path(
title="thing", title="thing",
res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], 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 == [ assert dms_device_mock.async_search_directory.await_args_list == [
call( call(
parent_id, 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) # 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.reset_mock()
dms_device_mock.async_search_directory.side_effect = search_directory_result 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 == [ assert dms_device_mock.async_search_directory.await_args_list == [
call( call(
parent_id, parent_id,
@ -354,44 +295,14 @@ async def test_resolve_media_path(
assert result.mime_type == res_mime assert result.mime_type == res_mime
async def test_resolve_path_simple( async def test_resolve_path_browsed(hass: HomeAssistant, dms_device_mock: Mock) -> None:
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:
"""Test async_resolve_path: action error results in browsing.""" """Test async_resolve_path: action error results in browsing."""
path: Final = "path/to/thing" path: Final = "path/to/thing"
object_ids: Final = ["path_id", "to_id", "thing_id"] 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 = [] search_directory_result = []
for ob_id, ob_title in zip(object_ids, path.split("/")): for ob_id, ob_title in zip(object_ids, path.split("/")):
didl_item = didl_lite.Item( didl_item = didl_lite.Item(
@ -417,7 +328,15 @@ async def test_resolve_path_browsed(
DmsDevice.BrowseResult(browse_children_result, 3, 3, 0) 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 # All levels should have an attempted search
assert dms_device_mock.async_search_directory.await_args_list == [ assert dms_device_mock.async_search_directory.await_args_list == [
call( call(
@ -428,7 +347,7 @@ async def test_resolve_path_browsed(
) )
for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) 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 # 2nd level should also be browsed
assert dms_device_mock.async_browse_direct_children.await_args_list == [ assert dms_device_mock.async_browse_direct_children.await_args_list == [
call("path_id", metadata_filter=["id", "upnp:class", "dc:title"]) 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( async def test_resolve_path_browsed_nothing(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test async_resolve_path: action error results in browsing, but nothing found.""" """Test async_resolve_path: action error results in browsing, but nothing found."""
dms_device_mock.async_search_directory.side_effect = UpnpActionError() 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) DmsDevice.BrowseResult([], 0, 0, 0)
] ]
with pytest.raises(Unresolvable, match="No contents for thing in thing/other"): 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 # There are children, but they don't match
dms_device_mock.async_browse_direct_children.side_effect = [ 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"): 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( async def test_resolve_path_quoted(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test async_resolve_path: quotes and backslashes in the path get escaped correctly.""" """Test async_resolve_path: quotes and backslashes in the path get escaped correctly."""
dms_device_mock.async_search_directory.side_effect = [ dms_device_mock.async_search_directory.side_effect = [
DmsDevice.BrowseResult( DmsDevice.BrowseResult(
@ -484,8 +401,8 @@ async def test_resolve_path_quoted(
), ),
UpnpError("Quick abort"), UpnpError("Quick abort"),
] ]
with pytest.raises(DeviceConnectionError): with pytest.raises(Unresolvable):
await device_source_mock.async_resolve_path(r'path/quote"back\slash') await async_resolve_media(hass, r'path/quote"back\slash')
assert dms_device_mock.async_search_directory.await_args_list == [ assert dms_device_mock.async_search_directory.await_args_list == [
call( call(
"0", "0",
@ -503,7 +420,7 @@ async def test_resolve_path_quoted(
async def test_resolve_path_ambiguous( async def test_resolve_path_ambiguous(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test async_resolve_path: ambiguous results (too many matches) gives error.""" """Test async_resolve_path: ambiguous results (too many matches) gives error."""
dms_device_mock.async_search_directory.side_effect = [ dms_device_mock.async_search_directory.side_effect = [
@ -530,23 +447,21 @@ async def test_resolve_path_ambiguous(
with pytest.raises( with pytest.raises(
Unresolvable, match="Too many items found for thing in thing/other" 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( async def test_resolve_path_no_such_container(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test async_resolve_path: Explicit check for NO_SUCH_CONTAINER.""" """Test async_resolve_path: Explicit check for NO_SUCH_CONTAINER."""
dms_device_mock.async_search_directory.side_effect = UpnpActionError( dms_device_mock.async_search_directory.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.NO_SUCH_CONTAINER error_code=ContentDirectoryErrorCode.NO_SUCH_CONTAINER
) )
with pytest.raises(Unresolvable, match="No such container: 0"): 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( async def test_resolve_media_search(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test the async_resolve_search method via async_resolve_media.""" """Test the async_resolve_search method via async_resolve_media."""
res_url: Final = "foo/bar" res_url: Final = "foo/bar"
res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}" res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}"
@ -557,7 +472,7 @@ async def test_resolve_media_search(
[], 0, 0, 0 [], 0, 0, 0
) )
with pytest.raises(Unresolvable, match='Nothing found for dc:title="thing"'): 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 == [ assert dms_device_mock.async_search_directory.await_args_list == [
call( call(
container_id="0", container_id="0",
@ -578,7 +493,7 @@ async def test_resolve_media_search(
dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult(
[didl_item], 1, 1, 0 [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.url == res_abs_url
assert result.mime_type == res_mime assert result.mime_type == res_mime
assert result.didl_metadata is didl_item 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( dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult(
[didl_item], 1, 2, 0 [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.url == res_abs_url
assert result.mime_type == res_mime assert result.mime_type == res_mime
assert result.didl_metadata is didl_item 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 [didl_lite.Descriptor("id", "namespace")], 1, 1, 0
) )
with pytest.raises(Unresolvable, match="Descriptor.* is not a DidlObject"): 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( async def test_browse_media_root(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test async_browse_media with no identifier will browse the root of the device.""" """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( dms_device_mock.async_browse_metadata.return_value = didl_lite.DidlObject(
id="0", restricted="false", title="root" 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( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0 [], 0, 0, 0
) )
# No identifier (first opened in media browser) # 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.identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.title == MOCK_DEVICE_NAME assert result.title == MOCK_DEVICE_NAME
dms_device_mock.async_browse_metadata.assert_awaited_once_with( 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_metadata.reset_mock()
dms_device_mock.async_browse_direct_children.reset_mock() dms_device_mock.async_browse_direct_children.reset_mock()
# Empty string identifier # 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.identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.title == MOCK_DEVICE_NAME assert result.title == MOCK_DEVICE_NAME
dms_device_mock.async_browse_metadata.assert_awaited_once_with( 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( async def test_browse_media_object(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test async_browse_object via async_browse_media.""" """Test async_browse_object via async_browse_media."""
object_id = "1234" object_id = "1234"
child_titles = ("Item 1", "Thing", "Item 2") 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 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( dms_device_mock.async_browse_metadata.assert_awaited_once_with(
object_id, metadata_filter=ANY object_id, metadata_filter=ANY
) )
@ -687,7 +617,7 @@ async def test_browse_media_object(
async def test_browse_object_sort_anything( async def test_browse_object_sort_anything(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test sort criteria for children where device allows anything.""" """Test sort criteria for children where device allows anything."""
dms_device_mock.sort_capabilities = ["*"] 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( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0 [], 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 # Sort criteria should be dlna_dms's default
dms_device_mock.async_browse_direct_children.assert_awaited_once_with( 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( async def test_browse_object_sort_superset(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test sorting where device allows superset of integration's criteria.""" """Test sorting where device allows superset of integration's criteria."""
dms_device_mock.sort_capabilities = [ 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( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0 [], 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 # Sort criteria should be dlna_dms's default
dms_device_mock.async_browse_direct_children.assert_awaited_once_with( 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( async def test_browse_object_sort_subset(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test sorting where device allows subset of integration's criteria.""" """Test sorting where device allows subset of integration's criteria."""
dms_device_mock.sort_capabilities = [ 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( dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0 [], 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, # Sort criteria should be reduced to only those allowed,
# and in the order specified by DLNA_SORT_CRITERIA # 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( async def test_browse_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test async_browse_media with a path.""" """Test async_browse_media with a path."""
title = "folder" title = "folder"
con_id = "123" con_id = "123"
@ -776,7 +704,7 @@ async def test_browse_media_path(
[], 0, 0, 0 [], 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.identifier == f"{MOCK_SOURCE_ID}/:{con_id}"
assert result.title == title assert result.title == title
@ -794,9 +722,7 @@ async def test_browse_media_path(
) )
async def test_browse_media_search( async def test_browse_media_search(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test async_browse_media with a search query.""" """Test async_browse_media with a search query."""
query = 'dc:title contains "FooBar"' query = 'dc:title contains "FooBar"'
object_details = (("111", "FooBar baz"), ("432", "Not FooBar"), ("99", "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") 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.identifier == f"{MOCK_SOURCE_ID}/?{query}"
assert result.title == "Search results" assert result.title == "Search results"
assert result.children assert result.children
@ -827,7 +753,7 @@ async def test_browse_media_search(
async def test_browse_search_invalid( async def test_browse_search_invalid(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test searching with an invalid query gives a BrowseError.""" """Test searching with an invalid query gives a BrowseError."""
query = "title == FooBar" query = "title == FooBar"
@ -835,11 +761,11 @@ async def test_browse_search_invalid(
error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA
) )
with pytest.raises(BrowseError, match=f"Invalid query: {query}"): 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( async def test_browse_search_no_results(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock hass: HomeAssistant, dms_device_mock: Mock
) -> None: ) -> None:
"""Test a search with no results does not give an error.""" """Test a search with no results does not give an error."""
query = 'dc:title contains "FooBar"' query = 'dc:title contains "FooBar"'
@ -847,15 +773,13 @@ async def test_browse_search_no_results(
[], 0, 0, 0 [], 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.identifier == f"{MOCK_SOURCE_ID}/?{query}"
assert result.title == "Search results" assert result.title == "Search results"
assert not result.children assert not result.children
async def test_thumbnail( async def test_thumbnail(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test getting thumbnails URLs for items.""" """Test getting thumbnails URLs for items."""
# Use browse_search to get multiple items at once for least effort # Use browse_search to get multiple items at once for least effort
dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult(
@ -901,16 +825,14 @@ async def test_thumbnail(
0, 0,
) )
result = await device_source_mock.async_browse_media("?query") result = await async_browse_media(hass, "?query")
assert result.children assert result.children
assert result.children[0].thumbnail == f"{MOCK_DEVICE_BASE_URL}/a_thumb.jpg" 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[1].thumbnail == f"{MOCK_DEVICE_BASE_URL}/b_thumb.png"
assert result.children[2].thumbnail is None assert result.children[2].thumbnail is None
async def test_can_play( async def test_can_play(hass: HomeAssistant, dms_device_mock: Mock) -> None:
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test determination of playability for items.""" """Test determination of playability for items."""
protocol_infos = [ protocol_infos = [
# No protocol info for resource # No protocol info for resource
@ -945,7 +867,7 @@ async def test_can_play(
search_results, len(search_results), len(search_results), 0 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 result.children
assert not result.children[0].can_play assert not result.children[0].can_play
for idx, info_can_play in enumerate(protocol_infos): for idx, info_can_play in enumerate(protocol_infos):

View File

@ -1,18 +1,32 @@
"""Test the DLNA DMS component setup, cleanup, and module-level functions.""" """Test the DLNA DMS component setup, cleanup, and module-level functions."""
from typing import cast
from unittest.mock import Mock 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.components.dlna_dms.dms import DlnaDmsData
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component 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 from tests.common import MockConfigEntry
async def test_resource_lifecycle( async def test_resource_lifecycle(
hass: HomeAssistant, hass: HomeAssistant,
domain_data_mock: DlnaDmsData, aiohttp_session_requester_mock: Mock,
config_entry_mock: MockConfigEntry, config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock, ssdp_scanner_mock: Mock,
dms_device_mock: Mock, dms_device_mock: Mock,
@ -23,14 +37,15 @@ async def test_resource_lifecycle(
assert await async_setup_component(hass, DOMAIN, {}) is True assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done() await hass.async_block_till_done()
# Check the entity is created and working # Check the device source is created and working
assert len(domain_data_mock.devices) == 1 domain_data = cast(DlnaDmsData, hass.data[DOMAIN])
assert len(domain_data_mock.sources) == 1 assert len(domain_data.devices) == 1
entity = next(iter(domain_data_mock.devices.values())) assert len(domain_data.sources) == 1
entity = next(iter(domain_data.devices.values()))
assert entity.available is True assert entity.available is True
# Check update listeners are subscribed # Check listener subscriptions
assert len(config_entry_mock.update_listeners) == 1 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.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 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.async_unsubscribe_services.await_count == 0
assert dms_device_mock.on_event is None assert dms_device_mock.on_event is None
# Check entity is gone # Check device source is gone
assert not domain_data_mock.devices assert not domain_data.devices
assert not domain_data_mock.sources 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"

View File

@ -5,8 +5,9 @@ from async_upnp_client.exceptions import UpnpError
from didl_lite import didl_lite from didl_lite import didl_lite
import pytest import pytest
from homeassistant.components import media_source
from homeassistant.components.dlna_dms.const import DOMAIN 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 ( from homeassistant.components.dlna_dms.media_source import (
DmsMediaSource, DmsMediaSource,
async_get_media_source, async_get_media_source,
@ -24,30 +25,18 @@ from .conftest import (
MOCK_DEVICE_BASE_URL, MOCK_DEVICE_BASE_URL,
MOCK_DEVICE_NAME, MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE, MOCK_DEVICE_TYPE,
MOCK_DEVICE_USN,
MOCK_SOURCE_ID, MOCK_SOURCE_ID,
) )
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
# Auto-use a few fixtures from conftest
@pytest.fixture pytestmark = [
async def entity( # Block network access
hass: HomeAssistant, pytest.mark.usefixtures("aiohttp_session_requester_mock"),
config_entry_mock: MockConfigEntry, # Setup the media_source platform
dms_device_mock: Mock, pytest.mark.usefixtures("setup_media_source"),
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)
async def test_get_media_source(hass: HomeAssistant) -> None: 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( async def test_resolve_media_bad_identifier(
hass: HomeAssistant, dms_source: DmsMediaSource hass: HomeAssistant, device_source_mock: None
) -> None: ) -> None:
"""Test trying to resolve an item that has an unresolvable identifier.""" """Test trying to resolve an item that has an unresolvable identifier."""
# Empty identifier # Empty identifier
item = MediaSourceItem(hass, DOMAIN, "")
with pytest.raises(Unresolvable, match="No source ID.*"): 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 # Identifier has media_id but no source_id
item = MediaSourceItem(hass, DOMAIN, "/media_id") # media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms
with pytest.raises(Unresolvable, match="No source ID.*"): with pytest.raises(Unresolvable, match="Invalid media source URI"):
await dms_source.async_resolve_media(item) await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}//media_id"
)
# Identifier has source_id but no media_id # Identifier has source_id but no media_id
item = MediaSourceItem(hass, DOMAIN, "source_id/")
with pytest.raises(Unresolvable, match="No media 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 # Identifier is missing source_id/media_id separator
item = MediaSourceItem(hass, DOMAIN, "source_id")
with pytest.raises(Unresolvable, match="No media 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 # Identifier has an unknown source_id
item = MediaSourceItem(hass, DOMAIN, "unknown_source/media_id")
with pytest.raises(Unresolvable, match="Unknown source ID: unknown_source"): 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( 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: ) -> None:
"""Test resolving an item via a DmsDeviceSource.""" """Test resolving an item via a DmsDeviceSource."""
object_id = "123" object_id = "123"
item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:{object_id}")
res_url = "foo/bar" res_url = "foo/bar"
res_mime = "audio/mpeg" res_mime = "audio/mpeg"
@ -112,7 +104,10 @@ async def test_resolve_media_success(
) )
dms_device_mock.async_browse_metadata.return_value = didl_item 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.url == f"{MOCK_DEVICE_BASE_URL}/{res_url}"
assert result.mime_type == res_mime assert result.mime_type == res_mime
assert result.didl_metadata is didl_item 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( async def test_browse_media_bad_identifier(
hass: HomeAssistant, dms_source: DmsMediaSource hass: HomeAssistant, device_source_mock: None
) -> None: ) -> None:
"""Test browse_media with a bad source_id.""" """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"): 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( 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: ) -> None:
"""Test browse_media without a source_id, with a single device registered.""" """Test browse_media without a source_id, with a single device registered."""
# Fast bail-out, mock will be checked after # Fast bail-out, mock will be checked after
dms_device_mock.async_browse_metadata.side_effect = UpnpError dms_device_mock.async_browse_metadata.side_effect = UpnpError
# No source_id nor media_id # No source_id nor media_id
item = MediaSourceItem(hass, DOMAIN, "")
with pytest.raises(BrowseError): 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 # Mock device should've been browsed for the root directory
dms_device_mock.async_browse_metadata.assert_awaited_once_with( dms_device_mock.async_browse_metadata.assert_awaited_once_with(
"0", metadata_filter=ANY "0", metadata_filter=ANY
) )
# No source_id but a media_id # 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() dms_device_mock.async_browse_metadata.reset_mock()
with pytest.raises(BrowseError): with pytest.raises(BrowseError, match="Invalid media source URI"):
await dms_source.async_browse_media(item) await media_source.async_browse_media(
# Mock device should've been browsed for the root directory hass, f"media-source://{DOMAIN}//:media-item-id"
dms_device_mock.async_browse_metadata.assert_awaited_once_with( )
"media-item-id", metadata_filter=ANY assert dms_device_mock.async_browse_metadata.await_count == 0
)
async def test_browse_media_multiple_sources( 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: ) -> None:
"""Test browse_media without a source_id, with multiple devices registered.""" """Test browse_media without a source_id, with multiple devices registered."""
# Set up a second source # Set up a second source
@ -182,12 +176,12 @@ async def test_browse_media_multiple_sources(
}, },
title=other_source_title, 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() await hass.async_block_till_done()
# No source_id nor media_id # No source_id nor media_id
item = MediaSourceItem(hass, DOMAIN, "") result = await media_source.async_browse_media(hass, f"media-source://{DOMAIN}")
result = await dms_source.async_browse_media(item)
# Mock device should not have been browsed # Mock device should not have been browsed
assert dms_device_mock.async_browse_metadata.await_count == 0 assert dms_device_mock.async_browse_metadata.await_count == 0
# Result will be a list of available devices # 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 isinstance(result.children[0], BrowseMediaSource)
assert result.children[0].identifier == f"{MOCK_SOURCE_ID}/:0" assert result.children[0].identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.children[0].title == MOCK_DEVICE_NAME assert result.children[0].title == MOCK_DEVICE_NAME
assert result.children[0].thumbnail == dms_device_mock.icon
assert isinstance(result.children[1], BrowseMediaSource) assert isinstance(result.children[1], BrowseMediaSource)
assert result.children[1].identifier == f"{other_source_id}/:0" assert result.children[1].identifier == f"{other_source_id}/:0"
assert result.children[1].title == other_source_title assert result.children[1].title == other_source_title
# No source_id but a media_id - will give the exact same list of all devices # 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
result = await dms_source.async_browse_media(item) 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 # Mock device should not have been browsed
assert dms_device_mock.async_browse_metadata.await_count == 0 assert dms_device_mock.async_browse_metadata.await_count == 0
# Result will be a list of available devices
assert result.title == "DLNA Servers" # Clean up, to fulfil ssdp_scanner post-condition of every callback being cleared
assert result.children await hass.config_entries.async_remove(other_config_entry.entry_id)
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
async def test_browse_media_source_id( async def test_browse_media_source_id(
hass: HomeAssistant, hass: HomeAssistant,
config_entry_mock: MockConfigEntry, config_entry_mock: MockConfigEntry,
dms_device_mock: Mock, dms_device_mock: Mock,
domain_data_mock: DlnaDmsData,
) -> None: ) -> None:
"""Test browse_media with an explicit source_id.""" """Test browse_media with an explicit source_id."""
# Set up a second device first, then the primary mock device. # 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, 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() await hass.async_block_till_done()
# Fast bail-out, mock will be checked after # Fast bail-out, mock will be checked after