test_smart_plug_manager.py 22 KB

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