test_location_ha_sensors_api.py 23 KB

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