test_printer_kill_switch.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. from types import SimpleNamespace
  2. from unittest.mock import AsyncMock
  3. import pytest
  4. from backend.app import main as main_module
  5. @pytest.fixture(autouse=True)
  6. def clear_kill_switch_state():
  7. main_module._kill_switch_setting_cache = None
  8. main_module._unauthorized_print_kill_sent.clear()
  9. main_module._kill_switch_notification_tasks.clear()
  10. main_module._expected_prints.clear()
  11. main_module._active_prints.clear()
  12. main_module._expected_print_registered_at.clear()
  13. main_module._printer_reconciled_since_connect.clear()
  14. yield
  15. for task in main_module._kill_switch_notification_tasks.values():
  16. if not task.done():
  17. task.cancel()
  18. main_module._unauthorized_print_kill_sent.clear()
  19. main_module._kill_switch_notification_tasks.clear()
  20. main_module._expected_prints.clear()
  21. main_module._active_prints.clear()
  22. main_module._expected_print_registered_at.clear()
  23. main_module._printer_reconciled_since_connect.clear()
  24. main_module._kill_switch_setting_cache = None
  25. def test_gcode_3mf_status_filename_matches_registered_expected_print():
  26. state = SimpleNamespace(
  27. current_print=None,
  28. subtask_name="",
  29. gcode_file="foreign_job.gcode.3mf",
  30. )
  31. keys = main_module._build_status_print_keys(7, state)
  32. assert (7, "foreign_job.gcode.3mf") in keys
  33. assert (7, "foreign_job.gcode") in keys
  34. @pytest.mark.asyncio
  35. async def test_unauthorized_active_print_triggers_stop(monkeypatch):
  36. stop_calls: list[int] = []
  37. broadcast = AsyncMock()
  38. provider_notification = AsyncMock(return_value=True)
  39. async def fake_status(*args, **kwargs):
  40. return None
  41. async def kill_switch_enabled(_db):
  42. return True
  43. unauthorized = AsyncMock(return_value=False)
  44. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  45. monkeypatch.setattr(
  46. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  47. )
  48. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  49. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  50. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  51. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  52. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  53. monkeypatch.setattr(main_module.ws_manager, "broadcast", broadcast)
  54. monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
  55. monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", provider_notification)
  56. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  57. state = SimpleNamespace(
  58. connected=True,
  59. state="RUNNING",
  60. progress=0,
  61. remaining_time=0,
  62. layer_num=0,
  63. temperatures={},
  64. raw_data={},
  65. stg_cur=0,
  66. cooling_fan_speed=None,
  67. big_fan1_speed=None,
  68. big_fan2_speed=None,
  69. chamber_light=False,
  70. active_extruder=0,
  71. tray_now=255,
  72. door_open=False,
  73. ams_filament_backup=False,
  74. current_print=None,
  75. subtask_name="foreign_job",
  76. subtask_id="external-task-1",
  77. gcode_file="foreign_job.gcode",
  78. )
  79. await main_module.on_printer_status_change(7, state)
  80. await main_module.on_printer_status_change(7, state)
  81. assert stop_calls == [7]
  82. unauthorized.assert_awaited_once()
  83. assert 7 in main_module._unauthorized_print_kill_sent
  84. broadcast.assert_awaited_once_with(
  85. {
  86. "type": "kill_switch_triggered",
  87. "printer_id": 7,
  88. "printer_name": "Printer 7",
  89. "filename": "foreign_job",
  90. "reason": "unauthorized_print",
  91. }
  92. )
  93. notification_task = main_module._kill_switch_notification_tasks[7]
  94. assert await notification_task is True
  95. provider_notification.assert_awaited_once_with(
  96. 7,
  97. "Printer 7",
  98. {
  99. "status": "stopped",
  100. "filename": "foreign_job.gcode",
  101. "subtask_name": "foreign_job",
  102. "progress": 0,
  103. "reason": "unauthorized_print",
  104. },
  105. )
  106. @pytest.mark.asyncio
  107. async def test_failed_immediate_notification_allows_completion_retry():
  108. task = main_module.spawn_background_task(_return_false(), name="test-kill-switch-notification-failure")
  109. assert await main_module._kill_switch_notification_already_sent(task) is False
  110. async def _return_false():
  111. return False
  112. @pytest.mark.asyncio
  113. async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
  114. monkeypatch.setitem(main_module._expected_prints, (7, "foreign_job"), 123)
  115. stop_calls: list[int] = []
  116. async def fake_status(*args, **kwargs):
  117. return None
  118. kill_switch_enabled = AsyncMock(return_value=True)
  119. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  120. monkeypatch.setattr(
  121. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  122. )
  123. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  124. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  125. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  126. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  127. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  128. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  129. state = SimpleNamespace(
  130. connected=True,
  131. state="RUNNING",
  132. progress=0,
  133. remaining_time=0,
  134. layer_num=0,
  135. temperatures={},
  136. raw_data={},
  137. stg_cur=0,
  138. cooling_fan_speed=None,
  139. big_fan1_speed=None,
  140. big_fan2_speed=None,
  141. chamber_light=False,
  142. active_extruder=0,
  143. tray_now=255,
  144. door_open=False,
  145. ams_filament_backup=False,
  146. current_print=None,
  147. subtask_name="foreign_job",
  148. gcode_file="foreign_job.gcode",
  149. )
  150. await main_module.on_printer_status_change(7, state)
  151. assert stop_calls == []
  152. assert 7 not in main_module._unauthorized_print_kill_sent
  153. kill_switch_enabled.assert_not_awaited()
  154. @pytest.mark.asyncio
  155. async def test_kill_switch_setting_is_cached(monkeypatch):
  156. kill_switch_enabled = AsyncMock(return_value=True)
  157. class FakeSessionContext:
  158. async def __aenter__(self):
  159. return SimpleNamespace()
  160. async def __aexit__(self, *_args):
  161. return False
  162. monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
  163. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  164. assert await main_module._is_printer_kill_switch_enabled_cached() is True
  165. assert await main_module._is_printer_kill_switch_enabled_cached() is True
  166. kill_switch_enabled.assert_awaited_once()
  167. @pytest.mark.asyncio
  168. async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
  169. stop_calls: list[int] = []
  170. async def fake_status(*args, **kwargs):
  171. return None
  172. async def kill_switch_enabled(_db):
  173. return True
  174. async def unauthorized(*_args):
  175. return False
  176. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  177. monkeypatch.setattr(
  178. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  179. )
  180. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  181. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  182. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  183. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  184. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  185. monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
  186. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  187. active_state = SimpleNamespace(
  188. connected=True,
  189. state="RUNNING",
  190. progress=0,
  191. remaining_time=0,
  192. layer_num=0,
  193. temperatures={},
  194. raw_data={},
  195. stg_cur=0,
  196. cooling_fan_speed=None,
  197. big_fan1_speed=None,
  198. big_fan2_speed=None,
  199. chamber_light=False,
  200. active_extruder=0,
  201. tray_now=255,
  202. door_open=False,
  203. ams_filament_backup=False,
  204. current_print=None,
  205. subtask_name="foreign_job",
  206. subtask_id="external-task-1",
  207. gcode_file="foreign_job.gcode",
  208. )
  209. idle_state = SimpleNamespace(
  210. connected=True,
  211. state="IDLE",
  212. progress=0,
  213. remaining_time=0,
  214. layer_num=0,
  215. temperatures={},
  216. raw_data={},
  217. stg_cur=0,
  218. cooling_fan_speed=None,
  219. big_fan1_speed=None,
  220. big_fan2_speed=None,
  221. chamber_light=False,
  222. active_extruder=0,
  223. tray_now=255,
  224. door_open=False,
  225. ams_filament_backup=False,
  226. current_print=None,
  227. subtask_name="",
  228. subtask_id=None,
  229. gcode_file=None,
  230. )
  231. await main_module.on_printer_status_change(7, active_state)
  232. assert stop_calls == [7]
  233. assert 7 in main_module._unauthorized_print_kill_sent
  234. await main_module.on_printer_status_change(7, idle_state)
  235. assert 7 not in main_module._unauthorized_print_kill_sent
  236. @pytest.mark.asyncio
  237. @pytest.mark.parametrize("printer_state", ["RUNNING", "PAUSE"])
  238. async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_state):
  239. archive = SimpleNamespace(id=123, filename="owned_job.gcode.3mf")
  240. query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
  241. db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
  242. class FakeSessionContext:
  243. async def __aenter__(self):
  244. return db
  245. async def __aexit__(self, *_args):
  246. return False
  247. stop_calls: list[int] = []
  248. async def fake_status(*args, **kwargs):
  249. return None
  250. async def kill_switch_enabled(_db):
  251. return True
  252. def discard_background_task(coro, **_kwargs):
  253. coro.close()
  254. monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
  255. monkeypatch.setattr(main_module, "spawn_background_task", discard_background_task)
  256. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  257. monkeypatch.setattr(
  258. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  259. )
  260. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  261. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  262. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  263. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  264. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  265. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  266. state = SimpleNamespace(
  267. connected=True,
  268. state=printer_state,
  269. progress=42,
  270. remaining_time=600,
  271. layer_num=50,
  272. temperatures={},
  273. raw_data={},
  274. stg_cur=0,
  275. cooling_fan_speed=None,
  276. big_fan1_speed=None,
  277. big_fan2_speed=None,
  278. chamber_light=False,
  279. active_extruder=0,
  280. tray_now=255,
  281. door_open=False,
  282. ams_filament_backup=False,
  283. current_print=None,
  284. subtask_name="owned_job",
  285. subtask_id="bambuddy-task-123",
  286. gcode_file="owned_job.gcode.3mf",
  287. )
  288. await main_module.on_printer_status_change(7, state)
  289. assert stop_calls == []
  290. assert (7, "owned_job.gcode.3mf") in main_module._active_prints
  291. assert main_module._active_prints[(7, "owned_job.gcode.3mf")] == 123
  292. assert 7 not in main_module._unauthorized_print_kill_sent
  293. @pytest.mark.asyncio
  294. async def test_kill_switch_defers_when_restart_identity_is_not_available(monkeypatch):
  295. state = SimpleNamespace(
  296. current_print=None,
  297. subtask_name="owned_job",
  298. subtask_id=None,
  299. gcode_file="owned_job.gcode.3mf",
  300. )
  301. db = SimpleNamespace(execute=AsyncMock())
  302. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  303. authorization = await main_module._is_bambuddy_authorized_print(7, state, db)
  304. assert authorization is None
  305. db.execute.assert_not_awaited()