"""Unit tests for Virtual Printer services. Tests the virtual printer manager, FTP server, and SSDP server components. """ import asyncio import json import zipfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest def _write_3mf_with_filaments(file_path: Path, filaments: list[dict], plate_index: int = 1) -> None: """Build a minimal 3MF zip with `Metadata/slice_info.config` carrying the given per-slot filament entries. Each `filaments` dict needs `id`, `type`, `color`, `used_g`. Used by the #1188 VP queue-mode tests below.""" filament_xml = "".join( f'' for f in filaments ) config = ( '' "" f'' f"{filament_xml}" "" "" ) with zipfile.ZipFile(file_path, "w") as zf: zf.writestr("Metadata/slice_info.config", config) # Plate gcode is referenced for plate-id detection in the VP path — # presence is enough; contents don't matter. zf.writestr(f"Metadata/plate_{plate_index}.gcode", "; gcode\n") class TestVirtualPrinterInstance: """Tests for VirtualPrinterInstance class.""" @pytest.fixture def instance(self, tmp_path): """Create a VirtualPrinterInstance with test defaults.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance return VirtualPrinterInstance( vp_id=1, name="TestPrinter", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) # ======================================================================== # Tests for instance properties # ======================================================================== def test_instance_stores_parameters(self, instance): """Verify constructor stores parameters correctly.""" assert instance.id == 1 assert instance.name == "TestPrinter" assert instance.mode == "archive" assert instance.model == "C11" assert instance.access_code == "12345678" assert instance.serial_suffix == "391800001" def test_instance_serial_property(self, instance): """Verify serial is generated from model prefix + suffix.""" # C11 = P1P, prefix = 01S00A assert instance.serial == "01S00A391800001" def test_instance_serial_x1c(self, tmp_path): """Verify X1C serial uses correct prefix.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=2, name="X1C", mode="archive", model="BL-P001", access_code="12345678", serial_suffix="391800002", base_dir=tmp_path, ) assert inst.serial == "00M00A391800002" def test_instance_is_proxy_false(self, instance): """Verify is_proxy is False for non-proxy mode.""" assert instance.is_proxy is False def test_instance_is_proxy_true(self, tmp_path): """Verify is_proxy is True for proxy mode.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=3, name="Proxy", mode="proxy", model="C11", access_code="", serial_suffix="391800003", target_printer_ip="192.168.1.100", base_dir=tmp_path, ) assert inst.is_proxy is True def test_instance_is_running_with_active_tasks(self, instance): """Verify is_running is True when tasks are active.""" mock_task = MagicMock() mock_task.done.return_value = False instance._tasks = [mock_task] assert instance.is_running is True def test_instance_is_running_with_no_tasks(self, instance): """Verify is_running is False when no tasks.""" assert instance.is_running is False def test_instance_creates_directories(self, instance, tmp_path): """Verify instance creates upload and cert directories.""" assert (tmp_path / "uploads" / "1").exists() assert (tmp_path / "uploads" / "1" / "cache").exists() assert (tmp_path / "certs" / "1").exists() # ======================================================================== # Tests for status # ======================================================================== def test_get_status_returns_correct_format(self, instance): """Verify get_status returns expected fields.""" instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108 mock_task = MagicMock(done=MagicMock(return_value=False)) instance._tasks = [mock_task] status = instance.get_status() assert status["running"] is True assert status["pending_files"] == 1 def test_get_status_not_running(self, instance): """Verify get_status when no tasks.""" status = instance.get_status() assert status["running"] is False assert status["pending_files"] == 0 # ======================================================================== # Tests for file handling # ======================================================================== @pytest.mark.asyncio async def test_on_file_received_adds_to_pending(self, instance): """Verify received file is added to pending list in review mode.""" instance.mode = "review" file_path = Path("/tmp/test.3mf") # nosec B108 with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue: await instance.on_file_received(file_path, "192.168.1.100") assert "test.3mf" in instance._pending_files mock_queue.assert_called_once() @pytest.mark.asyncio async def test_on_file_received_archives_immediately(self, instance): """Verify file is archived in immediate mode.""" file_path = Path("/tmp/test.3mf") # nosec B108 with patch.object(instance, "_archive_file", new_callable=AsyncMock) as mock_archive: await instance.on_file_received(file_path, "192.168.1.100") mock_archive.assert_called_once_with(file_path, "192.168.1.100") @pytest.mark.asyncio async def test_on_file_received_signals_FINISH_to_slicer(self, instance): """Regression #1280: when a slicer's Print flow uploads to a non-proxy VP, the VP must transition gcode_state PREPARE → FINISH so the slicer's in-flight-job lock releases. Going PREPARE → IDLE wedges Orca at "Downloading...(0%)" and blocks the next dispatch with "busy with another print job". Send-flow slicers don't watch the post-upload state, so this is a no-op behavior change for them. """ instance.mode = "archive" instance._mqtt = MagicMock() instance._mqtt.set_gcode_state = MagicMock() file_path = Path("/tmp/test.3mf") # nosec B108 with patch.object(instance, "_archive_file", new_callable=AsyncMock): await instance.on_file_received(file_path, "192.168.1.100") instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="test.3mf", prepare_percent="100") @pytest.mark.asyncio async def test_on_file_received_non_3mf_does_not_touch_state(self, instance): """Non-3MF uploads (e.g., a job's auxiliary files) must not transition the visible state — the slicer is only tracking the .3mf upload.""" instance.mode = "archive" instance._mqtt = MagicMock() instance._mqtt.set_gcode_state = MagicMock() file_path = Path("/tmp/test.gcode") # nosec B108 with patch.object(instance, "_archive_file", new_callable=AsyncMock): await instance.on_file_received(file_path, "192.168.1.100") instance._mqtt.set_gcode_state.assert_not_called() @pytest.mark.asyncio async def test_on_print_command_schedules_finish_release_non_proxy(self, instance): """#1658: Bambu Studio 2.7.x flipped the slicer's Send flow to FTP → FTP → MQTT project_file. Under that order the synthetic project_file ack overwrites the FINISH set by #1280 in ``on_file_received`` back to PREPARE, leaving the slicer's "Downloading" modal stuck forever. Re-firing FINISH a moment after the ack releases the modal. """ instance.mode = "archive" instance._mqtt = MagicMock() instance._mqtt.set_gcode_state = MagicMock() with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed: await instance.on_print_command("test.3mf", {"command": "project_file"}) mock_delayed.assert_called_once() # First positional arg should be the filename; second is the delay seconds. args = mock_delayed.call_args.args assert args[0] == "test.3mf" assert isinstance(args[1], int | float) @pytest.mark.asyncio async def test_on_print_command_proxy_mode_does_not_reschedule_finish(self, instance): """Proxy mode hands push_status straight from the real printer through the bridge. Re-firing a synthetic FINISH would clobber a real PREPARE / RUNNING transition coming back from the printer, so the scheduler is exempt for proxy mode.""" instance.mode = "proxy" instance._mqtt = MagicMock() with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed: await instance.on_print_command("test.3mf", {"command": "project_file"}) mock_delayed.assert_not_called() @pytest.mark.asyncio async def test_on_print_command_no_mqtt_does_not_schedule(self, instance): """If the MQTT server isn't running yet (transient race during boot), the scheduler must skip silently — no AttributeError, no orphan task.""" instance.mode = "queue" instance._mqtt = None # Should not raise. await instance.on_print_command("test.3mf", {"command": "project_file"}) assert instance._finish_release_task is None @pytest.mark.asyncio async def test_schedule_finish_release_cancels_previous_timer(self, instance): """A slicer that fires project_file twice (e.g. retry after a transient FTP hiccup) must only result in one FINISH transition — the earlier in-flight timer is cancelled when the next one is scheduled.""" instance.mode = "queue" instance._mqtt = MagicMock() instance._schedule_finish_release("first.3mf", delay=10.0) first_task = instance._finish_release_task assert first_task is not None instance._schedule_finish_release("second.3mf", delay=10.0) second_task = instance._finish_release_task assert second_task is not first_task # Give the loop one tick so the cancelled task settles. await asyncio.sleep(0) assert first_task.cancelled() or first_task.done() # Clean up the still-pending second task so the test doesn't leak it. second_task.cancel() try: await second_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_delayed_finish_release_sets_finish_state(self, instance): """End-to-end: after the delay elapses, set_gcode_state is called with FINISH and prepare_percent=100, matching the wire-format the slicer's Print flow consumes to release "Downloading".""" instance._mqtt = MagicMock() instance._mqtt.set_gcode_state = MagicMock() await instance._delayed_finish_release("queued.3mf", delay=0.0) instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="queued.3mf", prepare_percent="100") @pytest.mark.asyncio async def test_on_print_command_no_filename_does_not_schedule(self, instance): """A project_file command without a subtask_name (defensive — real slicers always send one) must not schedule a no-op FINISH that would carry an empty filename on the next 1 Hz push.""" instance.mode = "queue" instance._mqtt = MagicMock() with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed: await instance.on_print_command("", {"command": "project_file"}) mock_delayed.assert_not_called() @pytest.mark.asyncio async def test_archive_file_skips_non_3mf(self, instance): """Verify non-3MF files are skipped and cleaned up.""" instance._session_factory = MagicMock() instance._pending_files["verify_job"] = Path("/tmp/verify_job") # nosec B108 with patch("pathlib.Path.unlink"): await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100") # nosec B108 assert "verify_job" not in instance._pending_files @pytest.mark.asyncio async def test_archive_file_broadcasts_archive_created(self, tmp_path): """#1282: VP immediate-mode archives must broadcast archive_created so the Archives page refreshes without a tab switch. Real-printer prints get this via main.py's MQTT print_start handler; the VP path used to skip the broadcast entirely.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=30, name="ImmediateBroadcast", mode="archive", model="C12", access_code="12345678", serial_suffix="391800030", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 99 mock_archive.printer_id = None mock_archive.filename = "test.3mf" mock_archive.print_name = "test" mock_archive.status = "archived" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), patch( "backend.app.core.websocket.ws_manager.send_archive_created", new_callable=AsyncMock, ) as mock_broadcast, ): await inst._archive_file(file_path, "192.168.1.100") mock_broadcast.assert_awaited_once() payload = mock_broadcast.await_args.args[0] assert payload["id"] == 99 assert payload["filename"] == "test.3mf" assert payload["status"] == "archived" # ======================================================================== # Tests for auto_dispatch # ======================================================================== def test_auto_dispatch_defaults_to_true(self, tmp_path): """Verify auto_dispatch defaults to True when not specified.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=10, name="DefaultDispatch", mode="queue", model="C11", access_code="12345678", serial_suffix="391800010", base_dir=tmp_path, ) assert inst.auto_dispatch is True @pytest.mark.asyncio async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path): """Verify queue items have manual_start=False when auto_dispatch=True.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() added_items = [] def capture_add(item): added_items.append(item) mock_db.add = MagicMock(side_effect=capture_add) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=11, name="AutoDispatchOn", mode="queue", model="C11", access_code="12345678", serial_suffix="391800011", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) # Create a temp 3mf file file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.manual_start is False @pytest.mark.asyncio async def test_add_to_print_queue_broadcasts_archive_created(self, tmp_path): """#1282: VP queue-mode uploads must broadcast archive_created so the Archives page picks up the new entry live. Pre-fix the page only refreshed when the user manually switched tabs.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() mock_db.add = MagicMock() mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=31, name="QueueBroadcast", mode="queue", model="C12", access_code="12345678", serial_suffix="391800031", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 77 mock_archive.printer_id = None mock_archive.filename = "test.3mf" mock_archive.print_name = "test" mock_archive.status = "archived" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), patch( "backend.app.core.websocket.ws_manager.send_archive_created", new_callable=AsyncMock, ) as mock_broadcast, ): await inst._add_to_print_queue(file_path, "192.168.1.100") mock_broadcast.assert_awaited_once() payload = mock_broadcast.await_args.args[0] assert payload["id"] == 77 assert payload["print_name"] == "test" assert payload["status"] == "archived" @pytest.mark.asyncio async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path): """Verify queue items have manual_start=True when auto_dispatch=False.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() added_items = [] def capture_add(item): added_items.append(item) mock_db.add = MagicMock(side_effect=capture_add) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=12, name="AutoDispatchOff", mode="queue", model="C11", access_code="12345678", serial_suffix="391800012", auto_dispatch=False, base_dir=tmp_path, session_factory=mock_session_factory, ) # Create a temp 3mf file file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.manual_start is True @pytest.mark.asyncio async def test_add_to_print_queue_gcode_injection_on(self, tmp_path): """#1516: queue items opt into injection when the VP has gcode_injection=True.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() added_items = [] mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=13, name="InjectOn", mode="queue", model="C11", access_code="12345678", serial_suffix="391800013", gcode_injection=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 assert added_items[0].gcode_injection is True @pytest.mark.asyncio async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path): """#1516: queue items do NOT inject when the VP leaves gcode_injection at its default.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() added_items = [] mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=14, name="InjectOff", mode="queue", model="C11", access_code="12345678", serial_suffix="391800014", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 assert added_items[0].gcode_injection is False @pytest.mark.asyncio async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path): """#1235: VP queue-mode constructed PrintQueueItem without specifying bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse, so SQLAlchemy applied the column-level defaults and ignored the user's workflow preferences entirely. Every print sent from the slicer to the VP came through with the OPPOSITE of what the workflow page said, forcing the user to edit each queue item by hand. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=22, name="DefaultsTest", mode="queue", model="C12", access_code="12345678", serial_suffix="391800022", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") # The reporter set every workflow default to the OPPOSITE of the model's # column default. Pre-fix the column defaults won; with the fix the # settings values must flow through to the queue item exactly as stored. settings_map = { "virtual_printer_archive_name_source": None, # Legacy boolean-string rows still coerce (false->off, true->on). "default_bed_levelling": "false", # tri-state default: auto "default_flow_cali": "true", # tri-state default: auto "default_vibration_cali": "false", # model default: True "default_layer_inspect": "true", # model default: False "default_timelapse": "true", # model default: False } async def fake_get_setting(_db, key): return settings_map.get(key) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new=fake_get_setting, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.bed_levelling == "off", "default_bed_levelling=false must flow through" assert queue_item.flow_cali == "on", "default_flow_cali=true must flow through" assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through" assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through" assert queue_item.timelapse is True, "default_timelapse=true must flow through" @pytest.mark.asyncio async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path): """#1235 fallback: when no workflow setting is in the DB, the queue item should use the AppSettings (Pydantic) defaults — same values the user sees in the workflow page on a fresh install. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=23, name="FreshInstallDefaults", mode="queue", model="C12", access_code="12345678", serial_suffix="391800023", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, # No settings → fall back to schema defaults ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] # These must match the AppSettings (Pydantic) defaults in schemas/settings.py assert queue_item.bed_levelling == "auto" assert queue_item.flow_cali == "auto" assert queue_item.vibration_cali is True assert queue_item.layer_inspect is False assert queue_item.timelapse is False @pytest.mark.asyncio async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path): """#1403: VP-queue items used to fall back to `default_timelapse` even though the slicer's MQTT `project_file` command carries the user's actual choice. Capture-via-`on_print_command` flow lets the user's slicer toggle reach the queue item. Settings here have timelapse OFF; the slicer's MQTT capture has it ON. After the fix the queue item must reflect the slicer's choice. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=24, name="SlicerInherits", mode="queue", model="C12", access_code="12345678", serial_suffix="391800024", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") # Pre-populate the capture as if MQTT `project_file` arrived already. # Settings (below) deliberately have timelapse OFF — only the slicer # capture should drive the resulting queue item. await inst.on_print_command( file_path.name, { "command": "project_file", "timelapse": True, "bed_leveling": False, # Note: MQTT field is single-L `bed_leveling` "flow_cali": True, "vibration_cali": False, "layer_inspect": True, }, ) settings_map = { "virtual_printer_archive_name_source": None, "default_bed_levelling": "true", "default_flow_cali": "false", "default_vibration_cali": "true", "default_layer_inspect": "false", "default_timelapse": "false", } async def fake_get_setting(_db, key): return settings_map.get(key) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new=fake_get_setting, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False" assert queue_item.bed_levelling == "off", "Slicer's bed_leveling=False must override default_bed_levelling" assert queue_item.flow_cali == "on" assert queue_item.vibration_cali is False assert queue_item.layer_inspect is True # Capture is consumed — no lingering state for the next print of the same name. assert file_path.name not in inst._slicer_print_options @pytest.mark.asyncio async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path): """#1403: H-family firmwares carry calibration flags as integers (0/1) rather than booleans. The capture must coerce both shapes so H-family-sliced jobs through the VP queue work the same as P1/X1. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=25, name="SlicerIntegers", mode="queue", model="C12", access_code="12345678", serial_suffix="391800025", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") await inst.on_print_command( file_path.name, {"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1}, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.timelapse is True, "integer 1 must coerce to True" assert queue_item.bed_levelling == "off", "integer 0 must coerce to off" assert queue_item.flow_cali == "on" @pytest.mark.asyncio async def test_add_to_print_queue_captures_slicer_auto_from_int_companion(self, tmp_path): """The slicer's tri-state rides on the int companion (auto_bed_leveling / extrude_cali_flag). When the slicer picks "Auto" it sends bed_leveling false + auto_bed_leveling 2; the VP must record "auto", not "off". """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=26, name="SlicerAuto", mode="queue", model="C12", access_code="12345678", serial_suffix="391800026", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") await inst.on_print_command( file_path.name, { "command": "project_file", "bed_leveling": False, "auto_bed_leveling": 2, "flow_cali": False, "extrude_cali_flag": 2, }, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.bed_levelling == "auto", "auto_bed_leveling=2 must record 'auto'" assert queue_item.flow_cali == "auto", "extrude_cali_flag=2 must record 'auto'" @pytest.mark.asyncio async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path): """#1188: VP queue-mode used to create PrintQueueItems with no filament fields, so the scheduler fell through to model-only matching and dispatched onto whatever printer was free regardless of loaded colour. ``required_filament_types`` is populated unconditionally (cheap, helps the scheduler validate type even without ``force_color_match``) — pin that contract here.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=21, name="Reqs", mode="queue", model="C12", access_code="12345678", serial_suffix="391800021", auto_dispatch=True, queue_force_color_match=False, # off → only required_filament_types base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "multi.3mf" _write_3mf_with_filaments( file_path, [ {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"}, {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"}, # used_g=0 → not actually consumed by this plate, must be ignored {"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"}, ], plate_index=1, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "multi" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] # Type-only fallback always populated. Sorted, deduped, no zero-use ABS. assert queue_item.required_filament_types is not None assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"] # Setting off → no force_color_match overrides leaked. assert queue_item.filament_overrides is None @pytest.mark.asyncio async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path): """#1188 core fix: when the per-VP ``queue_force_color_match`` toggle is on, every consumed slot lands as a ``filament_overrides`` entry with ``force_color_match: true``. This is the field the scheduler keys on (``print_scheduler.py:512``) — without it, slot-by-slot type+color matching never runs.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=22, name="ForceColor", mode="queue", model="C12", access_code="12345678", serial_suffix="391800022", auto_dispatch=True, queue_force_color_match=True, # on base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "forced.3mf" _write_3mf_with_filaments( file_path, [ {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"}, {"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"}, ], plate_index=1, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "forced" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] assert queue_item.filament_overrides is not None overrides = json.loads(queue_item.filament_overrides) assert overrides == [ {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True}, {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True}, ] # required_filament_types still populated alongside overrides. assert json.loads(queue_item.required_filament_types) == ["PLA"] @pytest.mark.asyncio async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path): """A malformed or fake-bytes 3MF must not crash the upload path — we just write the queue item with no filament fields and let the scheduler fall back to model-only matching (the pre-#1188 default). Regression guard for the existing fake-bytes happy-path tests.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=23, name="Unparseable", mode="queue", model="C12", access_code="12345678", serial_suffix="391800023", auto_dispatch=True, queue_force_color_match=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "bad.3mf" file_path.write_bytes(b"not a real 3mf zip") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "bad" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 queue_item = added_items[0] # No filament data extractable → both fields stay None (graceful # fallback to model-only scheduling). assert queue_item.required_filament_types is None assert queue_item.filament_overrides is None # ======================================================================== # Tests for archive_name_source setting (#1152) # ======================================================================== @pytest.mark.asyncio @pytest.mark.parametrize( ("setting_value", "expected_prefer_filename"), [ ("filename", True), ("metadata", False), (None, False), # Default when setting unset ("", False), # Defensive: empty string is not "filename" ], ) async def test_archive_file_passes_prefer_filename_per_setting( self, tmp_path, setting_value, expected_prefer_filename ): """_archive_file reads `virtual_printer_archive_name_source` and forwards prefer_filename_for_name=True only when it equals 'filename' (#1152).""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=20, name="NameSource", mode="archive", model="C11", access_code="12345678", serial_suffix="391800020", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "user-renamed-job.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "user-renamed-job" archive_print_mock = AsyncMock(return_value=mock_archive) with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=setting_value, ), patch( "backend.app.services.archive.ArchiveService.archive_print", archive_print_mock, ), ): await inst._archive_file(file_path, "192.168.1.100") assert archive_print_mock.await_count == 1 kwargs = archive_print_mock.await_args.kwargs assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename # ======================================================================== # Tests for failure-path cleanup (#audit-R2-1) # ======================================================================== # # All three file handlers (_archive_file, _queue_file, _add_to_print_queue) # previously only popped _pending_files and unlinked the temp file on the # success branch. Failure paths leaked the marker (blocking same-name # retries via the FTP layer) and the temp file on disk. The cleanup must # ALWAYS run, even when archival / queue insert raises. @pytest.mark.asyncio async def test_archive_file_failure_path_pops_pending_and_unlinks(self, tmp_path): """When the archive layer raises, `_pending_files[filename]` must still be popped and the temp file must be unlinked. Otherwise the FTP layer's same-name retry guard would silently reject the slicer's next attempt and the upload_dir would accumulate ghost files.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=40, name="ArchiveFailCleanup", mode="archive", model="C12", access_code="12345678", serial_suffix="391800040", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "cleanup-archive.3mf" file_path.write_bytes(b"fake3mf") inst._pending_files[file_path.name] = file_path with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, side_effect=RuntimeError("archive blew up"), ), ): await inst._archive_file(file_path, "192.168.1.100") assert file_path.name not in inst._pending_files assert not file_path.exists() @pytest.mark.asyncio async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path): """Same invariant for _queue_file: a DB error during PendingUpload insert must not leak the in-flight marker or the temp file.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() # Commit raises — emulating a DB connectivity error. mock_db.add = MagicMock() mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable")) mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=41, name="QueueFailCleanup", mode="review", model="C12", access_code="12345678", serial_suffix="391800041", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "cleanup-queue.3mf" file_path.write_bytes(b"fake3mf") inst._pending_files[file_path.name] = file_path await inst._queue_file(file_path, "192.168.1.100") assert file_path.name not in inst._pending_files assert not file_path.exists() @pytest.mark.asyncio async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path): """Same invariant for _add_to_print_queue: a DB error or archive failure must not leak the in-flight marker or the temp file.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() mock_db.add = MagicMock() mock_db.commit = AsyncMock() mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up")) mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=42, name="DispatchFailCleanup", mode="queue", model="C12", access_code="12345678", serial_suffix="391800042", auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "cleanup-dispatch.3mf" file_path.write_bytes(b"fake3mf") inst._pending_files[file_path.name] = file_path with patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert file_path.name not in inst._pending_files assert not file_path.exists() # ======================================================================== # Test for position=MAX+1 (audit-R2) # ======================================================================== @pytest.mark.asyncio async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path): """VP-queue items previously got hardcoded `position=1`, colliding with existing items at position 1 and producing non-deterministic execution order. Now the position is chosen by `MAX(position)+1` against the target queue, matching the canonical `POST /print-queue/` path.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance # Capture the inserted PrintQueueItem so we can assert on .position. added_items: list = [] class _RecordingDb: def __init__(self): self.add = lambda item: added_items.append(item) self.commit = AsyncMock() async def execute(self, query): # noqa: ARG002 """Return a stub result whose `.scalar()` reports the existing MAX(position) for the target. Returning 7 means the new item should land at 8.""" result = MagicMock() result.scalar = MagicMock(return_value=7) return result mock_db = _RecordingDb() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=43, name="PositionMaxPlusOne", mode="queue", model="C12", access_code="12345678", serial_suffix="391800043", target_printer_id=99, auto_dispatch=True, base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "next-position.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 555 mock_archive.printer_id = None mock_archive.filename = "next-position.3mf" mock_archive.print_name = "next-position" mock_archive.status = "archived" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), patch( "backend.app.core.websocket.ws_manager.send_archive_created", new_callable=AsyncMock, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") # One queue item was added. assert len(added_items) == 1 queue_item = added_items[0] # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1. assert queue_item.position == 8 @pytest.mark.asyncio async def test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate(self, tmp_path): """#1733: BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE 3MF containing every plate. Pre-fix only the first plate index was extracted and one queue item was created; plates 2..N were silently dropped. Post-fix every `` block in `slice_info.config` produces its own PrintQueueItem with the correct ``plate_id``, sharing the same backing archive, with consecutive positions for plate-order execution. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items: list = [] class _RecordingDb: def __init__(self): # Capture inserted items as they're added; assign a fake .id # on flush so the manager's logger doesn't see None. self._next_id = 1000 def _add(item): added_items.append(item) self.add = _add self.commit = AsyncMock() async def execute(self, query): # noqa: ARG002 """Return MAX(position) = 0 so plate items land at 1, 2, 3.""" result = MagicMock() result.scalar = MagicMock(return_value=0) return result async def flush(self): # Mimic the FK populate so queue_item.id is available after add(). for item in added_items: if getattr(item, "id", None) is None: item.id = self._next_id self._next_id += 1 mock_db = _RecordingDb() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=44, name="MultiPlateSendAll", mode="queue", model="O1D", # H2D — matches the live VP H2D-1 Proxy in #1733 access_code="12345678", serial_suffix="391800044", target_printer_id=1, auto_dispatch=False, # manual_start, mirrors the live VP base_dir=tmp_path, session_factory=mock_session_factory, ) # Build a 3MF with three plates baked into slice_info.config — # mirrors what BambuStudio / OrcaSlicer's "Send All" puts on the wire. file_path = tmp_path / "Cube.gcode.3mf" _write_3mf_with_filaments( file_path, [{"id": 1, "type": "PLA", "color": "#000000", "used_g": "15.61"}], plate_index=1 ) # Append plate 2 and 3 blocks to slice_info.config to mimic Send All. with zipfile.ZipFile(file_path, "r") as zf: existing = zf.read("Metadata/slice_info.config").decode() # Inject two additional blocks (indices 2 and 3) inside . multi_plate_config = existing.replace( "", ( '' '' "" '' '' "" "" ), ) # Repack the zip with the expanded slice_info.config. import io as _io buf = _io.BytesIO() with zipfile.ZipFile(file_path, "r") as src, zipfile.ZipFile(buf, "w") as dst: for name in src.namelist(): if name == "Metadata/slice_info.config": dst.writestr(name, multi_plate_config) else: dst.writestr(name, src.read(name)) # Plate-2 and plate-3 gcode payloads so `extract_filament_requirements` # has something to read for each — contents irrelevant, presence matters. dst.writestr("Metadata/plate_2.gcode", "; plate 2 gcode\n") dst.writestr("Metadata/plate_3.gcode", "; plate 3 gcode\n") file_path.write_bytes(buf.getvalue()) mock_archive = MagicMock() mock_archive.id = 999 mock_archive.printer_id = None mock_archive.filename = "Cube.gcode.3mf" mock_archive.print_name = "Cube" mock_archive.status = "archived" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), patch( "backend.app.core.websocket.ws_manager.send_archive_created", new_callable=AsyncMock, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") # Three queue items, one per plate, with the correct plate_id and # consecutive positions starting at MAX(position)+1 = 1. assert len(added_items) == 3, f"Expected 3 queue items for 3-plate Send All, got {len(added_items)}" plate_ids = [q.plate_id for q in added_items] assert plate_ids == [1, 2, 3], f"plate_ids should preserve slice_info order, got {plate_ids}" positions = [q.position for q in added_items] assert positions == [1, 2, 3], f"positions should be consecutive, got {positions}" archive_ids = {q.archive_id for q in added_items} assert archive_ids == {999}, f"All queue items must share the single backing archive, got {archive_ids}" # auto_dispatch=False on the VP → every item is manual_start. assert all(q.manual_start for q in added_items) @pytest.mark.asyncio async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path): """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends per-filament physical nozzle position IDs in `nozzle_mapping`. VP intake must store it as a JSON string on the queue item so the dispatcher can replay it. Without this the H2C firmware falls back to "last matching nozzle" auto-pick and ignores the user's slicer choice. """ import json as _json from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=42, name="H2CRack", mode="queue", model="O1C2", access_code="12345678", serial_suffix="391800042", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") # Pre-populate as if BS's project_file arrived. Wire shape matches # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of # per-filament physical nozzle position IDs (verified via H2C wire # capture). The slicer-side `nozzles_info` field that the original # #1780 attempt also looked for was never actually sent — it has # been dropped from the capture path entirely. await inst.on_print_command( file_path.name, { "command": "project_file", "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1], }, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 item = added_items[0] assert item.nozzle_mapping is not None assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1] @pytest.mark.asyncio async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path): """#1780: every model other than O1C2 sends no nozzle_mapping — the queue item must carry NULL, not an empty list. NULL is what the dispatch layer keys off of to skip the injection entirely on non- rack-swap printers. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=43, name="NotH2C", mode="queue", model="C11", access_code="12345678", serial_suffix="391800043", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") # X1C-style slicer command — no nozzle fields. await inst.on_print_command( file_path.name, {"command": "project_file", "timelapse": False, "bed_leveling": True}, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 item = added_items[0] assert item.nozzle_mapping is None @pytest.mark.asyncio async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch): """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the same nozzle_mapping on every plate's queue item, not only the first. Mirrors the per-plate stamping for gcode_injection, filament_overrides, etc. """ import json as _json from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items = [] mock_db = AsyncMock() mock_db.add = MagicMock(side_effect=added_items.append) mock_db.flush = AsyncMock() mock_db.commit = AsyncMock() mock_db.execute = AsyncMock() mock_db.execute.return_value.scalar.return_value = None mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=44, name="H2CMultiPlate", mode="queue", model="O1C2", access_code="12345678", serial_suffix="391800044", base_dir=tmp_path, session_factory=mock_session_factory, ) file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") # Force 3 plates so the queue loop runs three times. monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2, 3]) await inst.on_print_command( file_path.name, { "command": "project_file", "nozzle_mapping": [16, 0], }, ) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 3 for item in added_items: assert _json.loads(item.nozzle_mapping) == [16, 0] @pytest.mark.asyncio async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path): """#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER `_add_to_print_queue` already gave up waiting (observed at 2.085 s on H2C wireless setups). The queue item was committed with settings defaults; the slicer's nozzle_mapping + workflow flags must be patched onto it when MQTT lands, otherwise the H2C firmware falls back to auto-pick. """ import json as _json from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items: list = [] mock_db = AsyncMock() mock_db.add = MagicMock( side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0] ) async def _flush(): # added_items[-1].id was set by `add`; nothing else to do. return None mock_db.flush = AsyncMock(side_effect=_flush) mock_db.commit = AsyncMock() # First execute() call (the position-max SELECT inside _add_to_print_queue) # returns None; second (the eligible-pending SELECT in # _restamp_recent_queue_item) returns the committed queue id; third # (the UPDATE) is fire-and-forget. position_max_result = MagicMock() position_max_result.scalar = MagicMock(return_value=None) select_pending_result = MagicMock() select_pending_result.all = MagicMock(return_value=[(101,)]) update_result = MagicMock() mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result]) mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=99, name="LateMQTT", mode="queue", model="O1C2", access_code="12345678", serial_suffix="391800099", base_dir=tmp_path, session_factory=mock_session_factory, ) # MQTT server presence enables the wait_for path; we don't actually # use any methods on it. inst._mqtt = MagicMock() file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" # 1. _add_to_print_queue runs WITHOUT a prior on_print_command — # the wait_for times out (settings-default fallback) and the # queue item is committed. with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), # Shorten the wait so the test isn't slow. patch( "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") assert len(added_items) == 1 assert added_items[0].nozzle_mapping is None # MQTT was never received assert file_path.name in inst._recent_queue_items # 2. MQTT project_file arrives AFTER the wait expired — must # retroactively patch the queue item. await inst.on_print_command( file_path.name, { "command": "project_file", "file": file_path.name, "nozzle_mapping": [16, -1, -1, 1], "timelapse": True, "bed_leveling": False, }, ) # The UPDATE call is the third execute. Inspect its values. update_call = mock_db.execute.await_args_list[2] update_stmt = update_call.args[0] compiled = update_stmt.compile(compile_kwargs={"literal_binds": False}) params = dict(compiled.params) assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1] assert params["timelapse"] is True assert params["bed_levelling"] == "off" # MQTT bed_leveling → column bed_levelling (tri-state) # Recent-queue tracking dict is cleared after the patch. assert file_path.name not in inst._recent_queue_items @pytest.mark.asyncio async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path): """The actual race-window scenario: wait_for times out, then MQTT arrives and stashes options AFTER the wait but BEFORE the post-commit re-check. The post-commit pop must catch it. """ import json as _json from backend.app.services.virtual_printer.manager import VirtualPrinterInstance added_items: list = [] mock_db = AsyncMock() mock_db.add = MagicMock( side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0] ) mock_db.flush = AsyncMock() mock_db.commit = AsyncMock() position_max_result = MagicMock() position_max_result.scalar = MagicMock(return_value=None) select_pending_result = MagicMock() select_pending_result.all = MagicMock(return_value=[(301,)]) update_result = MagicMock() mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result]) mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=96, name="RaceCommitYield", mode="queue", model="O1C2", access_code="12345678", serial_suffix="391800096", base_dir=tmp_path, session_factory=mock_session_factory, ) inst._mqtt = MagicMock() file_path = tmp_path / "test.3mf" file_path.write_bytes(b"fake3mf") # Stash MQTT data on the FIRST commit (simulating MQTT arrival # during _add_to_print_queue's commit yield); _restamp also calls # db.commit later, so we one-shot the side effect. commit_calls = {"n": 0} async def _delayed_stash(*_args, **_kwargs): commit_calls["n"] += 1 if commit_calls["n"] == 1: inst._slicer_print_options[file_path.name] = { "command": "project_file", "file": file_path.name, "nozzle_mapping": [0, 16, -1, -1], "timelapse": False, } return None mock_db.commit = AsyncMock(side_effect=_delayed_stash) mock_archive = MagicMock() mock_archive.id = 1 mock_archive.print_name = "test" with ( patch( "backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None, ), patch( "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=mock_archive, ), patch( "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05, ), ): await inst._add_to_print_queue(file_path, "192.168.1.100") # Queue item INSERTed with defaults (wait timed out, no slicer_opts). assert len(added_items) == 1 # But the post-commit pop caught the late stash and applied the # slicer nozzle_mapping via _restamp's UPDATE. update_call = mock_db.execute.await_args_list[2] update_stmt = update_call.args[0] compiled = update_stmt.compile(compile_kwargs={"literal_binds": False}) params = dict(compiled.params) assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1] assert params["timelapse"] is False # _recent_queue_items entry was consumed by the post-commit # _restamp call. assert file_path.name not in inst._recent_queue_items # And the stash is empty. assert file_path.name not in inst._slicer_print_options @pytest.mark.asyncio async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path): """Once the scheduler has picked the queue item up (status != pending), the retroactive patch is a no-op — racing the dispatcher would be unsafe. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance mock_db = AsyncMock() # The eligible-pending SELECT returns nothing — item is no longer pending. empty_result = MagicMock() empty_result.all = MagicMock(return_value=[]) mock_db.execute = AsyncMock(return_value=empty_result) mock_db.commit = AsyncMock() mock_session_factory = MagicMock() mock_session_ctx = AsyncMock() mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) mock_session_ctx.__aexit__ = AsyncMock(return_value=False) mock_session_factory.return_value = mock_session_ctx inst = VirtualPrinterInstance( vp_id=98, name="LateMQTTDispatched", mode="queue", model="O1C2", access_code="12345678", serial_suffix="391800098", base_dir=tmp_path, session_factory=mock_session_factory, ) inst._mqtt = MagicMock() # Pre-seed the recent-queue dict — pretend _add_to_print_queue just # committed item id 42. inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0) # Drive _restamp via on_print_command on the late-MQTT path. with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0): await inst.on_print_command( "test.3mf", { "command": "project_file", "file": "test.3mf", "nozzle_mapping": [16, -1], }, ) # No UPDATE was issued — only the eligibility SELECT ran. assert mock_db.execute.await_count == 1 mock_db.commit.assert_not_awaited() assert "test.3mf" not in inst._recent_queue_items class TestVirtualPrinterManager: """Tests for VirtualPrinterManager orchestrator.""" @pytest.fixture def manager(self): """Create a VirtualPrinterManager instance.""" from backend.app.services.virtual_printer.manager import VirtualPrinterManager return VirtualPrinterManager() def test_manager_starts_empty(self, manager): """Verify manager starts with no instances.""" assert len(manager._instances) == 0 assert manager.is_enabled is False def test_manager_get_status_empty(self, manager): """Verify get_status returns disabled state when no instances.""" status = manager.get_status() assert status["enabled"] is False assert status["running"] is False assert status["mode"] == "archive" def test_manager_is_enabled_with_instance(self, manager, tmp_path): """Verify is_enabled is True when instances exist.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="Test", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) manager._instances[1] = inst assert manager.is_enabled is True @pytest.mark.asyncio async def test_manager_remove_instance_server(self, manager, tmp_path): """Verify remove_instance stops and removes a server-mode instance.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="Test", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst await manager.remove_instance(1) assert 1 not in manager._instances inst.stop_server.assert_called_once() @pytest.mark.asyncio async def test_manager_remove_instance_proxy(self, manager, tmp_path): """Verify remove_instance stops proxy-mode instance.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=2, name="Proxy", mode="proxy", model="C11", access_code="", serial_suffix="391800002", target_printer_ip="192.168.1.100", base_dir=tmp_path, ) inst.stop_proxy = AsyncMock() manager._instances[2] = inst await manager.remove_instance(2) assert 2 not in manager._instances inst.stop_proxy.assert_called_once() def test_manager_get_status_with_instance(self, manager, tmp_path): """Verify legacy get_status returns first instance data.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="Bambuddy", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) mock_task = MagicMock(done=MagicMock(return_value=False)) inst._tasks = [mock_task] inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108 manager._instances[1] = inst status = manager.get_status() assert status["enabled"] is True assert status["running"] is True assert status["mode"] == "archive" assert status["name"] == "Bambuddy" assert status["serial"] == "01S00A391800001" assert status["model"] == "C11" assert status["model_name"] == "P1P" assert status["pending_files"] == 1 def test_manager_get_all_status(self, manager, tmp_path): """Verify get_all_status returns status for all instances.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance for i in range(1, 3): inst = VirtualPrinterInstance( vp_id=i, name=f"VP{i}", mode="archive", model="C11", access_code="12345678", serial_suffix=f"39180000{i}", base_dir=tmp_path, ) manager._instances[i] = inst statuses = manager.get_all_status() assert len(statuses) == 2 assert statuses[0]["name"] == "VP1" assert statuses[1]["name"] == "VP2" @pytest.mark.asyncio async def test_manager_stop_all(self, manager, tmp_path): """Verify stop_all removes all instances.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance for i in range(1, 3): inst = VirtualPrinterInstance( vp_id=i, name=f"VP{i}", mode="archive", model="C11", access_code="12345678", serial_suffix=f"39180000{i}", base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[i] = inst await manager.stop_all() assert len(manager._instances) == 0 # ======================================================================== # Tests for sync_from_db config change detection # ======================================================================== def _make_db_vp(self, **overrides): """Create a mock VirtualPrinter DB object.""" defaults = { "id": 1, "name": "TestVP", "enabled": True, "mode": "archive", "model": "C11", "access_code": "12345678", "serial_suffix": "391800001", "bind_ip": "", "remote_interface_ip": "", "target_printer_id": None, "auto_dispatch": True, "tailscale_disabled": True, # Opt-in default (#1070 UX fix) "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector "gcode_injection": False, # same reason as above "position": 0, } defaults.update(overrides) vp = MagicMock() for k, v in defaults.items(): setattr(vp, k, v) return vp def _setup_sync_mocks(self, manager, enabled_vps, tmp_path): """Wire up session_factory mock for sync_from_db.""" mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = enabled_vps mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=mock_result) mock_db.__aenter__ = AsyncMock(return_value=mock_db) mock_db.__aexit__ = AsyncMock(return_value=False) manager._session_factory = MagicMock(return_value=mock_db) manager._base_dir = tmp_path @pytest.mark.asyncio async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path): """Verify sync_from_db restarts VP when mode changes.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst # DB says mode changed to "review" db_vp = self._make_db_vp(mode="review") self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: # Patch VirtualPrinterInstance to prevent actual start with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: mock_new = MagicMock() mock_new.start_server = AsyncMock() MockInst.return_value = mock_new await manager.sync_from_db() mock_remove.assert_called_once_with(1) @pytest.mark.asyncio async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path): """Verify sync_from_db restarts VP when access_code changes.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst db_vp = self._make_db_vp(access_code="newcode99") self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: mock_new = MagicMock() mock_new.start_server = AsyncMock() MockInst.return_value = mock_new await manager.sync_from_db() mock_remove.assert_called_once_with(1) @pytest.mark.asyncio async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path): """Verify sync_from_db does NOT restart when config is identical.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) manager._instances[1] = inst # DB matches running config exactly db_vp = self._make_db_vp() self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: await manager.sync_from_db() mock_remove.assert_not_called() @pytest.mark.asyncio async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path): """Verify sync_from_db restarts VP when bind_ip changes.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", bind_ip="192.168.1.10", base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst db_vp = self._make_db_vp(bind_ip="192.168.1.20") self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: mock_new = MagicMock() mock_new.start_server = AsyncMock() MockInst.return_value = mock_new await manager.sync_from_db() mock_remove.assert_called_once_with(1) @pytest.mark.asyncio async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path): """Verify sync_from_db restarts VP when model changes.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst db_vp = self._make_db_vp(model="C12") self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: mock_new = MagicMock() mock_new.start_server = AsyncMock() MockInst.return_value = mock_new await manager.sync_from_db() mock_remove.assert_called_once_with(1) @pytest.mark.asyncio async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path): """Flipping tailscale_disabled is purely informational — must NOT trigger a restart. Cert provisioning was removed; the toggle only governs whether the VP card surfaces the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing it through sync_from_db should leave any running instance untouched. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", tailscale_disabled=False, base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst db_vp = self._make_db_vp(tailscale_disabled=True) self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: await manager.sync_from_db() mock_remove.assert_not_called() @pytest.mark.asyncio async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path): """Toggling gcode_injection in the DB must restart the running instance. Without this, the in-memory ``self.gcode_injection`` keeps its old value and ``_add_to_print_queue`` stamps the stale flag on every new queue item — so disabling injection in the UI silently has no effect until the process restarts. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=1, name="TestVP", mode="archive", model="C11", access_code="12345678", serial_suffix="391800001", gcode_injection=True, base_dir=tmp_path, ) inst.stop_server = AsyncMock() manager._instances[1] = inst db_vp = self._make_db_vp(gcode_injection=False) self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: mock_new = MagicMock() mock_new.start_server = AsyncMock() MockInst.return_value = mock_new await manager.sync_from_db() mock_remove.assert_called_once_with(1) class TestFTPSession: """Tests for FTP session handling.""" @pytest.fixture def mock_reader(self): """Create a mock StreamReader.""" reader = AsyncMock() return reader @pytest.fixture def mock_writer(self): """Create a mock StreamWriter.""" writer = MagicMock() writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345)) writer.write = MagicMock() writer.drain = AsyncMock() writer.close = MagicMock() writer.wait_closed = AsyncMock() writer.is_closing = MagicMock(return_value=False) return writer @pytest.fixture def ssl_context(self): """Create a mock SSL context.""" return MagicMock() @pytest.fixture def session(self, mock_reader, mock_writer, ssl_context, tmp_path): """Create an FTPSession instance.""" from backend.app.services.virtual_printer.ftp_server import FTPSession return FTPSession( reader=mock_reader, writer=mock_writer, upload_dir=tmp_path, access_code="12345678", ssl_context=ssl_context, on_file_received=None, ) # ======================================================================== # Tests for authentication # ======================================================================== @pytest.mark.asyncio async def test_user_command_accepts_bblp(self, session): """Verify USER command accepts bblp user.""" await session.cmd_USER("bblp") assert session.username == "bblp" @pytest.mark.asyncio async def test_pass_command_authenticates(self, session): """Verify PASS command authenticates with correct code.""" session.username = "bblp" await session.cmd_PASS("12345678") assert session.authenticated is True @pytest.mark.asyncio async def test_pass_command_rejects_wrong_code(self, session): """Verify PASS command rejects wrong access code.""" session.username = "bblp" await session.cmd_PASS("wrongcode") assert session.authenticated is False # ======================================================================== # Tests for FTP commands # ======================================================================== @pytest.mark.asyncio async def test_syst_command(self, session): """Verify SYST returns UNIX type.""" await session.cmd_SYST("") session.writer.write.assert_called() call_args = session.writer.write.call_args[0][0].decode() assert "215" in call_args assert "UNIX" in call_args @pytest.mark.asyncio async def test_pwd_command_requires_auth(self, session): """Verify PWD requires authentication.""" session.authenticated = False await session.cmd_PWD("") call_args = session.writer.write.call_args[0][0].decode() assert "530" in call_args @pytest.mark.asyncio async def test_pwd_command_when_authenticated(self, session): """Verify PWD returns root directory when authenticated.""" session.authenticated = True await session.cmd_PWD("") call_args = session.writer.write.call_args[0][0].decode() assert "257" in call_args @pytest.mark.asyncio async def test_type_command_sets_binary(self, session): """Verify TYPE I sets binary mode.""" session.authenticated = True await session.cmd_TYPE("I") assert session.transfer_type == "I" @pytest.mark.asyncio async def test_pbsz_command(self, session): """Verify PBSZ returns success.""" await session.cmd_PBSZ("0") call_args = session.writer.write.call_args[0][0].decode() assert "200" in call_args @pytest.mark.asyncio async def test_prot_command_accepts_p(self, session): """Verify PROT P is accepted.""" await session.cmd_PROT("P") call_args = session.writer.write.call_args[0][0].decode() assert "200" in call_args @pytest.mark.asyncio async def test_quit_command(self, session): """Verify QUIT sends goodbye and raises CancelledError.""" with pytest.raises(asyncio.CancelledError): await session.cmd_QUIT("") class TestSSDPServer: """Tests for Virtual Printer SSDP server.""" @pytest.fixture def ssdp_server(self): """Create a VirtualPrinterSSDPServer instance.""" from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer return VirtualPrinterSSDPServer( serial="TEST123", name="TestPrinter", model="BL-P001", ) # ======================================================================== # Tests for SSDP response # ======================================================================== def test_build_notify_message(self, ssdp_server): """Verify NOTIFY packet contains required headers.""" # Set a known IP for testing ssdp_server._local_ip = "192.168.1.100" message = ssdp_server._build_notify_message() assert b"NOTIFY" in message assert b"DevName.bambu.com: TestPrinter" in message assert b"USN: TEST123" in message def test_build_response_message(self, ssdp_server): """Verify response packet contains required headers.""" # Set a known IP for testing ssdp_server._local_ip = "192.168.1.100" message = ssdp_server._build_response_message() assert b"HTTP/1.1 200 OK" in message assert b"DevName.bambu.com: TestPrinter" in message assert b"USN: TEST123" in message def test_ssdp_server_uses_correct_model(self, ssdp_server): """Verify SSDP server uses the provided model.""" ssdp_server._local_ip = "192.168.1.100" message = ssdp_server._build_notify_message() assert b"DevModel.bambu.com: BL-P001" in message # ======================================================================== # Tests for advertise_ip parameter # ======================================================================== def test_advertise_ip_sets_local_ip(self): """Verify advertise_ip overrides auto-detection.""" from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer server = VirtualPrinterSSDPServer( serial="TEST123", name="TestPrinter", model="BL-P001", advertise_ip="10.0.0.50", ) assert server._local_ip == "10.0.0.50" def test_advertise_ip_empty_string_uses_auto_detect(self): """Verify empty advertise_ip falls back to auto-detection.""" from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer server = VirtualPrinterSSDPServer( serial="TEST123", name="TestPrinter", model="BL-P001", advertise_ip="", ) assert server._local_ip is None def test_advertise_ip_in_notify_message(self): """Verify NOTIFY message uses the advertise_ip.""" from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer server = VirtualPrinterSSDPServer( serial="TEST123", name="TestPrinter", model="BL-P001", advertise_ip="10.0.0.50", ) message = server._build_notify_message() assert b"Location: 10.0.0.50" in message def test_advertise_ip_in_response_message(self): """Verify M-SEARCH response uses the advertise_ip.""" from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer server = VirtualPrinterSSDPServer( serial="TEST123", name="TestPrinter", model="BL-P001", advertise_ip="10.0.0.50", ) message = server._build_response_message() assert b"Location: 10.0.0.50" in message def test_default_no_advertise_ip(self): """Verify default constructor has None local_ip (auto-detect).""" from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer server = VirtualPrinterSSDPServer() assert server._local_ip is None class TestCertificateService: """Tests for TLS certificate generation.""" @pytest.fixture def cert_service(self, tmp_path): """Create a CertificateService instance.""" from backend.app.services.virtual_printer.certificate import CertificateService return CertificateService(cert_dir=tmp_path, serial="TEST123") def test_generate_certificates(self, cert_service, tmp_path): """Verify certificates are generated correctly.""" cert_path, key_path = cert_service.generate_certificates() assert cert_path.exists() assert key_path.exists() # Verify certificate content cert_content = cert_path.read_text() assert "BEGIN CERTIFICATE" in cert_content key_content = key_path.read_text() assert "BEGIN" in key_content and "KEY" in key_content def test_certificates_reused_if_exist(self, cert_service): """Verify existing certificates are reused.""" # First generation cert_path1, key_path1 = cert_service.generate_certificates() mtime1 = cert_path1.stat().st_mtime # Second call should reuse (via ensure_certificates) cert_path2, key_path2 = cert_service.ensure_certificates() mtime2 = cert_path2.stat().st_mtime assert mtime1 == mtime2 # File wasn't regenerated def test_delete_certificates(self, cert_service): """Verify certificates can be deleted.""" cert_service.generate_certificates() assert cert_service.cert_path.exists() assert cert_service.key_path.exists() cert_service.delete_certificates() assert not cert_service.cert_path.exists() assert not cert_service.key_path.exists() def test_ensure_creates_if_not_exist(self, cert_service): """Verify ensure_certificates generates if not existing.""" assert not cert_service.cert_path.exists() cert_path, key_path = cert_service.ensure_certificates() assert cert_path.exists() assert key_path.exists() class TestBindServer: """Tests for BindServer (port 3002 bind/detect protocol).""" @pytest.fixture def bind_server(self): """Create a BindServer instance.""" from backend.app.services.virtual_printer.bind_server import BindServer return BindServer( serial="09400A391800001", model="O1D", name="Bambuddy", ) def test_build_frame(self, bind_server): """Verify frame building produces correct format.""" payload = {"login": {"command": "detect"}} frame = bind_server._build_frame(payload) # Header: 0xA5A5 assert frame[:2] == b"\xa5\xa5" # Trailer: 0xA7A7 assert frame[-2:] == b"\xa7\xa7" # Length field is total message size (LE uint16) import struct total_len = struct.unpack_from(" list[dict]: from backend.app.services.virtual_printer.manager import VirtualPrinterInstance tcp_calls = self._patch_start_server(monkeypatch) instance = VirtualPrinterInstance( vp_id=99, name="CamTest", mode="archive", model="BL-P001", # VP's spoofed identity — irrelevant here access_code="12345678", serial_suffix="391800099", target_printer_id=7, base_dir=tmp_path, ) # printer_manager stub returns a client with the model + ip we want. client = MagicMock() client.ip_address = target_ip client.model = target_model printer_manager = MagicMock() printer_manager.get_client.return_value = client instance._printer_manager = printer_manager # Cert / advertise resolution — start_server calls this early. Patch # to a fixed tuple so no filesystem I/O is required. monkeypatch.setattr( instance, "_resolve_cert_and_advertise", lambda: (Path("/tmp/cert.pem"), Path("/tmp/key.pem"), "192.168.1.1"), # nosec B108 ) try: await instance.start_server() finally: for task in instance._tasks: task.cancel() await asyncio.gather(*instance._tasks, return_exceptions=True) return tcp_calls @pytest.mark.asyncio async def test_rtsp_model_p2s_opens_port_322(self, tmp_path, monkeypatch): calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P2S") assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls), ( f"Expected 322 pass-through for RTSP model P2S, got {calls}" ) @pytest.mark.asyncio async def test_chamber_image_model_p1s_opens_port_6000(self, tmp_path, monkeypatch): """#1868 regression guard: P1S target must expose 6000, not 322.""" calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P1S") assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls), ( f"Expected 6000 pass-through for chamber-image model P1S (#1868), got {calls}" ) assert not any(c["listen_port"] == 322 for c in calls), f"P1S should NOT get a 322 listener, got {calls}" @pytest.mark.asyncio async def test_chamber_image_model_a1_opens_port_6000(self, tmp_path, monkeypatch): calls = await self._run_start_server(tmp_path, monkeypatch, target_model="A1") assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls) @pytest.mark.asyncio async def test_rtsp_model_x1c_opens_port_322(self, tmp_path, monkeypatch): calls = await self._run_start_server(tmp_path, monkeypatch, target_model="X1C") assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls) class TestVirtualPrinterInstanceProxyMode: """Tests for VirtualPrinterInstance proxy mode.""" @pytest.fixture def proxy_instance(self, tmp_path): """Create a proxy-mode VirtualPrinterInstance.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance return VirtualPrinterInstance( vp_id=10, name="ProxyTest", mode="proxy", model="C11", access_code="", serial_suffix="391800010", target_printer_ip="192.168.1.100", target_printer_serial="01P00A000000001", base_dir=tmp_path, ) def test_proxy_instance_properties(self, proxy_instance): """Verify proxy instance stores config correctly.""" assert proxy_instance.is_proxy is True assert proxy_instance.mode == "proxy" assert proxy_instance.target_printer_ip == "192.168.1.100" assert proxy_instance.target_printer_serial == "01P00A000000001" def test_proxy_instance_does_not_require_access_code(self, proxy_instance): """Verify proxy mode can have empty access code.""" assert proxy_instance.access_code == "" def test_get_status_proxy_includes_proxy_fields(self, proxy_instance): """Verify get_status includes proxy fields when proxy is active.""" mock_proxy = MagicMock() mock_proxy.get_status.return_value = { "running": True, "ftp_port": 990, "mqtt_port": 8883, "ftp_connections": 1, "mqtt_connections": 2, "target_host": "192.168.1.100", } proxy_instance._proxy = mock_proxy status = proxy_instance.get_status() assert "proxy" in status assert status["proxy"]["ftp_port"] == 990 assert status["proxy"]["mqtt_connections"] == 2 def test_proxy_instance_stores_remote_interface(self, tmp_path): """Verify proxy instance stores remote_interface_ip.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=11, name="Proxy2", mode="proxy", model="C11", access_code="", serial_suffix="391800011", target_printer_ip="192.168.1.100", remote_interface_ip="10.0.0.50", base_dir=tmp_path, ) assert inst.remote_interface_ip == "10.0.0.50" class TestVirtualPrinterInstanceIPOverride: """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance.""" @pytest.fixture def instance_with_remote_ip(self, tmp_path): """Create an instance with remote_interface_ip set.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance return VirtualPrinterInstance( vp_id=20, name="IPTest", mode="archive", model="BL-P001", access_code="12345678", serial_suffix="391800020", bind_ip="192.168.1.50", remote_interface_ip="10.0.0.50", base_dir=tmp_path, ) def test_instance_stores_bind_ip(self, instance_with_remote_ip): """Verify bind_ip is stored.""" assert instance_with_remote_ip.bind_ip == "192.168.1.50" def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip): """Verify remote_interface_ip is stored.""" assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50" def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip): """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs.""" with ( patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"), patch.object( instance_with_remote_ip._cert_service, "generate_certificates", return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108 ) as mock_gen, ): instance_with_remote_ip.generate_certificates() mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"]) def test_generate_certificates_no_remote_ip(self, tmp_path): """Verify generate_certificates passes only bind_ip when no remote_interface_ip.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=21, name="NoRemote", mode="archive", model="BL-P001", access_code="12345678", serial_suffix="391800021", bind_ip="192.168.1.50", base_dir=tmp_path, ) with ( patch.object(inst._cert_service, "delete_printer_certificate"), patch.object( inst._cert_service, "generate_certificates", return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108 ) as mock_gen, ): inst.generate_certificates() mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"]) def test_generate_certificates_no_ips(self, tmp_path): """Verify generate_certificates passes None when no IPs configured.""" from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( vp_id=22, name="NoIPs", mode="archive", model="BL-P001", access_code="12345678", serial_suffix="391800022", base_dir=tmp_path, ) with ( patch.object(inst._cert_service, "delete_printer_certificate"), patch.object( inst._cert_service, "generate_certificates", return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108 ) as mock_gen, ): inst.generate_certificates() mock_gen.assert_called_once_with(additional_ips=None) class TestBindServer: """Tests for the BindServer (port 3002 bind/detect protocol).""" @pytest.fixture def bind_server(self): """Create a BindServer instance.""" from backend.app.services.virtual_printer.bind_server import BindServer return BindServer( serial="01S00C000000001", model="BL-P001", name="Bambuddy", ) def test_build_frame(self, bind_server): """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7.""" payload = {"login": {"command": "detect"}} frame = bind_server._build_frame(payload) assert frame[:2] == b"\xa5\xa5" assert frame[-2:] == b"\xa7\xa7" # Length field is total message size import struct total_len = struct.unpack_from(" bytes: """Build a minimal MQTT PUBLISH packet.""" # PUBLISH fixed header: type 3, no flags topic_bytes = topic.encode("utf-8") # Variable header: topic length (2 bytes) + topic var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes body = var_header + payload # Encode remaining length remaining = len(body) header = bytearray([0x30]) # PUBLISH, QoS 0 while True: encoded_byte = remaining % 128 remaining //= 128 if remaining > 0: encoded_byte |= 0x80 header.append(encoded_byte) if remaining == 0: break return bytes(header) + body @staticmethod def _build_mqtt_pingreq() -> bytes: """Build an MQTT PINGREQ packet (2 bytes, no payload).""" return b"\xc0\x00" def test_rewrite_ip_in_publish(self): """IP string in PUBLISH payload is rewritten.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}' packet = self._build_mqtt_publish("device/status", payload) result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray()) assert b"10.0.0.1" in result assert b"192.168.1.100" not in result def test_no_rewrite_when_ip_absent(self): """Packets without the target IP are passed through unchanged.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy payload = b'{"status":"idle"}' packet = self._build_mqtt_publish("device/status", payload) result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray()) assert result == packet def test_non_publish_packets_unchanged(self): """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy pingreq = self._build_mqtt_pingreq() result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray()) assert result == pingreq def test_rewrite_preserves_packet_framing(self): """Rewritten packet has valid MQTT remaining length.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy # Use IPs of different lengths to test length re-encoding old_ip = b"192.168.255.133" # 15 bytes new_ip = b"10.0.0.1" # 8 bytes payload = b'{"ip":"192.168.255.133"}' packet = self._build_mqtt_publish("device/status", payload) result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray()) # Parse the result to verify framing assert result[0] == 0x30 # PUBLISH header byte # Decode remaining length pos = 1 remaining = 0 multiplier = 1 while True: b = result[pos] pos += 1 remaining += (b & 0x7F) * multiplier multiplier *= 128 if (b & 0x80) == 0: break # Remaining length should match actual data assert pos + remaining == len(result) assert new_ip in result def test_incomplete_packet_buffered(self): """Incomplete packet at end of chunk is buffered for next call.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy payload = b'{"ip":"192.168.1.100"}' packet = self._build_mqtt_publish("device/status", payload) # Split packet in the middle half = len(packet) // 2 chunk1 = packet[:half] chunk2 = packet[half:] result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray()) # First chunk should be buffered (incomplete packet) assert len(buf) > 0 result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf) # Second chunk completes the packet, IP should be rewritten combined = result1 + result2 assert b"10.0.0.1" in combined assert b"192.168.1.100" not in combined def test_multiple_packets_in_one_chunk(self): """Multiple MQTT packets in a single chunk are all processed.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy payload1 = b'{"ip":"192.168.1.100"}' payload2 = b'{"other":"data"}' packet1 = self._build_mqtt_publish("topic1", payload1) packet2 = self._build_mqtt_publish("topic2", payload2) combined = packet1 + packet2 result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray()) assert b"10.0.0.1" in result assert b"192.168.1.100" not in result # Second packet should still be present assert b"other" in result def test_extra_replacements(self): """Extra replacement pairs (e.g. integer IP) are also applied.""" from backend.app.services.virtual_printer.tcp_proxy import TLSProxy payload = b'{"net":{"info":[{"ip":2248124608}]}}' packet = self._build_mqtt_publish("device/status", payload) result, buf = TLSProxy._rewrite_mqtt_ip( packet, b"NOMATCH", b"NOREPLACE", bytearray(), extra_replacements=[(b"2248124608", b"285190336")], ) assert b"285190336" in result assert b"2248124608" not in result class TestIpToLeIntBytes: """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion.""" def test_converts_ip_to_le_int(self): from backend.app.services.virtual_printer.tcp_proxy import TLSProxy assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608" assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336" assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226" def test_roundtrip(self): """Verify the integer converts back to the correct IP.""" import struct from backend.app.services.virtual_printer.tcp_proxy import TLSProxy for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]: le_int = int(TLSProxy._ip_to_le_int_bytes(ip)) parts = ip.split(".") expected = struct.unpack("