|
|
@@ -0,0 +1,148 @@
|
|
|
+"""Regression tests for HomeAssistantService.list_entities domain filtering (#1388).
|
|
|
+
|
|
|
+Reporter MartinNYHC opened the Add Smart Plug modal in HA mode, typed a search
|
|
|
+matching a multi-entity device (one Shelly outlet exposed as switch + several
|
|
|
+sensor.* and binary_sensor.* siblings), and clicked a non-switch entity. The
|
|
|
+schema regex for ha_entity_id only accepts switch/light/input_boolean/script,
|
|
|
+so the Save round-trip came back 422 with the raw Pydantic pattern string —
|
|
|
+the same regex shown in the bug report screenshot.
|
|
|
+
|
|
|
+Root cause: before this fix, the search path bypassed the domain filter
|
|
|
+entirely, so the dropdown showed every entity whose entity_id or friendly_name
|
|
|
+matched the query, regardless of whether the schema would later accept it.
|
|
|
+Users could click an entity they had no way to actually save.
|
|
|
+
|
|
|
+Fix: always apply the allowed-domains filter, and apply the search filter on
|
|
|
+top of it. The two filters now compose instead of branching.
|
|
|
+"""
|
|
|
+
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.services.homeassistant import HomeAssistantService
|
|
|
+
|
|
|
+
|
|
|
+def _ha_response(entities: list[dict]) -> MagicMock:
|
|
|
+ response = MagicMock()
|
|
|
+ response.raise_for_status = MagicMock()
|
|
|
+ response.json = MagicMock(return_value=entities)
|
|
|
+ return response
|
|
|
+
|
|
|
+
|
|
|
+def _mock_get(entities: list[dict]):
|
|
|
+ async_client = MagicMock()
|
|
|
+ async_client.get = AsyncMock(return_value=_ha_response(entities))
|
|
|
+ async_client.__aenter__ = AsyncMock(return_value=async_client)
|
|
|
+ async_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
+ return async_client
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_no_search_returns_only_allowed_domains():
|
|
|
+ """Without a search query, only switch/light/input_boolean/script appear."""
|
|
|
+ entities = [
|
|
|
+ {"entity_id": "switch.printer", "attributes": {"friendly_name": "Printer"}, "state": "on"},
|
|
|
+ {"entity_id": "light.lamp", "attributes": {"friendly_name": "Lamp"}, "state": "off"},
|
|
|
+ {"entity_id": "input_boolean.flag", "attributes": {"friendly_name": "Flag"}, "state": "on"},
|
|
|
+ {"entity_id": "script.morning", "attributes": {"friendly_name": "Morning"}, "state": "off"},
|
|
|
+ {"entity_id": "sensor.power", "attributes": {"friendly_name": "Power"}, "state": "12.3"},
|
|
|
+ {"entity_id": "binary_sensor.status", "attributes": {"friendly_name": "Status"}, "state": "on"},
|
|
|
+ {"entity_id": "media_player.tv", "attributes": {"friendly_name": "TV"}, "state": "idle"},
|
|
|
+ ]
|
|
|
+ service = HomeAssistantService()
|
|
|
+
|
|
|
+ with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
|
|
|
+ result = await service.list_entities("http://ha.local", "tok")
|
|
|
+
|
|
|
+ domains = sorted({e["domain"] for e in result})
|
|
|
+ assert domains == ["input_boolean", "light", "script", "switch"]
|
|
|
+ assert len(result) == 4
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_search_still_filters_to_allowed_domains():
|
|
|
+ """#1388: search must compose with the domain filter, not replace it.
|
|
|
+
|
|
|
+ Reporter's setup: a Shelly outlet device generates one switch.* entity
|
|
|
+ and several sensor.*/binary_sensor.* siblings, all sharing a common
|
|
|
+ friendly-name prefix. The user searched the prefix and was offered the
|
|
|
+ non-switch siblings as clickable options — picking one led to the 422
|
|
|
+ pattern error. After the fix, the search-narrowed list excludes them.
|
|
|
+ """
|
|
|
+ entities = [
|
|
|
+ {
|
|
|
+ "entity_id": "switch.prise_imprimante_3d_bambu_output_1",
|
|
|
+ "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1"},
|
|
|
+ "state": "on",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_power",
|
|
|
+ "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Puissance"},
|
|
|
+ "state": "12.5",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "entity_id": "binary_sensor.prise_imprimante_3d_bambu_output_1_status",
|
|
|
+ "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Status"},
|
|
|
+ "state": "on",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_energy",
|
|
|
+ "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Énergie"},
|
|
|
+ "state": "0.42",
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ service = HomeAssistantService()
|
|
|
+
|
|
|
+ with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
|
|
|
+ result = await service.list_entities("http://ha.local", "tok", search="Prise imprimante")
|
|
|
+
|
|
|
+ assert len(result) == 1
|
|
|
+ assert result[0]["entity_id"] == "switch.prise_imprimante_3d_bambu_output_1"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_search_matches_by_entity_id_or_friendly_name():
|
|
|
+ """Search still matches across both fields, just within the allowed set."""
|
|
|
+ entities = [
|
|
|
+ {"entity_id": "switch.printer_a", "attributes": {"friendly_name": "Living Room Plug"}, "state": "on"},
|
|
|
+ {"entity_id": "switch.printer_b", "attributes": {"friendly_name": "Office Plug"}, "state": "off"},
|
|
|
+ {"entity_id": "light.living_room", "attributes": {"friendly_name": "Ceiling"}, "state": "off"},
|
|
|
+ ]
|
|
|
+ service = HomeAssistantService()
|
|
|
+
|
|
|
+ with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
|
|
|
+ result = await service.list_entities("http://ha.local", "tok", search="living")
|
|
|
+
|
|
|
+ ids = sorted(e["entity_id"] for e in result)
|
|
|
+ assert ids == ["light.living_room", "switch.printer_a"]
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_search_is_case_insensitive():
|
|
|
+ entities = [
|
|
|
+ {"entity_id": "switch.PRINTER", "attributes": {"friendly_name": "Printer"}, "state": "on"},
|
|
|
+ ]
|
|
|
+ service = HomeAssistantService()
|
|
|
+
|
|
|
+ with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
|
|
|
+ result = await service.list_entities("http://ha.local", "tok", search="PRINTER")
|
|
|
+
|
|
|
+ assert len(result) == 1
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_empty_search_treated_as_no_search():
|
|
|
+ """A whitespace-only search string should fall back to the full allowed-
|
|
|
+ domain list rather than matching everything that contains an empty string."""
|
|
|
+ entities = [
|
|
|
+ {"entity_id": "switch.foo", "attributes": {"friendly_name": "Foo"}, "state": "on"},
|
|
|
+ {"entity_id": "sensor.bar", "attributes": {"friendly_name": "Bar"}, "state": "1"},
|
|
|
+ ]
|
|
|
+ service = HomeAssistantService()
|
|
|
+
|
|
|
+ with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
|
|
|
+ result = await service.list_entities("http://ha.local", "tok", search=" ")
|
|
|
+
|
|
|
+ assert len(result) == 1
|
|
|
+ assert result[0]["entity_id"] == "switch.foo"
|