test_smart_plug_manager.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. """Unit tests for SmartPlugManager service.
  2. These tests specifically target the auto-off behavior and toggle functionality
  3. that were identified as common regression points.
  4. """
  5. from datetime import datetime
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from backend.app.services.smart_plug_manager import SmartPlugManager
  9. class TestSmartPlugManager:
  10. """Tests for SmartPlugManager class."""
  11. @pytest.fixture
  12. def manager(self):
  13. """Create a fresh SmartPlugManager instance."""
  14. return SmartPlugManager()
  15. @pytest.fixture
  16. def mock_plug(self):
  17. """Create a mock SmartPlug object."""
  18. plug = MagicMock()
  19. plug.id = 1
  20. plug.name = "Test Plug"
  21. plug.ip_address = "192.168.1.100"
  22. plug.username = None
  23. plug.password = None
  24. plug.enabled = True
  25. plug.auto_on = True
  26. plug.auto_off = True
  27. plug.off_delay_mode = "time"
  28. plug.off_delay_minutes = 5
  29. plug.off_temp_threshold = 70
  30. plug.printer_id = 1
  31. plug.auto_off_executed = False
  32. plug.auto_off_pending = False
  33. plug.last_state = "ON"
  34. plug.last_checked = None
  35. return plug
  36. @pytest.fixture
  37. def mock_db(self):
  38. """Create a mock database session."""
  39. db = AsyncMock()
  40. db.commit = AsyncMock()
  41. db.refresh = AsyncMock()
  42. return db
  43. # ========================================================================
  44. # Tests for on_print_start
  45. # ========================================================================
  46. @pytest.mark.asyncio
  47. async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
  48. """Verify plug is turned ON when print starts with auto_on enabled."""
  49. with (
  50. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  51. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  52. ):
  53. mock_get_plug.return_value = mock_plug
  54. mock_tasmota.turn_on = AsyncMock(return_value=True)
  55. await manager.on_print_start(printer_id=1, db=mock_db)
  56. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  57. @pytest.mark.asyncio
  58. async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db):
  59. """Verify plug is NOT turned on when auto_on is disabled."""
  60. mock_plug.auto_on = False
  61. with (
  62. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  63. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  64. ):
  65. mock_get_plug.return_value = mock_plug
  66. mock_tasmota.turn_on = AsyncMock()
  67. await manager.on_print_start(printer_id=1, db=mock_db)
  68. mock_tasmota.turn_on.assert_not_called()
  69. @pytest.mark.asyncio
  70. async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  71. """Verify plug is NOT turned on when plug.enabled is False."""
  72. mock_plug.enabled = False
  73. with (
  74. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  75. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  76. ):
  77. mock_get_plug.return_value = mock_plug
  78. mock_tasmota.turn_on = AsyncMock()
  79. await manager.on_print_start(printer_id=1, db=mock_db)
  80. mock_tasmota.turn_on.assert_not_called()
  81. @pytest.mark.asyncio
  82. async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):
  83. """Verify graceful handling when no plug is linked to printer."""
  84. with (
  85. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  86. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  87. ):
  88. mock_get_plug.return_value = None
  89. mock_tasmota.turn_on = AsyncMock()
  90. # Should not raise any exception
  91. await manager.on_print_start(printer_id=999, db=mock_db)
  92. mock_tasmota.turn_on.assert_not_called()
  93. @pytest.mark.asyncio
  94. async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db):
  95. """Verify starting a new print cancels any pending auto-off."""
  96. # Set up a pending task
  97. mock_task = MagicMock()
  98. manager._pending_off[mock_plug.id] = mock_task
  99. with (
  100. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  101. patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
  102. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  103. ):
  104. mock_get_plug.return_value = mock_plug
  105. mock_tasmota.turn_on = AsyncMock(return_value=True)
  106. await manager.on_print_start(printer_id=1, db=mock_db)
  107. mock_task.cancel.assert_called_once()
  108. assert mock_plug.id not in manager._pending_off
  109. @pytest.mark.asyncio
  110. async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db):
  111. """Verify auto_off_executed flag is reset when turning on."""
  112. mock_plug.auto_off_executed = True
  113. with (
  114. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  115. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  116. ):
  117. mock_get_plug.return_value = mock_plug
  118. mock_tasmota.turn_on = AsyncMock(return_value=True)
  119. await manager.on_print_start(printer_id=1, db=mock_db)
  120. assert mock_plug.auto_off_executed is False
  121. # ========================================================================
  122. # Tests for on_print_complete
  123. # ========================================================================
  124. @pytest.mark.asyncio
  125. async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db):
  126. """Verify time-based auto-off is scheduled when print completes."""
  127. mock_plug.off_delay_mode = "time"
  128. mock_plug.off_delay_minutes = 5
  129. with (
  130. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  131. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  132. ):
  133. mock_get_plug.return_value = mock_plug
  134. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  135. mock_schedule.assert_called_once_with(mock_plug, 1, 300) # 5 min * 60 sec
  136. @pytest.mark.asyncio
  137. async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db):
  138. """Verify temperature-based auto-off is scheduled when print completes."""
  139. mock_plug.off_delay_mode = "temperature"
  140. mock_plug.off_temp_threshold = 70
  141. with (
  142. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  143. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  144. ):
  145. mock_get_plug.return_value = mock_plug
  146. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  147. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  148. @pytest.mark.asyncio
  149. async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db):
  150. """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
  151. This is a key regression test - the toggle must respect the setting.
  152. """
  153. mock_plug.auto_off = False
  154. with (
  155. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  156. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  157. patch.object(manager, "_schedule_temp_based_off") as mock_temp,
  158. ):
  159. mock_get_plug.return_value = mock_plug
  160. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  161. mock_schedule.assert_not_called()
  162. mock_temp.assert_not_called()
  163. @pytest.mark.asyncio
  164. async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  165. """Verify auto-off does NOT trigger when plug is disabled."""
  166. mock_plug.enabled = False
  167. with (
  168. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  169. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  170. ):
  171. mock_get_plug.return_value = mock_plug
  172. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  173. mock_schedule.assert_not_called()
  174. @pytest.mark.asyncio
  175. async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):
  176. """Verify auto-off does NOT trigger on failed prints for investigation."""
  177. with (
  178. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  179. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  180. ):
  181. mock_get_plug.return_value = mock_plug
  182. await manager.on_print_complete(printer_id=1, status="failed", db=mock_db)
  183. mock_schedule.assert_not_called()
  184. @pytest.mark.asyncio
  185. async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):
  186. """Verify auto-off does NOT trigger on aborted prints."""
  187. with (
  188. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  189. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  190. ):
  191. mock_get_plug.return_value = mock_plug
  192. await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db)
  193. mock_schedule.assert_not_called()
  194. # ========================================================================
  195. # Tests for _cancel_pending_off
  196. # ========================================================================
  197. @pytest.mark.asyncio
  198. async def test_cancel_pending_off_removes_task(self, manager, mock_plug):
  199. """Verify pending off tasks can be cancelled."""
  200. mock_task = MagicMock()
  201. manager._pending_off[mock_plug.id] = mock_task
  202. with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
  203. manager._cancel_pending_off(mock_plug.id)
  204. assert mock_plug.id not in manager._pending_off
  205. mock_task.cancel.assert_called_once()
  206. @pytest.mark.asyncio
  207. async def test_cancel_pending_off_handles_missing_task(self, manager):
  208. """Verify no error when cancelling non-existent task."""
  209. # Should not raise any exception
  210. with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
  211. manager._cancel_pending_off(999) # Non-existent plug ID
  212. @pytest.mark.asyncio
  213. async def test_cancel_all_pending(self, manager, mock_plug):
  214. """Verify all pending tasks can be cancelled."""
  215. mock_task1 = MagicMock()
  216. mock_task2 = MagicMock()
  217. manager._pending_off[1] = mock_task1
  218. manager._pending_off[2] = mock_task2
  219. with patch("asyncio.create_task"):
  220. manager.cancel_all_pending()
  221. assert len(manager._pending_off) == 0
  222. mock_task1.cancel.assert_called_once()
  223. mock_task2.cancel.assert_called_once()
  224. # ========================================================================
  225. # Tests for scheduler
  226. # ========================================================================
  227. def test_start_scheduler(self, manager):
  228. """Verify scheduler can be started."""
  229. assert manager._scheduler_task is None
  230. # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
  231. with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
  232. mock_create.return_value = MagicMock()
  233. manager.start_scheduler()
  234. assert manager._scheduler_task is not None
  235. mock_loop.assert_called_once()
  236. def test_stop_scheduler(self, manager):
  237. """Verify scheduler can be stopped."""
  238. mock_task = MagicMock()
  239. manager._scheduler_task = mock_task
  240. manager.stop_scheduler()
  241. mock_task.cancel.assert_called_once()
  242. assert manager._scheduler_task is None
  243. def test_start_scheduler_idempotent(self, manager):
  244. """Verify starting scheduler twice doesn't create multiple tasks."""
  245. mock_task = MagicMock()
  246. manager._scheduler_task = mock_task
  247. # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
  248. with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
  249. manager.start_scheduler()
  250. mock_create.assert_not_called() # Should not create new task
  251. mock_loop.assert_not_called() # Should not call _schedule_loop
  252. class TestScheduleLoop:
  253. """Tests for the schedule-based plug control."""
  254. @pytest.fixture
  255. def manager(self):
  256. return SmartPlugManager()
  257. @pytest.mark.asyncio
  258. async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
  259. """Verify scheduled on-time turns plug on."""
  260. mock_plug = MagicMock()
  261. mock_plug.id = 1
  262. mock_plug.name = "Test Plug"
  263. mock_plug.enabled = True
  264. mock_plug.schedule_enabled = True
  265. mock_plug.schedule_on_time = "08:00"
  266. mock_plug.schedule_off_time = "22:00"
  267. mock_plug.printer_id = None
  268. mock_plug.last_state = "OFF"
  269. with (
  270. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  271. patch("backend.app.core.database.async_session") as mock_session_ctx,
  272. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  273. ):
  274. # Set current time to 08:00
  275. mock_now = MagicMock()
  276. mock_now.strftime.return_value = "08:00"
  277. mock_datetime.now.return_value = mock_now
  278. mock_datetime.utcnow.return_value = datetime.utcnow()
  279. # Set up async session mock
  280. mock_db = AsyncMock()
  281. mock_result = MagicMock()
  282. mock_result.scalars.return_value.all.return_value = [mock_plug]
  283. mock_db.execute = AsyncMock(return_value=mock_result)
  284. mock_db.commit = AsyncMock()
  285. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  286. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  287. mock_tasmota.turn_on = AsyncMock(return_value=True)
  288. await manager._check_schedules()
  289. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  290. @pytest.mark.asyncio
  291. async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
  292. """Verify scheduled off-time turns plug off."""
  293. mock_plug = MagicMock()
  294. mock_plug.id = 1
  295. mock_plug.name = "Test Plug"
  296. mock_plug.enabled = True
  297. mock_plug.schedule_enabled = True
  298. mock_plug.schedule_on_time = "08:00"
  299. mock_plug.schedule_off_time = "22:00"
  300. mock_plug.printer_id = 1
  301. mock_plug.last_state = "ON"
  302. with (
  303. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  304. patch("backend.app.core.database.async_session") as mock_session_ctx,
  305. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  306. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  307. ):
  308. # Set current time to 22:00
  309. mock_now = MagicMock()
  310. mock_now.strftime.return_value = "22:00"
  311. mock_datetime.now.return_value = mock_now
  312. mock_datetime.utcnow.return_value = datetime.utcnow()
  313. # Set up async session mock
  314. mock_db = AsyncMock()
  315. mock_result = MagicMock()
  316. mock_result.scalars.return_value.all.return_value = [mock_plug]
  317. mock_db.execute = AsyncMock(return_value=mock_result)
  318. mock_db.commit = AsyncMock()
  319. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  320. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  321. mock_tasmota.turn_off = AsyncMock(return_value=True)
  322. mock_pm.mark_printer_offline = MagicMock()
  323. await manager._check_schedules()
  324. mock_tasmota.turn_off.assert_called_once_with(mock_plug)
  325. @pytest.mark.asyncio
  326. async def test_check_schedules_skipped_when_disabled(self, manager):
  327. """Verify schedule is skipped when schedule_enabled is False."""
  328. mock_plug = MagicMock()
  329. mock_plug.id = 1
  330. mock_plug.enabled = True
  331. mock_plug.schedule_enabled = False # Disabled
  332. with (
  333. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  334. patch("backend.app.core.database.async_session") as mock_session_ctx,
  335. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  336. ):
  337. mock_now = MagicMock()
  338. mock_now.strftime.return_value = "08:00"
  339. mock_datetime.now.return_value = mock_now
  340. # Set up async session mock - returns no plugs (filtered by schedule_enabled)
  341. mock_db = AsyncMock()
  342. mock_result = MagicMock()
  343. mock_result.scalars.return_value.all.return_value = []
  344. mock_db.execute = AsyncMock(return_value=mock_result)
  345. mock_db.commit = AsyncMock()
  346. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  347. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  348. mock_tasmota.turn_on = AsyncMock()
  349. await manager._check_schedules()
  350. mock_tasmota.turn_on.assert_not_called()
  351. class TestPendingAutoOffPersistence:
  352. """Tests for auto-off pending state persistence (restart recovery)."""
  353. @pytest.fixture
  354. def manager(self):
  355. return SmartPlugManager()
  356. @pytest.mark.asyncio
  357. async def test_resume_pending_auto_offs_temperature_mode(self, manager):
  358. """Verify temperature-based pending auto-offs are resumed on startup."""
  359. mock_plug = MagicMock()
  360. mock_plug.id = 1
  361. mock_plug.name = "Test Plug"
  362. mock_plug.ip_address = "192.168.1.100"
  363. mock_plug.username = None
  364. mock_plug.password = None
  365. mock_plug.printer_id = 1
  366. mock_plug.auto_off_pending = True
  367. mock_plug.auto_off_pending_since = datetime.utcnow()
  368. mock_plug.off_delay_mode = "temperature"
  369. mock_plug.off_temp_threshold = 70
  370. with (
  371. patch("backend.app.core.database.async_session") as mock_session_ctx,
  372. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  373. ):
  374. mock_db = AsyncMock()
  375. mock_result = MagicMock()
  376. mock_result.scalars.return_value.all.return_value = [mock_plug]
  377. mock_db.execute = AsyncMock(return_value=mock_result)
  378. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  379. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  380. await manager.resume_pending_auto_offs()
  381. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  382. @pytest.mark.asyncio
  383. async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
  384. """Verify time-based pending auto-offs turn off immediately on resume."""
  385. mock_plug = MagicMock()
  386. mock_plug.id = 1
  387. mock_plug.name = "Test Plug"
  388. mock_plug.ip_address = "192.168.1.100"
  389. mock_plug.username = None
  390. mock_plug.password = None
  391. mock_plug.printer_id = 1
  392. mock_plug.auto_off_pending = True
  393. mock_plug.auto_off_pending_since = datetime.utcnow()
  394. mock_plug.off_delay_mode = "time"
  395. with (
  396. patch("backend.app.core.database.async_session") as mock_session_ctx,
  397. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  398. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
  399. patch("backend.app.services.smart_plug_manager.printer_manager"),
  400. ):
  401. mock_db = AsyncMock()
  402. mock_result = MagicMock()
  403. mock_result.scalars.return_value.all.return_value = [mock_plug]
  404. mock_db.execute = AsyncMock(return_value=mock_result)
  405. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  406. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  407. mock_tasmota.turn_off = AsyncMock(return_value=True)
  408. await manager.resume_pending_auto_offs()
  409. mock_tasmota.turn_off.assert_called_once()
  410. mock_mark.assert_called_once_with(1)