test_smart_plug_manager.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  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, timezone
  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_plugs_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_plugs_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_plugs_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_plugs_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 = []
  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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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 TestGetPlugsForPrinter:
  253. """Tests for _get_plugs_for_printer — returns all plugs for a printer (#903)."""
  254. @pytest.fixture
  255. def manager(self):
  256. return SmartPlugManager()
  257. @pytest.mark.asyncio
  258. async def test_returns_empty_list_when_no_plugs(self, manager):
  259. """Verify empty list is returned when no plugs are linked to printer."""
  260. mock_db = AsyncMock()
  261. mock_result = MagicMock()
  262. mock_result.scalars.return_value.all.return_value = []
  263. mock_db.execute = AsyncMock(return_value=mock_result)
  264. result = await manager._get_plugs_for_printer(1, mock_db)
  265. assert result == []
  266. @pytest.mark.asyncio
  267. async def test_returns_single_plug_as_list(self, manager):
  268. """Verify single plug is returned in a list."""
  269. plug = MagicMock()
  270. plug.plug_type = "tasmota"
  271. mock_db = AsyncMock()
  272. mock_result = MagicMock()
  273. mock_result.scalars.return_value.all.return_value = [plug]
  274. mock_db.execute = AsyncMock(return_value=mock_result)
  275. result = await manager._get_plugs_for_printer(1, mock_db)
  276. assert result == [plug]
  277. @pytest.mark.asyncio
  278. async def test_returns_all_plugs(self, manager):
  279. """Verify all plugs are returned when multiple exist (#903)."""
  280. plug1 = MagicMock()
  281. plug1.plug_type = "homeassistant"
  282. plug1.ha_entity_id = "switch.printer"
  283. plug2 = MagicMock()
  284. plug2.plug_type = "homeassistant"
  285. plug2.ha_entity_id = "switch.filter"
  286. mock_db = AsyncMock()
  287. mock_result = MagicMock()
  288. mock_result.scalars.return_value.all.return_value = [plug1, plug2]
  289. mock_db.execute = AsyncMock(return_value=mock_result)
  290. result = await manager._get_plugs_for_printer(1, mock_db)
  291. assert result == [plug1, plug2]
  292. class TestAutoOffPersistent:
  293. """Tests for persistent auto-off behavior (Issue #826).
  294. When auto_off_persistent is True, auto_off should remain enabled after
  295. execution instead of being disabled (one-shot default).
  296. """
  297. @pytest.fixture
  298. def manager(self):
  299. return SmartPlugManager()
  300. @pytest.mark.asyncio
  301. async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
  302. """Default one-shot: auto_off should be set to False after execution."""
  303. mock_plug = MagicMock()
  304. mock_plug.id = 1
  305. mock_plug.auto_off = True
  306. mock_plug.auto_off_persistent = False
  307. mock_plug.auto_off_executed = False
  308. mock_plug.auto_off_pending = True
  309. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  310. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  311. mock_db = AsyncMock()
  312. mock_result = MagicMock()
  313. mock_result.scalar_one_or_none.return_value = mock_plug
  314. mock_db.execute = AsyncMock(return_value=mock_result)
  315. mock_db.commit = AsyncMock()
  316. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  317. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  318. await manager._mark_auto_off_executed(1)
  319. assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
  320. assert mock_plug.auto_off_pending is False
  321. assert mock_plug.auto_off_pending_since is None
  322. mock_db.commit.assert_called_once()
  323. @pytest.mark.asyncio
  324. async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
  325. """Persistent mode: auto_off should remain True after execution."""
  326. mock_plug = MagicMock()
  327. mock_plug.id = 2
  328. mock_plug.auto_off = True
  329. mock_plug.auto_off_persistent = True
  330. mock_plug.auto_off_executed = False
  331. mock_plug.auto_off_pending = True
  332. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  333. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  334. mock_db = AsyncMock()
  335. mock_result = MagicMock()
  336. mock_result.scalar_one_or_none.return_value = mock_plug
  337. mock_db.execute = AsyncMock(return_value=mock_result)
  338. mock_db.commit = AsyncMock()
  339. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  340. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  341. await manager._mark_auto_off_executed(2)
  342. assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
  343. assert mock_plug.auto_off_pending is False
  344. assert mock_plug.auto_off_pending_since is None
  345. mock_db.commit.assert_called_once()
  346. @pytest.mark.asyncio
  347. async def test_persistent_auto_off_full_cycle(self, manager):
  348. """Verify persistent auto-off survives a full print cycle.
  349. Simulates: print start → print complete → auto-off executes → next print start.
  350. auto_off should remain True throughout for persistent plugs.
  351. """
  352. mock_plug = MagicMock()
  353. mock_plug.id = 3
  354. mock_plug.name = "HA BentoBox Filter"
  355. mock_plug.plug_type = "homeassistant"
  356. mock_plug.ha_entity_id = "switch.bentobox_filter"
  357. mock_plug.ip_address = None
  358. mock_plug.username = None
  359. mock_plug.password = None
  360. mock_plug.enabled = True
  361. mock_plug.auto_on = True
  362. mock_plug.auto_off = True
  363. mock_plug.auto_off_persistent = True
  364. mock_plug.off_delay_mode = "time"
  365. mock_plug.off_delay_minutes = 1
  366. mock_plug.off_temp_threshold = 70
  367. mock_plug.printer_id = 1
  368. mock_plug.auto_off_executed = False
  369. mock_plug.auto_off_pending = False
  370. mock_plug.last_state = "OFF"
  371. mock_plug.last_checked = None
  372. mock_db = AsyncMock()
  373. mock_db.commit = AsyncMock()
  374. # Step 1: Print starts — plug turns on
  375. with (
  376. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
  377. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
  378. ):
  379. mock_get.return_value = [mock_plug]
  380. mock_service = AsyncMock()
  381. mock_service.turn_on = AsyncMock(return_value=True)
  382. mock_svc.return_value = mock_service
  383. await manager.on_print_start(printer_id=1, db=mock_db)
  384. assert mock_plug.auto_off_executed is False
  385. assert mock_plug.auto_off is True # Still enabled
  386. # Step 2: Print completes — auto-off is scheduled
  387. with (
  388. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
  389. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  390. ):
  391. mock_get.return_value = [mock_plug]
  392. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  393. mock_schedule.assert_called_once()
  394. assert mock_plug.auto_off is True # Still enabled after scheduling
  395. # Step 3: Auto-off executes via _mark_auto_off_executed
  396. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  397. mock_db2 = AsyncMock()
  398. mock_result = MagicMock()
  399. mock_result.scalar_one_or_none.return_value = mock_plug
  400. mock_db2.execute = AsyncMock(return_value=mock_result)
  401. mock_db2.commit = AsyncMock()
  402. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
  403. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  404. await manager._mark_auto_off_executed(3)
  405. # KEY ASSERTION: auto_off stays True for persistent mode
  406. assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
  407. assert mock_plug.auto_off_pending is False
  408. class TestScheduleLoop:
  409. """Tests for the schedule-based plug control."""
  410. @pytest.fixture
  411. def manager(self):
  412. return SmartPlugManager()
  413. @pytest.mark.asyncio
  414. async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
  415. """Verify scheduled on-time turns plug on."""
  416. mock_plug = MagicMock()
  417. mock_plug.id = 1
  418. mock_plug.name = "Test Plug"
  419. mock_plug.enabled = True
  420. mock_plug.schedule_enabled = True
  421. mock_plug.schedule_on_time = "08:00"
  422. mock_plug.schedule_off_time = "22:00"
  423. mock_plug.printer_id = None
  424. mock_plug.last_state = "OFF"
  425. with (
  426. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  427. patch("backend.app.core.database.async_session") as mock_session_ctx,
  428. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  429. ):
  430. # Set current time to 08:00
  431. mock_now = MagicMock()
  432. mock_now.strftime.return_value = "08:00"
  433. mock_datetime.now.return_value = mock_now
  434. mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
  435. # Set up async session mock
  436. mock_db = AsyncMock()
  437. mock_result = MagicMock()
  438. mock_result.scalars.return_value.all.return_value = [mock_plug]
  439. mock_db.execute = AsyncMock(return_value=mock_result)
  440. mock_db.commit = AsyncMock()
  441. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  442. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  443. mock_tasmota.turn_on = AsyncMock(return_value=True)
  444. await manager._check_schedules()
  445. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  446. @pytest.mark.asyncio
  447. async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
  448. """Verify scheduled off-time turns plug off."""
  449. mock_plug = MagicMock()
  450. mock_plug.id = 1
  451. mock_plug.name = "Test Plug"
  452. mock_plug.enabled = True
  453. mock_plug.schedule_enabled = True
  454. mock_plug.schedule_on_time = "08:00"
  455. mock_plug.schedule_off_time = "22:00"
  456. mock_plug.printer_id = 1
  457. mock_plug.last_state = "ON"
  458. with (
  459. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  460. patch("backend.app.core.database.async_session") as mock_session_ctx,
  461. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  462. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  463. ):
  464. # Set current time to 22:00
  465. mock_now = MagicMock()
  466. mock_now.strftime.return_value = "22:00"
  467. mock_datetime.now.return_value = mock_now
  468. mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
  469. # Set up async session mock
  470. mock_db = AsyncMock()
  471. mock_result = MagicMock()
  472. mock_result.scalars.return_value.all.return_value = [mock_plug]
  473. mock_db.execute = AsyncMock(return_value=mock_result)
  474. mock_db.commit = AsyncMock()
  475. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  476. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  477. mock_tasmota.turn_off = AsyncMock(return_value=True)
  478. mock_pm.mark_printer_offline = MagicMock()
  479. await manager._check_schedules()
  480. mock_tasmota.turn_off.assert_called_once_with(mock_plug)
  481. @pytest.mark.asyncio
  482. async def test_check_schedules_skipped_when_disabled(self, manager):
  483. """Verify schedule is skipped when schedule_enabled is False."""
  484. mock_plug = MagicMock()
  485. mock_plug.id = 1
  486. mock_plug.enabled = True
  487. mock_plug.schedule_enabled = False # Disabled
  488. with (
  489. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  490. patch("backend.app.core.database.async_session") as mock_session_ctx,
  491. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  492. ):
  493. mock_now = MagicMock()
  494. mock_now.strftime.return_value = "08:00"
  495. mock_datetime.now.return_value = mock_now
  496. # Set up async session mock - returns no plugs (filtered by schedule_enabled)
  497. mock_db = AsyncMock()
  498. mock_result = MagicMock()
  499. mock_result.scalars.return_value.all.return_value = []
  500. mock_db.execute = AsyncMock(return_value=mock_result)
  501. mock_db.commit = AsyncMock()
  502. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  503. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  504. mock_tasmota.turn_on = AsyncMock()
  505. await manager._check_schedules()
  506. mock_tasmota.turn_on.assert_not_called()
  507. class TestPendingAutoOffPersistence:
  508. """Tests for auto-off pending state persistence (restart recovery)."""
  509. @pytest.fixture
  510. def manager(self):
  511. return SmartPlugManager()
  512. @pytest.mark.asyncio
  513. async def test_resume_pending_auto_offs_temperature_mode(self, manager):
  514. """Verify temperature-based pending auto-offs are resumed on startup."""
  515. mock_plug = MagicMock()
  516. mock_plug.id = 1
  517. mock_plug.name = "Test Plug"
  518. mock_plug.ip_address = "192.168.1.100"
  519. mock_plug.username = None
  520. mock_plug.password = None
  521. mock_plug.printer_id = 1
  522. mock_plug.auto_off_pending = True
  523. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  524. mock_plug.off_delay_mode = "temperature"
  525. mock_plug.off_temp_threshold = 70
  526. with (
  527. patch("backend.app.core.database.async_session") as mock_session_ctx,
  528. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  529. ):
  530. mock_db = AsyncMock()
  531. mock_result = MagicMock()
  532. mock_result.scalars.return_value.all.return_value = [mock_plug]
  533. mock_db.execute = AsyncMock(return_value=mock_result)
  534. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  535. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  536. await manager.resume_pending_auto_offs()
  537. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  538. @pytest.mark.asyncio
  539. async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
  540. """Verify time-based pending auto-offs turn off immediately on resume."""
  541. mock_plug = MagicMock()
  542. mock_plug.id = 1
  543. mock_plug.name = "Test Plug"
  544. mock_plug.ip_address = "192.168.1.100"
  545. mock_plug.username = None
  546. mock_plug.password = None
  547. mock_plug.printer_id = 1
  548. mock_plug.auto_off_pending = True
  549. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  550. mock_plug.off_delay_mode = "time"
  551. with (
  552. patch("backend.app.core.database.async_session") as mock_session_ctx,
  553. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  554. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
  555. patch("backend.app.services.smart_plug_manager.printer_manager"),
  556. ):
  557. mock_db = AsyncMock()
  558. mock_result = MagicMock()
  559. mock_result.scalars.return_value.all.return_value = [mock_plug]
  560. mock_db.execute = AsyncMock(return_value=mock_result)
  561. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  562. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  563. mock_tasmota.turn_off = AsyncMock(return_value=True)
  564. await manager.resume_pending_auto_offs()
  565. mock_tasmota.turn_off.assert_called_once()
  566. mock_mark.assert_called_once_with(1)