test_location_ha_sensors_api.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. from unittest.mock import AsyncMock, patch
  2. import pytest
  3. from httpx import AsyncClient
  4. from backend.app.services.ha_sensor_manager import SensorReading
  5. from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
  6. HUMIDITY = {
  7. "name": "Drybox Humidity",
  8. "entity_id": "sensor.drybox_humidity",
  9. "kind": "numeric",
  10. "device_class": "humidity",
  11. "unit": "%",
  12. }
  13. DOOR = {
  14. "name": "Cabinet Door",
  15. "entity_id": "binary_sensor.cabinet_door",
  16. "kind": "binary",
  17. "device_class": "door",
  18. "alert_state": "on",
  19. }
  20. @pytest.fixture(autouse=True)
  21. def _no_live_ha():
  22. with patch.object(location_ha_sensor_manager, "refresh_one", AsyncMock()):
  23. yield
  24. @pytest.fixture(autouse=True)
  25. def _clean_cache():
  26. yield
  27. location_ha_sensor_manager._readings.clear()
  28. location_ha_sensor_manager._last_alerting.clear()
  29. class TestCrud:
  30. @pytest.mark.asyncio
  31. @pytest.mark.integration
  32. async def test_bind_a_humidity_sensor(self, async_client: AsyncClient, location_factory):
  33. location = await location_factory()
  34. response = await async_client.post(
  35. "/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id}
  36. )
  37. assert response.status_code == 200
  38. body = response.json()
  39. assert body["entity_id"] == "sensor.drybox_humidity"
  40. assert body["kind"] == "numeric"
  41. assert body["show_on_card"] is True
  42. assert body["notify_on_alert"] is False
  43. assert "block_print" not in body
  44. @pytest.mark.asyncio
  45. @pytest.mark.integration
  46. async def test_rejects_a_switch(self, async_client: AsyncClient, location_factory):
  47. location = await location_factory()
  48. response = await async_client.post(
  49. "/api/v1/location-ha-sensors/",
  50. json={**DOOR, "location_id": location.id, "entity_id": "switch.something"},
  51. )
  52. assert response.status_code == 422
  53. @pytest.mark.asyncio
  54. @pytest.mark.integration
  55. async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, location_factory):
  56. location = await location_factory()
  57. response = await async_client.post(
  58. "/api/v1/location-ha-sensors/",
  59. json={**HUMIDITY, "location_id": location.id, "kind": "binary"},
  60. )
  61. assert response.status_code == 422
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_rejects_a_notify_with_nothing_to_trigger_on(self, async_client: AsyncClient, location_factory):
  65. location = await location_factory()
  66. response = await async_client.post(
  67. "/api/v1/location-ha-sensors/",
  68. json={**DOOR, "location_id": location.id, "alert_state": None, "notify_on_alert": True},
  69. )
  70. assert response.status_code == 422
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, location_factory):
  74. location = await location_factory()
  75. payload = {**HUMIDITY, "location_id": location.id}
  76. await async_client.post("/api/v1/location-ha-sensors/", json=payload)
  77. response = await async_client.post("/api/v1/location-ha-sensors/", json=payload)
  78. assert response.status_code == 400
  79. assert "already bound" in response.json()["detail"]
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_rejects_an_entity_id_longer_than_the_column(self, async_client: AsyncClient, location_factory):
  83. """entity_id is bounded by max_length, not just by its pattern.
  84. The pattern's [a-z0-9_]+ is unbounded, so a direct API caller could
  85. exceed the String(255) column. SQLite stores it regardless, but
  86. PostgreSQL raises DataError, and the route only maps IntegrityError —
  87. it would come back as a 500 instead of a 422.
  88. """
  89. location = await location_factory()
  90. response = await async_client.post(
  91. "/api/v1/location-ha-sensors/",
  92. json={**HUMIDITY, "location_id": location.id, "entity_id": "sensor." + "a" * 400},
  93. )
  94. assert response.status_code == 422
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_patch_rejects_an_entity_id_longer_than_the_column(self, async_client: AsyncClient, location_factory):
  98. location = await location_factory()
  99. created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  100. response = await async_client.patch(
  101. f"/api/v1/location-ha-sensors/{created.json()['id']}",
  102. json={"entity_id": "sensor." + "b" * 400},
  103. )
  104. assert response.status_code == 422
  105. @pytest.mark.sqlite_only # an over-length value only SQLite stores
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_a_row_that_predates_the_bound_is_still_readable(
  109. self, async_client: AsyncClient, db_session, location_factory
  110. ):
  111. """The bound guards writes; it must not turn old rows into a 500.
  112. SQLite never enforced the column's 255, so an install that took a
  113. long entity_id through the API before max_length existed has that row
  114. today. Inheriting the bound on the response model would fail response
  115. validation and take the whole list down for one row -- the same 500
  116. the bound was added to prevent, moved to the read path.
  117. """
  118. from sqlalchemy import text
  119. location = await location_factory()
  120. created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  121. legacy_id = "sensor." + "a" * 400
  122. await db_session.execute(
  123. text("UPDATE location_ha_sensors SET entity_id = :e WHERE id = :i"),
  124. {"e": legacy_id, "i": created.json()["id"]},
  125. )
  126. await db_session.commit()
  127. response = await async_client.get(f"/api/v1/location-ha-sensors/?location_id={location.id}")
  128. assert response.status_code == 200
  129. assert response.json()[0]["entity_id"] == legacy_id
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_rejects_an_unknown_location(self, async_client: AsyncClient):
  133. response = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": 9999})
  134. assert response.status_code == 404
  135. @pytest.mark.asyncio
  136. @pytest.mark.integration
  137. async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, location_factory):
  138. location = await location_factory()
  139. created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  140. sensor_id = created.json()["id"]
  141. response = await async_client.patch(
  142. f"/api/v1/location-ha-sensors/{sensor_id}",
  143. json={"alert_above": 60, "notify_on_alert": True, "name": "Box Humidity"},
  144. )
  145. assert response.status_code == 200
  146. assert response.json()["notify_on_alert"] is True
  147. assert response.json()["name"] == "Box Humidity"
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, location_factory):
  151. location = await location_factory()
  152. created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  153. sensor_id = created.json()["id"]
  154. location_ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
  155. response = await async_client.delete(f"/api/v1/location-ha-sensors/{sensor_id}")
  156. assert response.status_code == 200
  157. assert location_ha_sensor_manager.get_reading(sensor_id) is None
  158. class TestReadings:
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_serves_the_cached_reading(self, async_client: AsyncClient, location_factory):
  162. location = await location_factory()
  163. created = await async_client.post(
  164. "/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id, "alert_above": 60}
  165. )
  166. sensor_id = created.json()["id"]
  167. location_ha_sensor_manager._readings[sensor_id] = SensorReading("65.0", 65.0, True, True)
  168. response = await async_client.get(f"/api/v1/location-ha-sensors/by-location/{location.id}/readings")
  169. assert response.status_code == 200
  170. reading = response.json()[0]
  171. assert reading["value"] == 65.0
  172. assert reading["alerting"] is True
  173. assert reading["unit"] == "%"
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, location_factory):
  177. location = await location_factory()
  178. await async_client.post(
  179. "/api/v1/location-ha-sensors/",
  180. json={**HUMIDITY, "location_id": location.id, "show_on_card": False},
  181. )
  182. response = await async_client.get(f"/api/v1/location-ha-sensors/by-location/{location.id}/readings")
  183. assert response.json() == []
  184. @pytest.mark.asyncio
  185. @pytest.mark.integration
  186. async def test_hidden_sensors_are_included_when_not_restricted_to_the_card(
  187. self, async_client: AsyncClient, location_factory
  188. ):
  189. location = await location_factory()
  190. await async_client.post(
  191. "/api/v1/location-ha-sensors/",
  192. json={**HUMIDITY, "location_id": location.id, "show_on_card": False},
  193. )
  194. response = await async_client.get(
  195. f"/api/v1/location-ha-sensors/by-location/{location.id}/readings?show_on_card=false"
  196. )
  197. assert len(response.json()) == 1
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_other_locations_sensors_are_not_listed(self, async_client: AsyncClient, location_factory):
  201. one = await location_factory()
  202. two = await location_factory()
  203. await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": one.id})
  204. response = await async_client.get(f"/api/v1/location-ha-sensors/by-location/{two.id}/readings")
  205. assert response.json() == []
  206. class TestEntityPicker:
  207. @pytest.mark.asyncio
  208. @pytest.mark.integration
  209. async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient):
  210. response = await async_client.get("/api/v1/location-ha-sensors/entities")
  211. assert response.status_code == 400
  212. assert "Home Assistant not configured" in response.json()["detail"]
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient):
  216. response = await async_client.get("/api/v1/location-ha-sensors/entities")
  217. assert response.status_code != 404
  218. class TestCascadeAndUniqueness:
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, location_factory):
  222. location = await location_factory()
  223. await async_client.post("/api/v1/location-ha-sensors/", json={**DOOR, "location_id": location.id})
  224. second = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  225. response = await async_client.patch(
  226. f"/api/v1/location-ha-sensors/{second.json()['id']}",
  227. json={"entity_id": DOOR["entity_id"], "kind": "binary"},
  228. )
  229. assert response.status_code == 400
  230. assert "already bound" in response.json()["detail"]
  231. # One sensor per category per location (#2824 review). The card footer and
  232. # the inventory column each pick their reading with a single `find`, so a
  233. # second sensor of the same category silently shadows the first instead of
  234. # appearing next to it. The modal prompts to replace; these cover the same
  235. # rule for a direct API caller.
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_rejects_a_second_sensor_of_the_same_category(self, async_client: AsyncClient, location_factory):
  239. location = await location_factory()
  240. await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  241. response = await async_client.post(
  242. "/api/v1/location-ha-sensors/",
  243. json={
  244. **HUMIDITY,
  245. "location_id": location.id,
  246. "name": "Second Humidity",
  247. "entity_id": "sensor.drybox_humidity_two",
  248. },
  249. )
  250. assert response.status_code == 400
  251. assert "humidity" in response.json()["detail"]
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_moisture_does_not_collide_with_humidity(self, async_client: AsyncClient, location_factory):
  255. """ "moisture" is binary wet/dry, not a humidity percentage.
  256. Treating it as the humidity category let a leak detector block the
  257. hygrometer on the same location, put "wet" in a percent-formatted
  258. column, and promised it thresholds the schema rejects for a binary
  259. sensor. It has no category, so it does not take part in this rule.
  260. """
  261. location = await location_factory()
  262. await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  263. response = await async_client.post(
  264. "/api/v1/location-ha-sensors/",
  265. json={
  266. "location_id": location.id,
  267. "name": "Drybox Leak",
  268. "entity_id": "binary_sensor.drybox_moisture",
  269. "kind": "binary",
  270. "device_class": "moisture",
  271. "alert_state": "on",
  272. },
  273. )
  274. assert response.status_code == 200
  275. @pytest.mark.asyncio
  276. @pytest.mark.integration
  277. async def test_allows_a_different_category_on_the_same_location(self, async_client: AsyncClient, location_factory):
  278. """The auto-bind flow adds temperature/humidity/battery siblings together."""
  279. location = await location_factory()
  280. await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  281. response = await async_client.post(
  282. "/api/v1/location-ha-sensors/",
  283. json={
  284. **HUMIDITY,
  285. "location_id": location.id,
  286. "name": "Drybox Temperature",
  287. "entity_id": "sensor.drybox_temperature",
  288. "device_class": "temperature",
  289. "unit": "°C",
  290. },
  291. )
  292. assert response.status_code == 200
  293. @pytest.mark.asyncio
  294. @pytest.mark.integration
  295. async def test_same_category_on_another_location_is_fine(self, async_client: AsyncClient, location_factory):
  296. one = await location_factory()
  297. two = await location_factory()
  298. await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": one.id})
  299. response = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": two.id})
  300. assert response.status_code == 200
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_repointing_a_sensor_within_its_own_category_still_works(
  304. self, async_client: AsyncClient, location_factory
  305. ):
  306. """The modal's replace flow PATCHes the existing row — it must not hit its own rule."""
  307. location = await location_factory()
  308. created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  309. response = await async_client.patch(
  310. f"/api/v1/location-ha-sensors/{created.json()['id']}",
  311. json={"entity_id": "sensor.other_humidity", "device_class": "humidity"},
  312. )
  313. assert response.status_code == 200
  314. assert response.json()["entity_id"] == "sensor.other_humidity"
  315. @pytest.mark.asyncio
  316. @pytest.mark.integration
  317. async def test_patch_cannot_collide_with_another_sensors_category(
  318. self, async_client: AsyncClient, location_factory
  319. ):
  320. location = await location_factory()
  321. await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  322. temperature = await async_client.post(
  323. "/api/v1/location-ha-sensors/",
  324. json={
  325. **HUMIDITY,
  326. "location_id": location.id,
  327. "name": "Drybox Temperature",
  328. "entity_id": "sensor.drybox_temperature",
  329. "device_class": "temperature",
  330. "unit": "°C",
  331. },
  332. )
  333. response = await async_client.patch(
  334. f"/api/v1/location-ha-sensors/{temperature.json()['id']}",
  335. json={"device_class": "humidity"},
  336. )
  337. assert response.status_code == 400
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_deleting_a_location_takes_its_sensors(self, async_client: AsyncClient, location_factory):
  341. location = await location_factory()
  342. await async_client.post("/api/v1/location-ha-sensors/", json={**DOOR, "location_id": location.id})
  343. deleted = await async_client.delete(f"/api/v1/inventory/locations/{location.id}")
  344. assert deleted.status_code == 200
  345. listed = await async_client.get("/api/v1/location-ha-sensors/")
  346. assert listed.json() == []
  347. class TestThresholdValidation:
  348. """NaN/Infinity must not get into the alert thresholds.
  349. Pydantic's lax mode coerces the strings "nan"/"inf" into real floats. A
  350. NaN threshold satisfies "notify_on_alert requires an alert condition" yet
  351. every comparison against it is False — a notification that can never fire
  352. — and it slips past the below-vs-above ordering check the same way, while
  353. responses serialize it as null so the UI shows an empty field.
  354. """
  355. @pytest.mark.asyncio
  356. @pytest.mark.integration
  357. @pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "Infinity"])
  358. async def test_create_rejects_non_finite_thresholds(self, async_client: AsyncClient, location_factory, bad):
  359. location = await location_factory()
  360. response = await async_client.post(
  361. "/api/v1/location-ha-sensors/",
  362. json={**HUMIDITY, "location_id": location.id, "alert_above": bad},
  363. )
  364. assert response.status_code == 422
  365. @pytest.mark.asyncio
  366. @pytest.mark.integration
  367. async def test_patch_rejects_non_finite_thresholds(self, async_client: AsyncClient, location_factory):
  368. location = await location_factory()
  369. created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
  370. response = await async_client.patch(
  371. f"/api/v1/location-ha-sensors/{created.json()['id']}",
  372. json={"alert_below": "nan"},
  373. )
  374. assert response.status_code == 422
  375. class TestUniqueBindingBackstop:
  376. @pytest.mark.asyncio
  377. @pytest.mark.integration
  378. async def test_the_database_itself_rejects_a_duplicate_binding(
  379. self, async_client: AsyncClient, location_factory, db_session
  380. ):
  381. """The route's duplicate check is read-then-insert; the unique index is
  382. what stops the race where two concurrent creates both pass it."""
  383. from sqlalchemy.exc import IntegrityError
  384. from backend.app.models.location_ha_sensor import LocationHASensor
  385. location = await location_factory()
  386. db_session.add(LocationHASensor(location_id=location.id, name="First", entity_id="sensor.x", kind="numeric"))
  387. await db_session.commit()
  388. db_session.add(LocationHASensor(location_id=location.id, name="Second", entity_id="sensor.x", kind="numeric"))
  389. with pytest.raises(IntegrityError):
  390. await db_session.commit()
  391. await db_session.rollback()
  392. class TestAlertDefaultsSetting:
  393. @pytest.mark.asyncio
  394. @pytest.mark.integration
  395. async def test_accepts_a_real_defaults_map(self, async_client: AsyncClient):
  396. value = '{"humidity": {"alertAbove": "60", "alertBelow": "", "notifyOnAlert": true}}'
  397. response = await async_client.put("/api/v1/settings/", json={"location_sensor_alert_defaults": value})
  398. assert response.status_code == 200
  399. fetched = await async_client.get("/api/v1/settings/")
  400. assert fetched.json()["location_sensor_alert_defaults"] == value
  401. @pytest.mark.asyncio
  402. @pytest.mark.integration
  403. async def test_caps_the_stored_length(self, async_client: AsyncClient):
  404. """The real payload is three categories × three short fields — well
  405. under 300 characters. The cap only stops a stray client from parking
  406. megabytes in the settings table; the frontend already treats anything
  407. unparseable as "use the built-ins"."""
  408. response = await async_client.put("/api/v1/settings/", json={"location_sensor_alert_defaults": "x" * 2001})
  409. assert response.status_code == 422
  410. class TestPollInterval:
  411. @pytest.mark.asyncio
  412. @pytest.mark.integration
  413. async def test_defaults_to_120_seconds(self, async_client: AsyncClient):
  414. interval = await location_ha_sensor_manager._get_poll_interval()
  415. assert interval == 120
  416. @pytest.mark.asyncio
  417. @pytest.mark.integration
  418. async def test_reads_the_configured_value(self, async_client: AsyncClient):
  419. response = await async_client.put("/api/v1/settings/", json={"location_sensor_poll_interval": 300})
  420. assert response.status_code == 200
  421. interval = await location_ha_sensor_manager._get_poll_interval()
  422. assert interval == 300
  423. @pytest.mark.asyncio
  424. @pytest.mark.integration
  425. async def test_rejects_a_value_below_the_60s_minimum(self, async_client: AsyncClient):
  426. response = await async_client.put("/api/v1/settings/", json={"location_sensor_poll_interval": 30})
  427. assert response.status_code == 422
  428. @pytest.mark.asyncio
  429. @pytest.mark.integration
  430. async def test_clamps_a_stored_value_below_the_minimum(self, async_client: AsyncClient, db_session):
  431. from backend.app.models.settings import Settings
  432. db_session.add(Settings(key="location_sensor_poll_interval", value="10"))
  433. await db_session.commit()
  434. interval = await location_ha_sensor_manager._get_poll_interval()
  435. assert interval == 60
  436. class TestSaveSurvivesHomeAssistant:
  437. @pytest.mark.asyncio
  438. @pytest.mark.integration
  439. async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, location_factory):
  440. location = await location_factory()
  441. with patch.object(location_ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
  442. response = await async_client.post(
  443. "/api/v1/location-ha-sensors/", json={**DOOR, "location_id": location.id}
  444. )
  445. assert response.status_code == 200
  446. listed = await async_client.get(f"/api/v1/location-ha-sensors/?location_id={location.id}")
  447. assert len(listed.json()) == 1