| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- """A 3MF whose transfer timed out is not downloaded a second time (#3308).
- The reporter's A1 serves FTPS at ~87 KB/s during its start-up routine. The
- sweep found ``/side_skull_Medium 7x5.5x2 in.gcode.3mf`` -- the printer answered
- SIZE -- and spent the 300 s its size bought it without finishing. The directory
- walk then listed the folder, picked that same file as the exact-name match and
- spent another 300 s on it: twelve minutes holding the printer's one FTPS slot
- at its slowest, with the cover lookups queued behind it, before the fallback
- archive and its recovery retry (which may take 900 s) could even start.
- A path the sweep failed to transfer is skipped by the walk. Only that path:
- while a file of the print's own name is listed the walk offers no file that
- merely contains the name, so skipping it cannot let a look-alike stand in. And it counts as a
- file listed for the print whose transfer failed, so an earlier run's 3MF
- (#3307) does not stand in for it either -- the retry fetches the real file.
- """
- from contextlib import ExitStack
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.core.config import settings as app_settings
- from backend.app.main import (
- _active_prints,
- _expected_print_creators,
- _expected_print_registered_at,
- _expected_prints,
- _print_ams_mappings,
- _timelapse_baselines,
- )
- from backend.app.services.bambu_ftp import FileNotOnPrinterError
- pytestmark = pytest.mark.unit
- FILE = "side_skull_Medium 7x5.5x2 in.gcode.3mf"
- EXACT = f"/{FILE}"
- @pytest.fixture(autouse=True)
- def _isolated(tmp_path):
- dicts = (
- _expected_prints,
- _expected_print_registered_at,
- _expected_print_creators,
- _print_ams_mappings,
- _active_prints,
- _timelapse_baselines,
- )
- for d in dicts:
- d.clear()
- with patch.object(app_settings, "archive_dir", tmp_path / "archive"):
- yield
- for d in dicts:
- d.clear()
- def _printer():
- printer = MagicMock()
- printer.id = 2
- printer.auto_archive = True
- printer.external_camera_enabled = False
- printer.external_camera_url = None
- printer.plate_detection_enabled = False
- printer.name = "A1"
- printer.model = "A1"
- printer.ip_address = "192.0.2.20"
- printer.access_code = "12345678"
- return printer
- async def _print_start(*, transfer, listing):
- """on_print_start for the reporter's A1 print; *transfer* decides each path."""
- printer = _printer()
- def execute_router(stmt, *args, **kwargs):
- sql = str(stmt).lower()
- if "from printers" in sql or "from printer " in sql:
- return MagicMock(
- scalar_one_or_none=MagicMock(return_value=printer),
- scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
- )
- return MagicMock(
- scalar_one_or_none=MagicMock(return_value=None),
- scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
- )
- added: list = []
- session = AsyncMock()
- session.__aenter__ = AsyncMock(return_value=session)
- session.__aexit__ = AsyncMock()
- session.execute = AsyncMock(side_effect=execute_router)
- session.commit = AsyncMock()
- session.refresh = AsyncMock()
- session.add = MagicMock(side_effect=added.append)
- attempts: list[str] = []
- async def download(ip, code, remote_path, local_path, **kwargs):
- attempts.append(remote_path)
- return await transfer(remote_path, local_path)
- state = MagicMock(current_project_url=None, sdcard=True, sdcard_reported=True)
- archived = MagicMock(id=99, printer_id=2, status="printing", filename=FILE)
- service = MagicMock()
- service.archive_print = AsyncMock(return_value=archived)
- finder = AsyncMock(return_value=None)
- retry = MagicMock()
- with ExitStack() as stack:
- session_maker = stack.enter_context(patch("backend.app.main.async_session"))
- notif = stack.enter_context(patch("backend.app.main.notification_service"))
- plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
- ws = stack.enter_context(patch("backend.app.main.ws_manager"))
- relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
- pm = stack.enter_context(patch("backend.app.main.printer_manager"))
- stack.enter_context(patch("backend.app.main.download_file_async", new=download))
- stack.enter_context(patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)))
- stack.enter_context(patch("backend.app.main.get_cached_3mf", return_value=None))
- stack.enter_context(patch("backend.app.main.cache_3mf_download"))
- stack.enter_context(patch("backend.app.main.peek_plate_index_in_3mf", return_value=None))
- stack.enter_context(
- patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=list(listing)))
- )
- stack.enter_context(patch("backend.app.main.ftps_handshake_blocked", return_value=False))
- stack.enter_context(
- patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30)))
- )
- stack.enter_context(patch("backend.app.main._record_energy_start", new_callable=AsyncMock))
- stack.enter_context(patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock))
- stack.enter_context(patch("backend.app.main._maybe_start_layer_timelapse"))
- stack.enter_context(patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock))
- stack.enter_context(patch("backend.app.main._schedule_fallback_3mf_retry", new=retry))
- stack.enter_context(
- patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(return_value=False))
- )
- stack.enter_context(
- patch("backend.app.main._drop_archive_of_deleted_printer", new=AsyncMock(return_value=False))
- )
- stack.enter_context(patch("backend.app.main._printer_gone", new=AsyncMock(return_value=False)))
- stack.enter_context(patch("backend.app.main.ArchiveService", return_value=service))
- stack.enter_context(patch("backend.app.main._archived_3mf_for_repeat", new=finder))
- session_maker.return_value = session
- notif.on_print_start = AsyncMock()
- plug.on_print_start = AsyncMock()
- ws.send_print_start = AsyncMock()
- ws.send_archive_updated = AsyncMock()
- ws.send_archive_created = AsyncMock()
- relay.on_print_start = AsyncMock()
- pm.get_status = MagicMock(return_value=state)
- pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3308"))
- from backend.app.main import on_print_start
- await on_print_start(2, {"filename": FILE, "subtask_name": FILE})
- fallback = next(
- (r for r in added if isinstance(getattr(r, "extra_data", None), dict) and r.extra_data.get("no_3mf_available")),
- None,
- )
- return SimpleNamespace(attempts=attempts, fallback=fallback, service=service, finder=finder, retry=retry)
- def _listed(*names):
- return [{"name": name, "is_directory": False} for name in names]
- async def _times_out_at_root(remote_path, local_path):
- """The exact path is served but never finishes; every other path is a 550."""
- if remote_path == EXACT:
- return False
- raise FileNotOnPrinterError(remote_path)
- class TestNoSecondDownload:
- @pytest.mark.asyncio
- async def test_the_reported_case_tries_the_file_once(self):
- run = await _print_start(
- transfer=_times_out_at_root,
- listing=_listed("FractMod.gcode.3mf", f"sort_of_{FILE}", FILE),
- )
- assert run.attempts.count(EXACT) == 1
- @pytest.mark.asyncio
- async def test_a_transfer_that_errored_is_not_tried_again_either(self):
- async def errors_at_root(remote_path, local_path):
- if remote_path == EXACT:
- raise OSError("connection reset mid-transfer")
- raise FileNotOnPrinterError(remote_path)
- run = await _print_start(transfer=errors_at_root, listing=_listed(FILE))
- assert run.attempts.count(EXACT) == 1
- @pytest.mark.asyncio
- async def test_and_leaves_it_to_the_retry(self):
- run = await _print_start(transfer=_times_out_at_root, listing=_listed(FILE))
- assert run.fallback is not None
- assert run.fallback.extra_data["no_3mf_reason"] == "ftp_transfer_failed"
- run.retry.assert_called_once()
- @pytest.mark.asyncio
- async def test_no_file_that_only_contains_the_name_stands_in(self):
- run = await _print_start(transfer=_times_out_at_root, listing=_listed(f"sort_of_{FILE}", FILE))
- assert f"/sort_of_{FILE}" not in run.attempts
- run.service.archive_print.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_nor_an_earlier_runs_3mf(self):
- """The file is on the card under its own name; #3307's stand-in is
- for a file the printer never names."""
- run = await _print_start(transfer=_times_out_at_root, listing=_listed(FILE))
- run.finder.assert_not_awaited()
- def _served_on_second_try(path, *, timed_out=()):
- """*path* answers 550 to the sweep and is served to the walk, standing for
- a file the walk reaches; paths in *timed_out* never finish."""
- seen: dict[str, int] = {}
- async def transfer(remote_path, local_path):
- seen[remote_path] = seen.get(remote_path, 0) + 1
- if remote_path in timed_out:
- return False
- if remote_path == path and seen[remote_path] == 2:
- local_path.parent.mkdir(parents=True, exist_ok=True)
- local_path.write_bytes(b"PK\x03\x04")
- return True
- raise FileNotOnPrinterError(remote_path)
- return transfer
- class TestTheWalkOtherwiseAsBefore:
- @pytest.mark.asyncio
- async def test_550_everywhere_still_walks_and_downloads(self):
- run = await _print_start(transfer=_served_on_second_try(f"/cache/{FILE}"), listing=_listed(FILE))
- assert run.attempts.count(f"/cache/{FILE}") == 2
- run.service.archive_print.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_only_the_failed_path_is_skipped(self):
- run = await _print_start(
- transfer=_served_on_second_try(f"/cache/{FILE}", timed_out=(EXACT,)), listing=_listed(FILE)
- )
- assert run.attempts.count(EXACT) == 1
- run.service.archive_print.assert_awaited_once()
|