| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- from types import SimpleNamespace
- from unittest.mock import AsyncMock
- import pytest
- from backend.app import main as main_module
- @pytest.fixture(autouse=True)
- def clear_kill_switch_state():
- main_module._kill_switch_setting_cache = None
- main_module._unauthorized_print_kill_sent.clear()
- main_module._kill_switch_notification_tasks.clear()
- main_module._expected_prints.clear()
- main_module._active_prints.clear()
- main_module._expected_print_registered_at.clear()
- main_module._printer_reconciled_since_connect.clear()
- yield
- for task in main_module._kill_switch_notification_tasks.values():
- if not task.done():
- task.cancel()
- main_module._unauthorized_print_kill_sent.clear()
- main_module._kill_switch_notification_tasks.clear()
- main_module._expected_prints.clear()
- main_module._active_prints.clear()
- main_module._expected_print_registered_at.clear()
- main_module._printer_reconciled_since_connect.clear()
- main_module._kill_switch_setting_cache = None
- def test_gcode_3mf_status_filename_matches_registered_expected_print():
- state = SimpleNamespace(
- current_print=None,
- subtask_name="",
- gcode_file="foreign_job.gcode.3mf",
- )
- keys = main_module._build_status_print_keys(7, state)
- assert (7, "foreign_job.gcode.3mf") in keys
- assert (7, "foreign_job.gcode") in keys
- @pytest.mark.asyncio
- async def test_unauthorized_active_print_triggers_stop(monkeypatch):
- stop_calls: list[int] = []
- broadcast = AsyncMock()
- provider_notification = AsyncMock(return_value=True)
- async def fake_status(*args, **kwargs):
- return None
- async def kill_switch_enabled(_db):
- return True
- unauthorized = AsyncMock(return_value=False)
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- monkeypatch.setattr(
- main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
- )
- monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
- monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
- monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
- monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
- monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
- monkeypatch.setattr(main_module.ws_manager, "broadcast", broadcast)
- monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
- monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", provider_notification)
- monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
- state = SimpleNamespace(
- connected=True,
- state="RUNNING",
- progress=0,
- remaining_time=0,
- layer_num=0,
- temperatures={},
- raw_data={},
- stg_cur=0,
- cooling_fan_speed=None,
- big_fan1_speed=None,
- big_fan2_speed=None,
- chamber_light=False,
- active_extruder=0,
- tray_now=255,
- door_open=False,
- ams_filament_backup=False,
- current_print=None,
- subtask_name="foreign_job",
- subtask_id="external-task-1",
- gcode_file="foreign_job.gcode",
- )
- await main_module.on_printer_status_change(7, state)
- await main_module.on_printer_status_change(7, state)
- assert stop_calls == [7]
- unauthorized.assert_awaited_once()
- assert 7 in main_module._unauthorized_print_kill_sent
- broadcast.assert_awaited_once_with(
- {
- "type": "kill_switch_triggered",
- "printer_id": 7,
- "printer_name": "Printer 7",
- "filename": "foreign_job",
- "reason": "unauthorized_print",
- }
- )
- notification_task = main_module._kill_switch_notification_tasks[7]
- assert await notification_task is True
- provider_notification.assert_awaited_once_with(
- 7,
- "Printer 7",
- {
- "status": "stopped",
- "filename": "foreign_job.gcode",
- "subtask_name": "foreign_job",
- "progress": 0,
- "reason": "unauthorized_print",
- },
- )
- @pytest.mark.asyncio
- async def test_failed_immediate_notification_allows_completion_retry():
- task = main_module.spawn_background_task(_return_false(), name="test-kill-switch-notification-failure")
- assert await main_module._kill_switch_notification_already_sent(task) is False
- async def _return_false():
- return False
- @pytest.mark.asyncio
- async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
- monkeypatch.setitem(main_module._expected_prints, (7, "foreign_job"), 123)
- stop_calls: list[int] = []
- async def fake_status(*args, **kwargs):
- return None
- kill_switch_enabled = AsyncMock(return_value=True)
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- monkeypatch.setattr(
- main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
- )
- monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
- monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
- monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
- monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
- monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
- monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
- state = SimpleNamespace(
- connected=True,
- state="RUNNING",
- progress=0,
- remaining_time=0,
- layer_num=0,
- temperatures={},
- raw_data={},
- stg_cur=0,
- cooling_fan_speed=None,
- big_fan1_speed=None,
- big_fan2_speed=None,
- chamber_light=False,
- active_extruder=0,
- tray_now=255,
- door_open=False,
- ams_filament_backup=False,
- current_print=None,
- subtask_name="foreign_job",
- gcode_file="foreign_job.gcode",
- )
- await main_module.on_printer_status_change(7, state)
- assert stop_calls == []
- assert 7 not in main_module._unauthorized_print_kill_sent
- kill_switch_enabled.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_kill_switch_setting_is_cached(monkeypatch):
- kill_switch_enabled = AsyncMock(return_value=True)
- class FakeSessionContext:
- async def __aenter__(self):
- return SimpleNamespace()
- async def __aexit__(self, *_args):
- return False
- monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
- monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
- assert await main_module._is_printer_kill_switch_enabled_cached() is True
- assert await main_module._is_printer_kill_switch_enabled_cached() is True
- kill_switch_enabled.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
- stop_calls: list[int] = []
- async def fake_status(*args, **kwargs):
- return None
- async def kill_switch_enabled(_db):
- return True
- async def unauthorized(*_args):
- return False
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- monkeypatch.setattr(
- main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
- )
- monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
- monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
- monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
- monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
- monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
- monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
- monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
- active_state = SimpleNamespace(
- connected=True,
- state="RUNNING",
- progress=0,
- remaining_time=0,
- layer_num=0,
- temperatures={},
- raw_data={},
- stg_cur=0,
- cooling_fan_speed=None,
- big_fan1_speed=None,
- big_fan2_speed=None,
- chamber_light=False,
- active_extruder=0,
- tray_now=255,
- door_open=False,
- ams_filament_backup=False,
- current_print=None,
- subtask_name="foreign_job",
- subtask_id="external-task-1",
- gcode_file="foreign_job.gcode",
- )
- idle_state = SimpleNamespace(
- connected=True,
- state="IDLE",
- progress=0,
- remaining_time=0,
- layer_num=0,
- temperatures={},
- raw_data={},
- stg_cur=0,
- cooling_fan_speed=None,
- big_fan1_speed=None,
- big_fan2_speed=None,
- chamber_light=False,
- active_extruder=0,
- tray_now=255,
- door_open=False,
- ams_filament_backup=False,
- current_print=None,
- subtask_name="",
- subtask_id=None,
- gcode_file=None,
- )
- await main_module.on_printer_status_change(7, active_state)
- assert stop_calls == [7]
- assert 7 in main_module._unauthorized_print_kill_sent
- await main_module.on_printer_status_change(7, idle_state)
- assert 7 not in main_module._unauthorized_print_kill_sent
- @pytest.mark.asyncio
- @pytest.mark.parametrize("printer_state", ["RUNNING", "PAUSE"])
- async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_state):
- # billing_run_id is the marker the scheduler stamps on its own dispatches;
- # an archive without one proves only that Bambuddy watched the print.
- archive = SimpleNamespace(
- id=123,
- filename="owned_job.gcode.3mf",
- billing_run_id="d7c1f0b2-0000-4000-8000-000000000001",
- created_by_id=None,
- )
- query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
- db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
- class FakeSessionContext:
- async def __aenter__(self):
- return db
- async def __aexit__(self, *_args):
- return False
- stop_calls: list[int] = []
- async def fake_status(*args, **kwargs):
- return None
- async def kill_switch_enabled(_db):
- return True
- def discard_background_task(coro, **_kwargs):
- coro.close()
- monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
- monkeypatch.setattr(main_module, "spawn_background_task", discard_background_task)
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- monkeypatch.setattr(
- main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
- )
- monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
- monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
- monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
- monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
- monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
- monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
- state = SimpleNamespace(
- connected=True,
- state=printer_state,
- progress=42,
- remaining_time=600,
- layer_num=50,
- temperatures={},
- raw_data={},
- stg_cur=0,
- cooling_fan_speed=None,
- big_fan1_speed=None,
- big_fan2_speed=None,
- chamber_light=False,
- active_extruder=0,
- tray_now=255,
- door_open=False,
- ams_filament_backup=False,
- current_print=None,
- subtask_name="owned_job",
- subtask_id="bambuddy-task-123",
- gcode_file="owned_job.gcode.3mf",
- )
- await main_module.on_printer_status_change(7, state)
- assert stop_calls == []
- assert (7, "owned_job.gcode.3mf") in main_module._active_prints
- assert main_module._active_prints[(7, "owned_job.gcode.3mf")] == 123
- assert 7 not in main_module._unauthorized_print_kill_sent
- @pytest.mark.asyncio
- async def test_kill_switch_defers_when_restart_identity_is_not_available(monkeypatch):
- state = SimpleNamespace(
- current_print=None,
- subtask_name="owned_job",
- subtask_id=None,
- gcode_file="owned_job.gcode.3mf",
- )
- db = SimpleNamespace(execute=AsyncMock())
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- authorization = await main_module._is_bambuddy_authorized_print(7, state, db)
- assert authorization is None
- db.execute.assert_not_awaited()
- def _authorization_db(archive, dispatched_queue_item_id=None):
- """Fake session answering the two lookups `_is_bambuddy_authorized_print` makes."""
- query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
- return SimpleNamespace(
- execute=AsyncMock(return_value=query_result),
- scalar=AsyncMock(return_value=dispatched_queue_item_id),
- )
- def _running_state(subtask_id="external-task-9"):
- return SimpleNamespace(
- current_print=None,
- subtask_name="some_job",
- subtask_id=subtask_id,
- gcode_file="some_job.gcode.3mf",
- )
- @pytest.mark.asyncio
- async def test_archive_without_a_dispatch_marker_is_not_authorization(monkeypatch):
- """on_print_start archives prints started from Studio or Handy too.
- Those rows carry the same status and subtask_id as Bambuddy's own, so treating
- the row's existence as proof would switch the feature off a few seconds into
- every foreign print — as soon as the 3MF finished downloading.
- """
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- observed_only = SimpleNamespace(
- id=55,
- filename="some_job.gcode.3mf",
- billing_run_id=None,
- created_by_id=None,
- )
- db = _authorization_db(observed_only, dispatched_queue_item_id=None)
- assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False
- assert (9, "some_job.gcode.3mf") not in main_module._active_prints
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "marker",
- [
- {"billing_run_id": "9f0c2b6e-0000-4000-8000-00000000abcd", "created_by_id": None},
- {"billing_run_id": None, "created_by_id": 4},
- ],
- ids=["billing_run_id", "created_by_id"],
- )
- async def test_either_dispatch_marker_authorizes_after_a_restart(monkeypatch, marker):
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- archive = SimpleNamespace(id=77, filename="some_job.gcode.3mf", **marker)
- db = _authorization_db(archive, dispatched_queue_item_id=None)
- assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is True
- assert main_module._active_prints[(9, "some_job.gcode.3mf")] == 77
- # The fast path is rehydrated, so the queue is never consulted.
- db.scalar.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_defers_while_bambuddy_has_a_job_running_on_that_printer(monkeypatch):
- """A library-file dispatch has no archive at send time, and the row created for
- it moments later by on_print_start carries neither marker. The queue row is the
- only durable trace, and it cannot be tied to a subtask_id — so it defers."""
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- unmarked = SimpleNamespace(id=56, filename="some_job.gcode.3mf", billing_run_id=None, created_by_id=None)
- db = _authorization_db(unmarked, dispatched_queue_item_id=310)
- assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
- # Deferring must not authorize the print for every later frame.
- assert (9, "some_job.gcode.3mf") not in main_module._active_prints
- @pytest.mark.asyncio
- async def test_defers_when_the_dispatch_has_not_been_archived_yet(monkeypatch):
- """Restart during the window between the MQTT send and the 3MF download."""
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- db = _authorization_db(None, dispatched_queue_item_id=311)
- assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
- @pytest.mark.asyncio
- async def test_foreign_print_with_no_archive_and_no_dispatch_is_unauthorized(monkeypatch):
- monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
- db = _authorization_db(None, dispatched_queue_item_id=None)
- assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False
|