test_ha_sensors_api_1148.py 15 KB

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