test_ha_sensors_api_1148.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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_an_entity_id_longer_than_the_column(self, async_client: AsyncClient, printer_factory):
  47. """Same column-width bound as the location sibling: the pattern alone
  48. is unbounded, and an oversized id would be a 500 on PostgreSQL."""
  49. printer = await printer_factory()
  50. response = await async_client.post(
  51. "/api/v1/ha-sensors/",
  52. json={**DOOR, "printer_id": printer.id, "entity_id": "binary_sensor." + "a" * 400},
  53. )
  54. assert response.status_code == 422
  55. @pytest.mark.asyncio
  56. @pytest.mark.integration
  57. async def test_a_row_that_predates_the_bound_is_still_readable(
  58. self, async_client: AsyncClient, db_session, printer_factory
  59. ):
  60. """The bound guards writes; it must not turn old rows into a 500.
  61. This feature shipped long before entity_id was bounded, and SQLite
  62. never enforced the column's 255, so an install that took a long id
  63. through the API has that row today. Inheriting the bound on the
  64. response model would fail response validation and take the whole list
  65. down for one row -- the same 500 the bound was added to prevent, moved
  66. to the read path.
  67. """
  68. from sqlalchemy import text
  69. printer = await printer_factory()
  70. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  71. # Same domain as the row's kind: the response model still derives the
  72. # expected kind from the id, so only the length and pattern are relaxed.
  73. legacy_id = "binary_sensor." + "a" * 400
  74. await db_session.execute(
  75. text("UPDATE printer_ha_sensors SET entity_id = :e WHERE id = :i"),
  76. {"e": legacy_id, "i": created.json()["id"]},
  77. )
  78. await db_session.commit()
  79. response = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
  80. assert response.status_code == 200
  81. assert response.json()[0]["entity_id"] == legacy_id
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory):
  85. """Switches are smart plugs; this table is read-only sensors."""
  86. printer = await printer_factory()
  87. response = await async_client.post(
  88. "/api/v1/ha-sensors/",
  89. json={**DOOR, "printer_id": printer.id, "entity_id": "switch.printer_plug"},
  90. )
  91. assert response.status_code == 422
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, printer_factory):
  95. printer = await printer_factory()
  96. response = await async_client.post(
  97. "/api/v1/ha-sensors/",
  98. json={**TEMP, "printer_id": printer.id, "kind": "binary"},
  99. )
  100. assert response.status_code == 422
  101. @pytest.mark.asyncio
  102. @pytest.mark.integration
  103. async def test_rejects_an_interlock_with_nothing_to_trigger_on(self, async_client: AsyncClient, printer_factory):
  104. """block_print without an alert condition would never fire — that reads
  105. as a broken setting, not as a no-op."""
  106. printer = await printer_factory()
  107. response = await async_client.post(
  108. "/api/v1/ha-sensors/",
  109. json={**DOOR, "printer_id": printer.id, "alert_state": None, "block_print": True},
  110. )
  111. assert response.status_code == 422
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
  115. printer = await printer_factory()
  116. payload = {**DOOR, "printer_id": printer.id}
  117. await async_client.post("/api/v1/ha-sensors/", json=payload)
  118. response = await async_client.post("/api/v1/ha-sensors/", json=payload)
  119. assert response.status_code == 400
  120. assert "already bound" in response.json()["detail"]
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_rejects_an_unknown_printer(self, async_client: AsyncClient):
  124. response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": 9999})
  125. assert response.status_code == 404
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_patch_revalidates_against_the_stored_row(self, async_client: AsyncClient, printer_factory):
  129. """The payload carries only block_print, so the coherence rule has to be
  130. re-run against the merged row, not against the patch alone."""
  131. printer = await printer_factory()
  132. created = await async_client.post(
  133. "/api/v1/ha-sensors/",
  134. json={**DOOR, "printer_id": printer.id, "alert_state": None},
  135. )
  136. sensor_id = created.json()["id"]
  137. response = await async_client.patch(f"/api/v1/ha-sensors/{sensor_id}", json={"block_print": True})
  138. assert response.status_code == 422
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, printer_factory):
  142. printer = await printer_factory()
  143. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  144. sensor_id = created.json()["id"]
  145. response = await async_client.patch(
  146. f"/api/v1/ha-sensors/{sensor_id}",
  147. json={"block_print": True, "notify_on_alert": True, "name": "Front Door"},
  148. )
  149. assert response.status_code == 200
  150. assert response.json()["block_print"] is True
  151. assert response.json()["name"] == "Front Door"
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, printer_factory):
  155. """Otherwise a later sensor reusing the id inherits this one's state."""
  156. printer = await printer_factory()
  157. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  158. sensor_id = created.json()["id"]
  159. ha_sensor_manager._readings[sensor_id] = SensorReading("on", None, True, True)
  160. response = await async_client.delete(f"/api/v1/ha-sensors/{sensor_id}")
  161. assert response.status_code == 200
  162. assert ha_sensor_manager.get_reading(sensor_id) is None
  163. class TestReadings:
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_serves_the_cached_reading(self, async_client: AsyncClient, printer_factory):
  167. printer = await printer_factory()
  168. created = await async_client.post(
  169. "/api/v1/ha-sensors/",
  170. json={**TEMP, "printer_id": printer.id, "alert_above": 35},
  171. )
  172. sensor_id = created.json()["id"]
  173. ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
  174. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  175. assert response.status_code == 200
  176. reading = response.json()[0]
  177. assert reading["value"] == 41.2
  178. assert reading["alerting"] is True
  179. assert reading["unit"] == "°C"
  180. @pytest.mark.asyncio
  181. @pytest.mark.integration
  182. async def test_unpolled_sensor_reports_unreachable_not_missing(self, async_client: AsyncClient, printer_factory):
  183. """Right after a restart the card should still list the sensor, greyed
  184. out — not drop it and reflow the layout."""
  185. printer = await printer_factory()
  186. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  187. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  188. assert len(response.json()) == 1
  189. assert response.json()[0]["reachable"] is False
  190. assert response.json()[0]["alerting"] is False
  191. @pytest.mark.asyncio
  192. @pytest.mark.integration
  193. async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, printer_factory):
  194. """An interlock the user does not want cluttering the card still works."""
  195. printer = await printer_factory()
  196. await async_client.post(
  197. "/api/v1/ha-sensors/",
  198. json={**DOOR, "printer_id": printer.id, "show_on_printer_card": False},
  199. )
  200. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  201. assert response.json() == []
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_readings_follow_sort_order(self, async_client: AsyncClient, printer_factory):
  205. printer = await printer_factory()
  206. await async_client.post(
  207. "/api/v1/ha-sensors/",
  208. json={**TEMP, "printer_id": printer.id, "sort_order": 2},
  209. )
  210. await async_client.post(
  211. "/api/v1/ha-sensors/",
  212. json={**DOOR, "printer_id": printer.id, "sort_order": 1},
  213. )
  214. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
  215. assert [r["name"] for r in response.json()] == ["Enclosure Door", "Enclosure Temp"]
  216. @pytest.mark.asyncio
  217. @pytest.mark.integration
  218. async def test_other_printers_sensors_are_not_listed(self, async_client: AsyncClient, printer_factory):
  219. one = await printer_factory()
  220. two = await printer_factory(serial_number="OTHER123", name="Second")
  221. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": one.id})
  222. response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{two.id}/readings")
  223. assert response.json() == []
  224. class TestEntityPicker:
  225. @pytest.mark.asyncio
  226. @pytest.mark.integration
  227. async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient):
  228. response = await async_client.get("/api/v1/ha-sensors/entities")
  229. assert response.status_code == 400
  230. assert "Home Assistant not configured" in response.json()["detail"]
  231. @pytest.mark.asyncio
  232. @pytest.mark.integration
  233. async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient):
  234. """Route ordering regression: /entities must not hit /{sensor_id}."""
  235. response = await async_client.get("/api/v1/ha-sensors/entities")
  236. assert response.status_code != 404
  237. class TestCascadeAndUniqueness:
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
  241. printer = await printer_factory()
  242. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  243. second = await async_client.post("/api/v1/ha-sensors/", json={**TEMP, "printer_id": printer.id})
  244. response = await async_client.patch(
  245. f"/api/v1/ha-sensors/{second.json()['id']}",
  246. json={"entity_id": DOOR["entity_id"], "kind": "binary"},
  247. )
  248. assert response.status_code == 400
  249. assert "already bound" in response.json()["detail"]
  250. @pytest.mark.asyncio
  251. @pytest.mark.integration
  252. async def test_patch_to_the_same_entity_is_not_a_clash_with_itself(
  253. self, async_client: AsyncClient, printer_factory
  254. ):
  255. printer = await printer_factory()
  256. created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  257. response = await async_client.patch(
  258. f"/api/v1/ha-sensors/{created.json()['id']}",
  259. json={"entity_id": DOOR["entity_id"], "name": "Front Door"},
  260. )
  261. assert response.status_code == 200
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_deleting_a_printer_takes_its_sensors(self, async_client: AsyncClient, printer_factory):
  265. """The relationship cascades, so no orphan row is left holding a
  266. printer_id that no longer resolves."""
  267. printer = await printer_factory()
  268. await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  269. deleted = await async_client.delete(f"/api/v1/printers/{printer.id}")
  270. assert deleted.status_code == 200
  271. listed = await async_client.get("/api/v1/ha-sensors/")
  272. assert listed.json() == []
  273. class TestSaveSurvivesHomeAssistant:
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, printer_factory):
  277. """The row is committed before the read. Reporting a failure for work
  278. that succeeded would send the user into a retry that 400s on the
  279. duplicate they just created."""
  280. printer = await printer_factory()
  281. with patch.object(ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
  282. response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
  283. assert response.status_code == 200
  284. listed = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
  285. assert len(listed.json()) == 1