test_smart_plug_manager.py 34 KB

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