test_smart_plug_manager.py 21 KB

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