From 390d4190874d33fdf08e09cd3aa6c425cda37a9d Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:25:45 +0200 Subject: [PATCH] bosch_shc: exercise via_device_id test through config entry setup (#178102) Co-authored-by: Claude Sonnet 5 --- tests/components/bosch_shc/conftest.py | 106 +++++++++++++++++++- tests/components/bosch_shc/test_entity.py | 115 ++++++++++++---------- 2 files changed, 166 insertions(+), 55 deletions(-) diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 1f45623e30fa..67f3f5ac407e 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -1,10 +1,114 @@ """bosch_shc session fixtures.""" -from unittest.mock import MagicMock +from collections.abc import Generator +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, create_autospec, patch +from boschshcpy import BatteryLevelService, SHCBatteryDevice import pytest +from homeassistant.components.bosch_shc.const import ( + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + DOMAIN, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def bosch_shc_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock bosch_shc config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_SSL_CERTIFICATE: "cert", + CONF_SSL_KEY: "key", + }, + unique_id="test-mac", + ) + + +# Keep in sync with binary_sensor.py's device_helper buckets — a bucket +# missing here breaks the mock_session fixture. +_EMPTY_DEVICE_BUCKETS: dict[str, list[Any]] = { + bucket: [] + for bucket in ( + "motion_detectors", + "shutter_contacts", + "shutter_contacts2", + "smoke_detectors", + "thermostats", + "twinguards", + "universal_switches", + "wallthermostats", + "water_leakage_detectors", + ) +} + + +@pytest.fixture +def device_buckets(request: pytest.FixtureRequest) -> dict[str, list[Any]]: + """device_helper buckets for the mock session. + + Empty by default; a test overrides specific buckets via + ``@pytest.mark.parametrize("device_buckets", [{...}], indirect=True)``. + """ + overrides: dict[str, list[Any]] = getattr(request, "param", {}) + return {**_EMPTY_DEVICE_BUCKETS, **overrides} + + +@pytest.fixture +def mock_session(device_buckets: dict[str, list[Any]]) -> Generator[MagicMock]: + """Mock SHCSession, patched in for the duration of the test.""" + session = MagicMock() + session.information.unique_id = "test-mac" + session.information.updateState.name = "UP_TO_DATE" + session.information.version = "2.0" + session.device_helper = SimpleNamespace(**device_buckets) + with patch("homeassistant.components.bosch_shc.SHCSession", return_value=session): + yield session + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the bosch_shc integration for testing.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +def battery_only_device( + device_id: str = "hdm:HomeMaticIP:motion1", + name: str = "Motion", + device_services: list[Any] | None = None, +) -> SHCBatteryDevice: + """Build a minimal device double for the motion_detectors bucket. + + motion_detectors only ever backs a single BatterySensor entity (unlike + shutter_contacts, which backs both a ShutterContactSensor and a + BatterySensor for the same device) — the single-entity shape keeps these + entity.py tests free of a second entity's subscribe/unsubscribe calls. + """ + device = create_autospec(SHCBatteryDevice, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.batterylevel = BatteryLevelService.State.OK + device.device_services = device_services or [] + device.manufacturer = "Bosch" + device.device_model = "MD" + device.status = "AVAILABLE" + device.deleted = False + return device diff --git a/tests/components/bosch_shc/test_entity.py b/tests/components/bosch_shc/test_entity.py index 63904273ae28..77becd322bb9 100644 --- a/tests/components/bosch_shc/test_entity.py +++ b/tests/components/bosch_shc/test_entity.py @@ -1,78 +1,85 @@ """Tests for the Bosch SHC entity base classes.""" -from unittest.mock import MagicMock +from collections.abc import Generator +from unittest.mock import MagicMock, patch -from boschshcpy import SHCDevice +import pytest from homeassistant.components.bosch_shc.const import DOMAIN -from homeassistant.components.bosch_shc.entity import SHCEntity +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from .conftest import battery_only_device, setup_integration + from tests.common import MockConfigEntry +HUB_IDENTIFIER = (DOMAIN, "test-mac") + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the binary_sensor platform.""" + with patch( + "homeassistant.components.bosch_shc.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.fixture +def motion_device(mock_session: MagicMock) -> MagicMock: + """The mock device backing the motion detector's battery sensor.""" + return mock_session.device_helper.motion_detectors[0] + + +@pytest.mark.parametrize( + "device_buckets", + [{"motion_detectors": [battery_only_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") async def test_shc_entity_via_device_id( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + motion_device: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test SHCEntity links its device to the SHC hub via via_device_id.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + """SHCEntity links its device to the SHC hub via via_device_id.""" + await setup_integration(hass, mock_config_entry) - hub_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, "root-serial")}, - manufacturer="Bosch", - name="Bosch SHC", - model="SmartHomeController", + hub_device = device_registry.async_get_device(identifiers={HUB_IDENTIFIER}) + assert hub_device is not None + + child_device = device_registry.async_get_device( + identifiers={(DOMAIN, motion_device.id)} ) - - device = MagicMock(spec=SHCDevice) - device.serial = "child-serial" - device.manufacturer = "Bosch" - device.device_model = "SWD" - device.name = "Shutter Contact" - device.id = "child-id" - device.root_device_id = "root-serial" - - entity = SHCEntity( - hass=hass, device=device, parent_id="root-serial", entry_id=entry.entry_id - ) - - assert entity.device_info is not None - assert entity.device_info["via_device_id"] == hub_device.id + assert child_device is not None + assert child_device.via_device_id == hub_device.id +@pytest.mark.parametrize( + "device_buckets", + [{"motion_detectors": [battery_only_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") async def test_shc_entity_via_device_id_mismatch( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + motion_device: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test SHCEntity sets up without a link when the hub identifier does not match. + """Setup does not crash and skips the link when the hub identifier does not match. boschshcpy may render the hub identifier and a device's root_device_id - differently, so the lookup can miss; setup must not crash in that case. + differently, so the lookup can miss. """ - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + motion_device.root_device_id = "root-serial-mismatch" - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, "hub-serial")}, - manufacturer="Bosch", - name="Bosch SHC", - model="SmartHomeController", + await setup_integration(hass, mock_config_entry) + + child_device = device_registry.async_get_device( + identifiers={(DOMAIN, motion_device.id)} ) - - device = MagicMock(spec=SHCDevice) - device.serial = "child-serial" - device.manufacturer = "Bosch" - device.device_model = "SWD" - device.name = "Shutter Contact" - device.id = "child-id" - device.root_device_id = "root-serial-mismatch" - - entity = SHCEntity( - hass=hass, device=device, parent_id="hub-serial", entry_id=entry.entry_id - ) - - assert entity.device_info is not None - assert "via_device_id" not in entity.device_info + assert child_device is not None + assert child_device.via_device_id is None