| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- """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"
|