test_homeassistant_list_entities.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. """Regression tests for HomeAssistantService.list_entities domain filtering (#1388).
  2. Reporter MartinNYHC opened the Add Smart Plug modal in HA mode, typed a search
  3. matching a multi-entity device (one Shelly outlet exposed as switch + several
  4. sensor.* and binary_sensor.* siblings), and clicked a non-switch entity. The
  5. schema regex for ha_entity_id only accepts switch/light/input_boolean/script,
  6. so the Save round-trip came back 422 with the raw Pydantic pattern string —
  7. the same regex shown in the bug report screenshot.
  8. Root cause: before this fix, the search path bypassed the domain filter
  9. entirely, so the dropdown showed every entity whose entity_id or friendly_name
  10. matched the query, regardless of whether the schema would later accept it.
  11. Users could click an entity they had no way to actually save.
  12. Fix: always apply the allowed-domains filter, and apply the search filter on
  13. top of it. The two filters now compose instead of branching.
  14. """
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.services.homeassistant import HomeAssistantService
  18. def _ha_response(entities: list[dict]) -> MagicMock:
  19. response = MagicMock()
  20. response.raise_for_status = MagicMock()
  21. response.json = MagicMock(return_value=entities)
  22. return response
  23. def _mock_get(entities: list[dict]):
  24. async_client = MagicMock()
  25. async_client.get = AsyncMock(return_value=_ha_response(entities))
  26. async_client.__aenter__ = AsyncMock(return_value=async_client)
  27. async_client.__aexit__ = AsyncMock(return_value=None)
  28. return async_client
  29. @pytest.mark.asyncio
  30. async def test_no_search_returns_only_allowed_domains():
  31. """Without a search query, only switch/light/input_boolean/script appear."""
  32. entities = [
  33. {"entity_id": "switch.printer", "attributes": {"friendly_name": "Printer"}, "state": "on"},
  34. {"entity_id": "light.lamp", "attributes": {"friendly_name": "Lamp"}, "state": "off"},
  35. {"entity_id": "input_boolean.flag", "attributes": {"friendly_name": "Flag"}, "state": "on"},
  36. {"entity_id": "script.morning", "attributes": {"friendly_name": "Morning"}, "state": "off"},
  37. {"entity_id": "sensor.power", "attributes": {"friendly_name": "Power"}, "state": "12.3"},
  38. {"entity_id": "binary_sensor.status", "attributes": {"friendly_name": "Status"}, "state": "on"},
  39. {"entity_id": "media_player.tv", "attributes": {"friendly_name": "TV"}, "state": "idle"},
  40. ]
  41. service = HomeAssistantService()
  42. with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
  43. result = await service.list_entities("http://ha.local", "tok")
  44. domains = sorted({e["domain"] for e in result})
  45. assert domains == ["input_boolean", "light", "script", "switch"]
  46. assert len(result) == 4
  47. @pytest.mark.asyncio
  48. async def test_search_still_filters_to_allowed_domains():
  49. """#1388: search must compose with the domain filter, not replace it.
  50. Reporter's setup: a Shelly outlet device generates one switch.* entity
  51. and several sensor.*/binary_sensor.* siblings, all sharing a common
  52. friendly-name prefix. The user searched the prefix and was offered the
  53. non-switch siblings as clickable options — picking one led to the 422
  54. pattern error. After the fix, the search-narrowed list excludes them.
  55. """
  56. entities = [
  57. {
  58. "entity_id": "switch.prise_imprimante_3d_bambu_output_1",
  59. "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1"},
  60. "state": "on",
  61. },
  62. {
  63. "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_power",
  64. "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Puissance"},
  65. "state": "12.5",
  66. },
  67. {
  68. "entity_id": "binary_sensor.prise_imprimante_3d_bambu_output_1_status",
  69. "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Status"},
  70. "state": "on",
  71. },
  72. {
  73. "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_energy",
  74. "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Énergie"},
  75. "state": "0.42",
  76. },
  77. ]
  78. service = HomeAssistantService()
  79. with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
  80. result = await service.list_entities("http://ha.local", "tok", search="Prise imprimante")
  81. assert len(result) == 1
  82. assert result[0]["entity_id"] == "switch.prise_imprimante_3d_bambu_output_1"
  83. @pytest.mark.asyncio
  84. async def test_search_matches_by_entity_id_or_friendly_name():
  85. """Search still matches across both fields, just within the allowed set."""
  86. entities = [
  87. {"entity_id": "switch.printer_a", "attributes": {"friendly_name": "Living Room Plug"}, "state": "on"},
  88. {"entity_id": "switch.printer_b", "attributes": {"friendly_name": "Office Plug"}, "state": "off"},
  89. {"entity_id": "light.living_room", "attributes": {"friendly_name": "Ceiling"}, "state": "off"},
  90. ]
  91. service = HomeAssistantService()
  92. with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
  93. result = await service.list_entities("http://ha.local", "tok", search="living")
  94. ids = sorted(e["entity_id"] for e in result)
  95. assert ids == ["light.living_room", "switch.printer_a"]
  96. @pytest.mark.asyncio
  97. async def test_search_is_case_insensitive():
  98. entities = [
  99. {"entity_id": "switch.PRINTER", "attributes": {"friendly_name": "Printer"}, "state": "on"},
  100. ]
  101. service = HomeAssistantService()
  102. with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
  103. result = await service.list_entities("http://ha.local", "tok", search="PRINTER")
  104. assert len(result) == 1
  105. @pytest.mark.asyncio
  106. async def test_empty_search_treated_as_no_search():
  107. """A whitespace-only search string should fall back to the full allowed-
  108. domain list rather than matching everything that contains an empty string."""
  109. entities = [
  110. {"entity_id": "switch.foo", "attributes": {"friendly_name": "Foo"}, "state": "on"},
  111. {"entity_id": "sensor.bar", "attributes": {"friendly_name": "Bar"}, "state": "1"},
  112. ]
  113. service = HomeAssistantService()
  114. with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
  115. result = await service.list_entities("http://ha.local", "tok", search=" ")
  116. assert len(result) == 1
  117. assert result[0]["entity_id"] == "switch.foo"