test_smart_plug_manager.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  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_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  51. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  52. ):
  53. mock_get_plug.return_value = mock_plug
  54. mock_tasmota.turn_on = AsyncMock(return_value=True)
  55. await manager.on_print_start(printer_id=1, db=mock_db)
  56. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  57. @pytest.mark.asyncio
  58. async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db):
  59. """Verify plug is NOT turned on when auto_on is disabled."""
  60. mock_plug.auto_on = False
  61. with (
  62. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  63. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  64. ):
  65. mock_get_plug.return_value = mock_plug
  66. mock_tasmota.turn_on = AsyncMock()
  67. await manager.on_print_start(printer_id=1, db=mock_db)
  68. mock_tasmota.turn_on.assert_not_called()
  69. @pytest.mark.asyncio
  70. async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  71. """Verify plug is NOT turned on when plug.enabled is False."""
  72. mock_plug.enabled = False
  73. with (
  74. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  75. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  76. ):
  77. mock_get_plug.return_value = mock_plug
  78. mock_tasmota.turn_on = AsyncMock()
  79. await manager.on_print_start(printer_id=1, db=mock_db)
  80. mock_tasmota.turn_on.assert_not_called()
  81. @pytest.mark.asyncio
  82. async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):
  83. """Verify graceful handling when no plug is linked to printer."""
  84. with (
  85. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  86. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  87. ):
  88. mock_get_plug.return_value = None
  89. mock_tasmota.turn_on = AsyncMock()
  90. # Should not raise any exception
  91. await manager.on_print_start(printer_id=999, db=mock_db)
  92. mock_tasmota.turn_on.assert_not_called()
  93. @pytest.mark.asyncio
  94. async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db):
  95. """Verify starting a new print cancels any pending auto-off."""
  96. # Set up a pending task
  97. mock_task = MagicMock()
  98. manager._pending_off[mock_plug.id] = mock_task
  99. with (
  100. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  101. patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
  102. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  103. ):
  104. mock_get_plug.return_value = mock_plug
  105. mock_tasmota.turn_on = AsyncMock(return_value=True)
  106. await manager.on_print_start(printer_id=1, db=mock_db)
  107. mock_task.cancel.assert_called_once()
  108. assert mock_plug.id not in manager._pending_off
  109. @pytest.mark.asyncio
  110. async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db):
  111. """Verify auto_off_executed flag is reset when turning on."""
  112. mock_plug.auto_off_executed = True
  113. with (
  114. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  115. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  116. ):
  117. mock_get_plug.return_value = mock_plug
  118. mock_tasmota.turn_on = AsyncMock(return_value=True)
  119. await manager.on_print_start(printer_id=1, db=mock_db)
  120. assert mock_plug.auto_off_executed is False
  121. # ========================================================================
  122. # Tests for on_print_complete
  123. # ========================================================================
  124. @pytest.mark.asyncio
  125. async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db):
  126. """Verify time-based auto-off is scheduled when print completes."""
  127. mock_plug.off_delay_mode = "time"
  128. mock_plug.off_delay_minutes = 5
  129. with (
  130. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  131. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  132. ):
  133. mock_get_plug.return_value = mock_plug
  134. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  135. mock_schedule.assert_called_once_with(mock_plug, 1, 300) # 5 min * 60 sec
  136. @pytest.mark.asyncio
  137. async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db):
  138. """Verify temperature-based auto-off is scheduled when print completes."""
  139. mock_plug.off_delay_mode = "temperature"
  140. mock_plug.off_temp_threshold = 70
  141. with (
  142. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  143. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  144. ):
  145. mock_get_plug.return_value = mock_plug
  146. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  147. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  148. @pytest.mark.asyncio
  149. async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db):
  150. """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
  151. This is a key regression test - the toggle must respect the setting.
  152. """
  153. mock_plug.auto_off = False
  154. with (
  155. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  156. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  157. patch.object(manager, "_schedule_temp_based_off") as mock_temp,
  158. ):
  159. mock_get_plug.return_value = mock_plug
  160. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  161. mock_schedule.assert_not_called()
  162. mock_temp.assert_not_called()
  163. @pytest.mark.asyncio
  164. async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  165. """Verify auto-off does NOT trigger when plug is disabled."""
  166. mock_plug.enabled = False
  167. with (
  168. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  169. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  170. ):
  171. mock_get_plug.return_value = mock_plug
  172. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  173. mock_schedule.assert_not_called()
  174. @pytest.mark.asyncio
  175. async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):
  176. """Verify auto-off does NOT trigger on failed prints for investigation."""
  177. with (
  178. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  179. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  180. ):
  181. mock_get_plug.return_value = mock_plug
  182. await manager.on_print_complete(printer_id=1, status="failed", db=mock_db)
  183. mock_schedule.assert_not_called()
  184. @pytest.mark.asyncio
  185. async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):
  186. """Verify auto-off does NOT trigger on aborted prints."""
  187. with (
  188. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
  189. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  190. ):
  191. mock_get_plug.return_value = mock_plug
  192. await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db)
  193. mock_schedule.assert_not_called()
  194. # ========================================================================
  195. # Tests for _cancel_pending_off
  196. # ========================================================================
  197. @pytest.mark.asyncio
  198. async def test_cancel_pending_off_removes_task(self, manager, mock_plug):
  199. """Verify pending off tasks can be cancelled."""
  200. mock_task = MagicMock()
  201. manager._pending_off[mock_plug.id] = mock_task
  202. with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
  203. manager._cancel_pending_off(mock_plug.id)
  204. assert mock_plug.id not in manager._pending_off
  205. mock_task.cancel.assert_called_once()
  206. @pytest.mark.asyncio
  207. async def test_cancel_pending_off_handles_missing_task(self, manager):
  208. """Verify no error when cancelling non-existent task."""
  209. # Should not raise any exception
  210. with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
  211. manager._cancel_pending_off(999) # Non-existent plug ID
  212. @pytest.mark.asyncio
  213. async def test_cancel_all_pending(self, manager, mock_plug):
  214. """Verify all pending tasks can be cancelled."""
  215. mock_task1 = MagicMock()
  216. mock_task2 = MagicMock()
  217. manager._pending_off[1] = mock_task1
  218. manager._pending_off[2] = mock_task2
  219. with patch("asyncio.create_task"):
  220. manager.cancel_all_pending()
  221. assert len(manager._pending_off) == 0
  222. mock_task1.cancel.assert_called_once()
  223. mock_task2.cancel.assert_called_once()
  224. # ========================================================================
  225. # Tests for scheduler
  226. # ========================================================================
  227. def test_start_scheduler(self, manager):
  228. """Verify scheduler can be started."""
  229. assert manager._scheduler_task is None
  230. # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
  231. with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
  232. mock_create.return_value = MagicMock()
  233. manager.start_scheduler()
  234. assert manager._scheduler_task is not None
  235. mock_loop.assert_called_once()
  236. def test_stop_scheduler(self, manager):
  237. """Verify scheduler can be stopped."""
  238. mock_task = MagicMock()
  239. manager._scheduler_task = mock_task
  240. manager.stop_scheduler()
  241. mock_task.cancel.assert_called_once()
  242. assert manager._scheduler_task is None
  243. def test_start_scheduler_idempotent(self, manager):
  244. """Verify starting scheduler twice doesn't create multiple tasks."""
  245. mock_task = MagicMock()
  246. manager._scheduler_task = mock_task
  247. # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
  248. with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
  249. manager.start_scheduler()
  250. mock_create.assert_not_called() # Should not create new task
  251. mock_loop.assert_not_called() # Should not call _schedule_loop
  252. class TestGetPlugForPrinter:
  253. """Tests for _get_plug_for_printer with multiple plugs per printer."""
  254. @pytest.fixture
  255. def manager(self):
  256. return SmartPlugManager()
  257. @pytest.mark.asyncio
  258. async def test_returns_none_when_no_plugs(self, manager):
  259. """Verify None 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_plug_for_printer(1, mock_db)
  265. assert result is None
  266. @pytest.mark.asyncio
  267. async def test_returns_single_plug(self, manager):
  268. """Verify single plug is returned directly."""
  269. plug = MagicMock()
  270. plug.plug_type = "tasmota"
  271. plug.ha_entity_id = None
  272. mock_db = AsyncMock()
  273. mock_result = MagicMock()
  274. mock_result.scalars.return_value.all.return_value = [plug]
  275. mock_db.execute = AsyncMock(return_value=mock_result)
  276. result = await manager._get_plug_for_printer(1, mock_db)
  277. assert result is plug
  278. @pytest.mark.asyncio
  279. async def test_prefers_main_plug_over_script(self, manager):
  280. """Verify main power plug is returned when both main and script exist."""
  281. script_plug = MagicMock()
  282. script_plug.plug_type = "homeassistant"
  283. script_plug.ha_entity_id = "script.pre_print"
  284. main_plug = MagicMock()
  285. main_plug.plug_type = "tasmota"
  286. main_plug.ha_entity_id = None
  287. mock_db = AsyncMock()
  288. mock_result = MagicMock()
  289. mock_result.scalars.return_value.all.return_value = [script_plug, main_plug]
  290. mock_db.execute = AsyncMock(return_value=mock_result)
  291. result = await manager._get_plug_for_printer(1, mock_db)
  292. assert result is main_plug
  293. @pytest.mark.asyncio
  294. async def test_handles_multiple_non_script_plugs(self, manager):
  295. """Verify no crash when multiple non-script plugs exist (e.g., Tasmota + HA switch)."""
  296. tasmota_plug = MagicMock()
  297. tasmota_plug.plug_type = "tasmota"
  298. tasmota_plug.ha_entity_id = None
  299. ha_switch = MagicMock()
  300. ha_switch.plug_type = "homeassistant"
  301. ha_switch.ha_entity_id = "switch.bathroom"
  302. mock_db = AsyncMock()
  303. mock_result = MagicMock()
  304. mock_result.scalars.return_value.all.return_value = [tasmota_plug, ha_switch]
  305. mock_db.execute = AsyncMock(return_value=mock_result)
  306. result = await manager._get_plug_for_printer(1, mock_db)
  307. # Should return first non-script plug (tasmota), not crash
  308. assert result is tasmota_plug
  309. @pytest.mark.asyncio
  310. async def test_returns_first_script_when_all_are_scripts(self, manager):
  311. """Verify first script is returned when only scripts are linked."""
  312. script1 = MagicMock()
  313. script1.plug_type = "homeassistant"
  314. script1.ha_entity_id = "script.heat_chamber"
  315. script2 = MagicMock()
  316. script2.plug_type = "homeassistant"
  317. script2.ha_entity_id = "script.exhaust_fan"
  318. mock_db = AsyncMock()
  319. mock_result = MagicMock()
  320. mock_result.scalars.return_value.all.return_value = [script1, script2]
  321. mock_db.execute = AsyncMock(return_value=mock_result)
  322. result = await manager._get_plug_for_printer(1, mock_db)
  323. assert result is script1
  324. class TestAutoOffPersistent:
  325. """Tests for persistent auto-off behavior (Issue #826).
  326. When auto_off_persistent is True, auto_off should remain enabled after
  327. execution instead of being disabled (one-shot default).
  328. """
  329. @pytest.fixture
  330. def manager(self):
  331. return SmartPlugManager()
  332. @pytest.mark.asyncio
  333. async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
  334. """Default one-shot: auto_off should be set to False after execution."""
  335. mock_plug = MagicMock()
  336. mock_plug.id = 1
  337. mock_plug.auto_off = True
  338. mock_plug.auto_off_persistent = False
  339. mock_plug.auto_off_executed = False
  340. mock_plug.auto_off_pending = True
  341. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  342. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  343. mock_db = AsyncMock()
  344. mock_result = MagicMock()
  345. mock_result.scalar_one_or_none.return_value = mock_plug
  346. mock_db.execute = AsyncMock(return_value=mock_result)
  347. mock_db.commit = AsyncMock()
  348. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  349. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  350. await manager._mark_auto_off_executed(1)
  351. assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
  352. assert mock_plug.auto_off_pending is False
  353. assert mock_plug.auto_off_pending_since is None
  354. mock_db.commit.assert_called_once()
  355. @pytest.mark.asyncio
  356. async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
  357. """Persistent mode: auto_off should remain True after execution."""
  358. mock_plug = MagicMock()
  359. mock_plug.id = 2
  360. mock_plug.auto_off = True
  361. mock_plug.auto_off_persistent = True
  362. mock_plug.auto_off_executed = False
  363. mock_plug.auto_off_pending = True
  364. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  365. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  366. mock_db = AsyncMock()
  367. mock_result = MagicMock()
  368. mock_result.scalar_one_or_none.return_value = mock_plug
  369. mock_db.execute = AsyncMock(return_value=mock_result)
  370. mock_db.commit = AsyncMock()
  371. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  372. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  373. await manager._mark_auto_off_executed(2)
  374. assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
  375. assert mock_plug.auto_off_pending is False
  376. assert mock_plug.auto_off_pending_since is None
  377. mock_db.commit.assert_called_once()
  378. @pytest.mark.asyncio
  379. async def test_persistent_auto_off_full_cycle(self, manager):
  380. """Verify persistent auto-off survives a full print cycle.
  381. Simulates: print start → print complete → auto-off executes → next print start.
  382. auto_off should remain True throughout for persistent plugs.
  383. """
  384. mock_plug = MagicMock()
  385. mock_plug.id = 3
  386. mock_plug.name = "HA BentoBox Filter"
  387. mock_plug.plug_type = "homeassistant"
  388. mock_plug.ha_entity_id = "switch.bentobox_filter"
  389. mock_plug.ip_address = None
  390. mock_plug.username = None
  391. mock_plug.password = None
  392. mock_plug.enabled = True
  393. mock_plug.auto_on = True
  394. mock_plug.auto_off = True
  395. mock_plug.auto_off_persistent = True
  396. mock_plug.off_delay_mode = "time"
  397. mock_plug.off_delay_minutes = 1
  398. mock_plug.off_temp_threshold = 70
  399. mock_plug.printer_id = 1
  400. mock_plug.auto_off_executed = False
  401. mock_plug.auto_off_pending = False
  402. mock_plug.last_state = "OFF"
  403. mock_plug.last_checked = None
  404. mock_db = AsyncMock()
  405. mock_db.commit = AsyncMock()
  406. # Step 1: Print starts — plug turns on
  407. with (
  408. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
  409. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
  410. ):
  411. mock_get.return_value = mock_plug
  412. mock_service = AsyncMock()
  413. mock_service.turn_on = AsyncMock(return_value=True)
  414. mock_svc.return_value = mock_service
  415. await manager.on_print_start(printer_id=1, db=mock_db)
  416. assert mock_plug.auto_off_executed is False
  417. assert mock_plug.auto_off is True # Still enabled
  418. # Step 2: Print completes — auto-off is scheduled
  419. with (
  420. patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
  421. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  422. ):
  423. mock_get.return_value = mock_plug
  424. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  425. mock_schedule.assert_called_once()
  426. assert mock_plug.auto_off is True # Still enabled after scheduling
  427. # Step 3: Auto-off executes via _mark_auto_off_executed
  428. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  429. mock_db2 = AsyncMock()
  430. mock_result = MagicMock()
  431. mock_result.scalar_one_or_none.return_value = mock_plug
  432. mock_db2.execute = AsyncMock(return_value=mock_result)
  433. mock_db2.commit = AsyncMock()
  434. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
  435. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  436. await manager._mark_auto_off_executed(3)
  437. # KEY ASSERTION: auto_off stays True for persistent mode
  438. assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
  439. assert mock_plug.auto_off_pending is False
  440. class TestScheduleLoop:
  441. """Tests for the schedule-based plug control."""
  442. @pytest.fixture
  443. def manager(self):
  444. return SmartPlugManager()
  445. @pytest.mark.asyncio
  446. async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
  447. """Verify scheduled on-time turns plug on."""
  448. mock_plug = MagicMock()
  449. mock_plug.id = 1
  450. mock_plug.name = "Test Plug"
  451. mock_plug.enabled = True
  452. mock_plug.schedule_enabled = True
  453. mock_plug.schedule_on_time = "08:00"
  454. mock_plug.schedule_off_time = "22:00"
  455. mock_plug.printer_id = None
  456. mock_plug.last_state = "OFF"
  457. with (
  458. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  459. patch("backend.app.core.database.async_session") as mock_session_ctx,
  460. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  461. ):
  462. # Set current time to 08:00
  463. mock_now = MagicMock()
  464. mock_now.strftime.return_value = "08:00"
  465. mock_datetime.now.return_value = mock_now
  466. mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
  467. # Set up async session mock
  468. mock_db = AsyncMock()
  469. mock_result = MagicMock()
  470. mock_result.scalars.return_value.all.return_value = [mock_plug]
  471. mock_db.execute = AsyncMock(return_value=mock_result)
  472. mock_db.commit = AsyncMock()
  473. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  474. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  475. mock_tasmota.turn_on = AsyncMock(return_value=True)
  476. await manager._check_schedules()
  477. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  478. @pytest.mark.asyncio
  479. async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
  480. """Verify scheduled off-time turns plug off."""
  481. mock_plug = MagicMock()
  482. mock_plug.id = 1
  483. mock_plug.name = "Test Plug"
  484. mock_plug.enabled = True
  485. mock_plug.schedule_enabled = True
  486. mock_plug.schedule_on_time = "08:00"
  487. mock_plug.schedule_off_time = "22:00"
  488. mock_plug.printer_id = 1
  489. mock_plug.last_state = "ON"
  490. with (
  491. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  492. patch("backend.app.core.database.async_session") as mock_session_ctx,
  493. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  494. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  495. ):
  496. # Set current time to 22:00
  497. mock_now = MagicMock()
  498. mock_now.strftime.return_value = "22:00"
  499. mock_datetime.now.return_value = mock_now
  500. mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
  501. # Set up async session mock
  502. mock_db = AsyncMock()
  503. mock_result = MagicMock()
  504. mock_result.scalars.return_value.all.return_value = [mock_plug]
  505. mock_db.execute = AsyncMock(return_value=mock_result)
  506. mock_db.commit = AsyncMock()
  507. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  508. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  509. mock_tasmota.turn_off = AsyncMock(return_value=True)
  510. mock_pm.mark_printer_offline = MagicMock()
  511. await manager._check_schedules()
  512. mock_tasmota.turn_off.assert_called_once_with(mock_plug)
  513. @pytest.mark.asyncio
  514. async def test_check_schedules_skipped_when_disabled(self, manager):
  515. """Verify schedule is skipped when schedule_enabled is False."""
  516. mock_plug = MagicMock()
  517. mock_plug.id = 1
  518. mock_plug.enabled = True
  519. mock_plug.schedule_enabled = False # Disabled
  520. with (
  521. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  522. patch("backend.app.core.database.async_session") as mock_session_ctx,
  523. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  524. ):
  525. mock_now = MagicMock()
  526. mock_now.strftime.return_value = "08:00"
  527. mock_datetime.now.return_value = mock_now
  528. # Set up async session mock - returns no plugs (filtered by schedule_enabled)
  529. mock_db = AsyncMock()
  530. mock_result = MagicMock()
  531. mock_result.scalars.return_value.all.return_value = []
  532. mock_db.execute = AsyncMock(return_value=mock_result)
  533. mock_db.commit = AsyncMock()
  534. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  535. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  536. mock_tasmota.turn_on = AsyncMock()
  537. await manager._check_schedules()
  538. mock_tasmota.turn_on.assert_not_called()
  539. class TestPendingAutoOffPersistence:
  540. """Tests for auto-off pending state persistence (restart recovery)."""
  541. @pytest.fixture
  542. def manager(self):
  543. return SmartPlugManager()
  544. @pytest.mark.asyncio
  545. async def test_resume_pending_auto_offs_temperature_mode(self, manager):
  546. """Verify temperature-based pending auto-offs are resumed on startup."""
  547. mock_plug = MagicMock()
  548. mock_plug.id = 1
  549. mock_plug.name = "Test Plug"
  550. mock_plug.ip_address = "192.168.1.100"
  551. mock_plug.username = None
  552. mock_plug.password = None
  553. mock_plug.printer_id = 1
  554. mock_plug.auto_off_pending = True
  555. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  556. mock_plug.off_delay_mode = "temperature"
  557. mock_plug.off_temp_threshold = 70
  558. with (
  559. patch("backend.app.core.database.async_session") as mock_session_ctx,
  560. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  561. ):
  562. mock_db = AsyncMock()
  563. mock_result = MagicMock()
  564. mock_result.scalars.return_value.all.return_value = [mock_plug]
  565. mock_db.execute = AsyncMock(return_value=mock_result)
  566. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  567. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  568. await manager.resume_pending_auto_offs()
  569. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  570. @pytest.mark.asyncio
  571. async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
  572. """Verify time-based pending auto-offs turn off immediately on resume."""
  573. mock_plug = MagicMock()
  574. mock_plug.id = 1
  575. mock_plug.name = "Test Plug"
  576. mock_plug.ip_address = "192.168.1.100"
  577. mock_plug.username = None
  578. mock_plug.password = None
  579. mock_plug.printer_id = 1
  580. mock_plug.auto_off_pending = True
  581. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  582. mock_plug.off_delay_mode = "time"
  583. with (
  584. patch("backend.app.core.database.async_session") as mock_session_ctx,
  585. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  586. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
  587. patch("backend.app.services.smart_plug_manager.printer_manager"),
  588. ):
  589. mock_db = AsyncMock()
  590. mock_result = MagicMock()
  591. mock_result.scalars.return_value.all.return_value = [mock_plug]
  592. mock_db.execute = AsyncMock(return_value=mock_result)
  593. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  594. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  595. mock_tasmota.turn_off = AsyncMock(return_value=True)
  596. await manager.resume_pending_auto_offs()
  597. mock_tasmota.turn_off.assert_called_once()
  598. mock_mark.assert_called_once_with(1)