test_ha_sensors_api_1148.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """Integration tests for the printer-bound Home Assistant sensor API (#1148)."""
  2. from unittest.mock import AsyncMock, patch
  3. import pytest
  4. from httpx import AsyncClient
  5. from backend.app.services.ha_sensor_manager import SensorReading, ha_sensor_manager
  6. DOOR = {
  7. "name": "Enclosure Door",
  8. "entity_id": "binary_sensor.enclosure_door",
  9. "kind": "binary",
  10. "device_class": "door",
  11. "alert_state": "on",
  12. }
  13. TEMP = {
  14. "name": "Enclosure Temp",
  15. "entity_id": "sensor.enclosure_temp",
  16. "kind": "numeric",
  17. "device_class": "temperature",
  18. "unit": "°C",
  19. }
  20. @pytest.fixture(autouse=True)
  21. def _no_live_ha():
  22. """Creating or editing a sensor reads it once; keep that off the network."""
  23. with patch.object(ha_sensor_manager, "refresh_one", AsyncMock()):
  24. yield
  25. @pytest.fixture(autouse=True)
  26. def _clean_cache():
  27. yield
  28. ha_sensor_manager._readings.clear()
  29. ha_sensor_manager._last_alerting.clear()
  30. class TestCrud:
  31. @pytest.mark.asyncio
  32. @pytest.mark.integration
  33. async def test_bind_a_door_contact(self, async_client: AsyncClient, printer_factory):
  34. printer = await printer_factory()
  35. response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  36. assert response.status_code == 200
  37. body = response.json()
  38. assert body["entity_id"] == "binary_sensor.enclosure_door"
  39. assert body["kind"] == "binary"
  40. assert body["show_on_printer_card"] is True
  41. # Display-only until the user opts in.
  42. assert body["block_print"] is False
  43. assert body["notify_on_alert"] is False
  44. @pytest.mark.asyncio
  45. @pytest.mark.integration
  46. async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory):
  47. """Switches are smart plugs; this table is read-only sensors."""
  48. printer = await printer_factory()
  49. response = await async_client.post(
  50. "/api/v1/ha-sensors/",
  51. json={**DOOR, "printer_id": printer.id, "entity_id": "switch.printer_plug"},
  52. )
  53. assert response.status_code == 422
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, printer_factory):
  57. printer = await printer_factory()
  58. response = await async_client.post(
  59. "/api/v1/ha-sensors/",
  60. json={**TEMP, "printer_id": printer.id, "kind": "binary"},
  61. )
  62. assert response.status_code == 422
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_rejects_an_interlock_with_nothing_to_trigger_on(self, async_client: AsyncClient, printer_factory):
  66. """block_print without an alert condition would never fire — that reads
  67. as a broken setting, not as a no-op."""
  68. printer = await printer_factory()
  69. response = await async_client.post(
  70. "/api/v1/ha-sensors/",
  71. json={**DOOR, "printer_id": printer.id, "alert_state": None, "block_print": True},
  72. )
  73. assert response.status_code == 422
  74. @pytest.mark.asyncio
  75. @pytest.mark.integration
  76. async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
  77. printer = await printer_factory()
  78. payload = {**DOOR, "printer_id": printer.id}
  79. await async_client.post("/api/v1/ha-sensors/", json=payload)
  80. response = await async_client.post("/api/v1/ha-sensors/", json=payload)
  81. assert response.status_code == 400
  82. assert "already bound" in response.json()["detail"]
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_rejects_an_unknown_printer(self, async_client: AsyncClient):
  86. response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": 9999})
  87. assert response.status_code == 404
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_patch_revalidates_against_the_stored_row(self, async_client: AsyncClient, printer_factory):
  91. """The payload carries only block_print, so the coherence rule has to be
  92. re-run against the merged row, not against the patch alone."""
  93. printer = await printer_factory()
  94. created = await async_client.post(
  95. "/api/v1/ha-sensors/",
  96. json={**DOOR, "printer_id": printer.id, "alert_state": None},
  97. )
  98. sensor_id = created.json()["id"]
  99. response = await async_client.patch(f"/api/v1/ha-sensors/{sensor_id}", json={"block_print": True})
  100. assert response.status_code == 422
  101. @pytest.mark.asyncio
  102. @pytest.mark.integration
  103. async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, printer_factory):
  104. printer = await printer_factory()
  105. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  106. sensor_id = created.json()["id"]
  107. response = await async_client.patch(
  108. f"/api/v1/ha-sensors/{sensor_id}",
  109. json={"block_print": True, "notify_on_alert": True, "name": "Front Door"},
  110. )
  111. assert response.status_code == 200
  112. assert response.json()["block_print"] is True
  113. assert response.json()["name"] == "Front Door"
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, printer_factory):
  117. """Otherwise a later sensor reusing the id inherits this one's state."""
  118. printer = await printer_factory()
  119. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  120. sensor_id = created.json()["id"]
  121. ha_sensor_manager._readings[sensor_id] = SensorReading("on", None, True, True)
  122. response = await async_client.delete(f"/api/v1/ha-sensors/{sensor_id}")
  123. assert response.status_code == 200
  124. assert ha_sensor_manager.get_reading(sensor_id) is None
  125. class TestReadings:
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_serves_the_cached_reading(self, async_client: AsyncClient, printer_factory):
  129. printer = await printer_factory()
  130. created = await async_client.post(
  131. "/api/v1/ha-sensors/",
  132. json={**TEMP, "printer_id": printer.id, "alert_above": 35},
  133. )
  134. sensor_id = created.json()["id"]
  135. ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
  136. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  137. assert response.status_code == 200
  138. reading = response.json()[0]
  139. assert reading["value"] == 41.2
  140. assert reading["alerting"] is True
  141. assert reading["unit"] == "°C"
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_unpolled_sensor_reports_unreachable_not_missing(self, async_client: AsyncClient, printer_factory):
  145. """Right after a restart the card should still list the sensor, greyed
  146. out — not drop it and reflow the layout."""
  147. printer = await printer_factory()
  148. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  149. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  150. assert len(response.json()) == 1
  151. assert response.json()[0]["reachable"] is False
  152. assert response.json()[0]["alerting"] is False
  153. @pytest.mark.asyncio
  154. @pytest.mark.integration
  155. async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, printer_factory):
  156. """An interlock the user does not want cluttering the card still works."""
  157. printer = await printer_factory()
  158. await async_client.post(
  159. "/api/v1/ha-sensors/",
  160. json={**DOOR, "printer_id": printer.id, "show_on_printer_card": False},
  161. )
  162. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  163. assert response.json() == []
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_readings_follow_sort_order(self, async_client: AsyncClient, printer_factory):
  167. printer = await printer_factory()
  168. await async_client.post(
  169. "/api/v1/ha-sensors/",
  170. json={**TEMP, "printer_id": printer.id, "sort_order": 2},
  171. )
  172. await async_client.post(
  173. "/api/v1/ha-sensors/",
  174. json={**DOOR, "printer_id": printer.id, "sort_order": 1},
  175. )
  176. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  177. assert [r["name"] for r in response.json()] == ["Enclosure Door", "Enclosure Temp"]
  178. @pytest.mark.asyncio
  179. @pytest.mark.integration
  180. async def test_other_printers_sensors_are_not_listed(self, async_client: AsyncClient, printer_factory):
  181. one = await printer_factory()
  182. two = await printer_factory(serial_number="OTHER123", name="Second")
  183. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": one.id})
  184. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{two.id}/readings")
  185. assert response.json() == []
  186. class TestEntityPicker:
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient):
  190. response = await async_client.get("/api/v1/ha-sensors/entities")
  191. assert response.status_code == 400
  192. assert "Home Assistant not configured" in response.json()["detail"]
  193. @pytest.mark.asyncio
  194. @pytest.mark.integration
  195. async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient):
  196. """Route ordering regression: /entities must not hit /{sensor_id}."""
  197. response = await async_client.get("/api/v1/ha-sensors/entities")
  198. assert response.status_code != 404
  199. class TestCascadeAndUniqueness:
  200. @pytest.mark.asyncio
  201. @pytest.mark.integration
  202. async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
  203. printer = await printer_factory()
  204. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  205. second = await async_client.post("/api/v1/ha-sensors/", json={**TEMP, "printer_id": printer.id})
  206. response = await async_client.patch(
  207. f"/api/v1/ha-sensors/{second.json()['id']}",
  208. json={"entity_id": DOOR["entity_id"], "kind": "binary"},
  209. )
  210. assert response.status_code == 400
  211. assert "already bound" in response.json()["detail"]
  212. @pytest.mark.asyncio
  213. @pytest.mark.integration
  214. async def test_patch_to_the_same_entity_is_not_a_clash_with_itself(
  215. self, async_client: AsyncClient, printer_factory
  216. ):
  217. printer = await printer_factory()
  218. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  219. response = await async_client.patch(
  220. f"/api/v1/ha-sensors/{created.json()['id']}",
  221. json={"entity_id": DOOR["entity_id"], "name": "Front Door"},
  222. )
  223. assert response.status_code == 200
  224. @pytest.mark.asyncio
  225. @pytest.mark.integration
  226. async def test_deleting_a_printer_takes_its_sensors(self, async_client: AsyncClient, printer_factory):
  227. """The relationship cascades, so no orphan row is left holding a
  228. printer_id that no longer resolves."""
  229. printer = await printer_factory()
  230. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  231. deleted = await async_client.delete(f"/api/v1/printers/{printer.id}")
  232. assert deleted.status_code == 200
  233. listed = await async_client.get("/api/v1/ha-sensors/")
  234. assert listed.json() == []
  235. class TestSaveSurvivesHomeAssistant:
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, printer_factory):
  239. """The row is committed before the read. Reporting a failure for work
  240. that succeeded would send the user into a retry that 400s on the
  241. duplicate they just created."""
  242. printer = await printer_factory()
  243. with patch.object(ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
  244. response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  245. assert response.status_code == 200
  246. listed = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
  247. assert len(listed.json()) == 1