test_printer_kill_switch.py 12 KB

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