test_smart_plug_manager.py 29 KB

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