test_location_ha_sensor_manager_2824.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import asyncio
  2. from types import SimpleNamespace
  3. from unittest.mock import AsyncMock, patch
  4. import pytest
  5. from backend.app.services.location_ha_sensor_manager import LocationHASensorManager
  6. def _sensor(**overrides):
  7. base = {
  8. "id": 1,
  9. "location_id": 7,
  10. "name": "Drybox Humidity",
  11. "entity_id": "sensor.drybox_humidity",
  12. "kind": "numeric",
  13. "device_class": "humidity",
  14. "unit": "%",
  15. "alert_state": None,
  16. "alert_above": 60,
  17. "alert_below": None,
  18. "notify_on_alert": False,
  19. "last_state": None,
  20. }
  21. base.update(overrides)
  22. return SimpleNamespace(**base)
  23. class TestNotificationEdge:
  24. async def _apply(self, manager, sensor, states, notify):
  25. db = AsyncMock()
  26. db.get.return_value = SimpleNamespace(name="Drybox 1")
  27. with patch("backend.app.services.notification_service.notification_service", notify):
  28. await manager._apply(db, [sensor], states)
  29. async def test_fires_once_on_the_way_in(self):
  30. manager = LocationHASensorManager()
  31. sensor = _sensor(notify_on_alert=True)
  32. notify = AsyncMock()
  33. await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
  34. assert notify.on_location_ha_sensor_alert.await_count == 0
  35. await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
  36. assert notify.on_location_ha_sensor_alert.await_count == 1
  37. await self._apply(manager, sensor, {sensor.entity_id: {"state": "66"}}, notify)
  38. assert notify.on_location_ha_sensor_alert.await_count == 1
  39. async def test_silent_on_the_first_poll_after_a_restart(self):
  40. manager = LocationHASensorManager()
  41. sensor = _sensor(notify_on_alert=True)
  42. notify = AsyncMock()
  43. await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
  44. assert notify.on_location_ha_sensor_alert.await_count == 0
  45. assert manager.get_reading(sensor.id).alerting is True
  46. async def test_silent_when_the_sensor_opts_out(self):
  47. manager = LocationHASensorManager()
  48. sensor = _sensor(notify_on_alert=False)
  49. notify = AsyncMock()
  50. await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
  51. await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
  52. assert notify.on_location_ha_sensor_alert.await_count == 0
  53. async def test_passes_the_location_name_not_a_printer_name(self):
  54. manager = LocationHASensorManager()
  55. sensor = _sensor(notify_on_alert=True)
  56. notify = AsyncMock()
  57. await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
  58. await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
  59. notify.on_location_ha_sensor_alert.assert_awaited_once()
  60. _, kwargs = notify.on_location_ha_sensor_alert.await_args
  61. assert kwargs["location_name"] == "Drybox 1"
  62. assert kwargs["sensor_name"] == "Drybox Humidity"
  63. async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
  64. manager = LocationHASensorManager()
  65. sensor = _sensor(notify_on_alert=True)
  66. notify = AsyncMock()
  67. await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
  68. await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
  69. await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
  70. await self._apply(manager, sensor, {sensor.entity_id: {"state": "66"}}, notify)
  71. assert notify.on_location_ha_sensor_alert.await_count == 1
  72. class TestLastStatePersistence:
  73. """What goes into last_state must fit its String(64) column.
  74. A numeric entity can start reporting free text longer than the column.
  75. SQLite stores it anyway, but PostgreSQL rejects the row — and since
  76. _apply commits the whole pass at once, one such sensor would sink every
  77. sensor's update on every tick.
  78. """
  79. async def _apply(self, manager, sensor, state):
  80. db = AsyncMock()
  81. with patch("backend.app.services.notification_service.notification_service", AsyncMock()):
  82. await manager._apply(db, [sensor], {sensor.entity_id: {"state": state}})
  83. async def test_a_long_text_state_is_cut_to_the_column_width(self):
  84. manager = LocationHASensorManager()
  85. sensor = _sensor(last_changed=None, last_checked=None)
  86. long_state = "x" * 500
  87. await self._apply(manager, sensor, long_state)
  88. assert sensor.last_state == "x" * 64
  89. # The cache keeps the full state — only what is persisted is cut.
  90. assert manager.get_reading(sensor.id).state == long_state
  91. async def test_an_unchanged_long_state_is_not_a_change_on_every_poll(self):
  92. manager = LocationHASensorManager()
  93. sensor = _sensor(last_changed=None, last_checked=None)
  94. long_state = "x" * 500
  95. await self._apply(manager, sensor, long_state)
  96. first_changed = sensor.last_changed
  97. await self._apply(manager, sensor, long_state)
  98. # Comparing the stored (cut) value against the raw state would see a
  99. # difference on every poll and churn last_changed forever.
  100. assert sensor.last_changed == first_changed
  101. class TestPollLoopSurvival:
  102. """The loop must outlive a transient database error (#2824 review).
  103. _get_poll_interval() reads Settings, so the sleep leg of _poll_loop does
  104. I/O and can raise on pool exhaustion or a restarting database. Letting
  105. that escape ends the task permanently: stop() is what clears _task, so a
  106. self-terminated loop leaves it set and start() refuses to restart it.
  107. """
  108. async def test_survives_a_failing_poll_interval_lookup(self):
  109. manager = LocationHASensorManager()
  110. polls = 0
  111. async def counting_poll():
  112. nonlocal polls
  113. polls += 1
  114. if polls >= 2:
  115. raise asyncio.CancelledError # end the loop once we've proven it came back
  116. async def failing_interval():
  117. raise RuntimeError("QueuePool limit reached")
  118. with (
  119. patch.object(manager, "poll_once", counting_poll),
  120. patch.object(manager, "_get_poll_interval", failing_interval),
  121. patch("backend.app.services.location_ha_sensor_manager.POLL_INTERVAL", 0),
  122. ):
  123. await manager._poll_loop()
  124. # Without the guard the first lookup failure escapes _poll_loop and
  125. # poll_once never runs a second time.
  126. assert polls == 2
  127. async def test_a_cancel_during_the_fallback_sleep_still_stops_the_loop(self):
  128. manager = LocationHASensorManager()
  129. async def failing_interval():
  130. raise RuntimeError("database is locked")
  131. with (
  132. patch.object(manager, "poll_once", AsyncMock()),
  133. patch.object(manager, "_get_poll_interval", failing_interval),
  134. patch("backend.app.services.location_ha_sensor_manager.POLL_INTERVAL", 3600),
  135. ):
  136. task = asyncio.create_task(manager._poll_loop())
  137. await asyncio.sleep(0) # let it reach the fallback sleep
  138. task.cancel()
  139. with pytest.raises(asyncio.CancelledError):
  140. await task