| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- """Say what actually went wrong, not what usually does (#2899).
- Every failed dispatch upload used to carry the same sentence: "Failed to upload
- file to printer. Check if SD card is inserted and properly formatted
- (FAT32/exFAT)." The reporter got it after a TLS handshake failure and restarted
- the printer on the strength of it. That could not have helped -- the handshake
- never reached the printer's filesystem, and the cool-off that produced the
- repeat failure lives in Bambuddy's own memory, where power-cycling a printer
- does not reach.
- #2780 had already removed operator advice from this failure's *log* line, for
- exactly this reason. The advice survived in the string people actually read.
- The information was never missing. ``connect`` separates five failure classes
- and ``upload_file`` separates 553/552/550, each with its own log line -- and
- both then returned a bare ``False``. These tests pin the reason travelling out
- to the caller, and the card being named only where the printer itself raised
- storage.
- """
- import ftplib # nosec B402 -- tests construct real ftplib error types
- import ssl
- import time
- from contextlib import ExitStack
- from pathlib import Path
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services.bambu_ftp import (
- BambuFTPClient,
- FtpFailure,
- FtpFailureKind,
- FtpFailureReport,
- describe_upload_failure,
- upload_file_async,
- with_ftp_retry,
- )
- pytestmark = pytest.mark.unit
- IP = "192.168.50.142"
- @pytest.fixture(autouse=True)
- def _clean_state():
- BambuFTPClient._handshake_blocked_until.clear()
- BambuFTPClient._handshake_skip_logged.clear()
- BambuFTPClient._mode_cache.clear()
- yield
- BambuFTPClient._handshake_blocked_until.clear()
- BambuFTPClient._handshake_skip_logged.clear()
- BambuFTPClient._mode_cache.clear()
- # ---------------------------------------------------------------------------
- # The client records which of its own branches it took
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize(
- ("error", "kind", "code"),
- [
- (ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"), FtpFailureKind.HANDSHAKE, None),
- (TimeoutError("handshake operation timed out"), FtpFailureKind.TIMEOUT, None),
- (ftplib.error_perm("530 Login incorrect."), FtpFailureKind.AUTH, "530"),
- (OSError("Connection reset by peer"), FtpFailureKind.NETWORK, None),
- ],
- ids=["handshake", "timeout", "auth", "network"],
- )
- def test_connect_records_which_failure_it_hit(error, kind, code):
- transport = MagicMock()
- transport.connect.side_effect = error
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
- client = BambuFTPClient(IP, "12345678", printer_model="P2S")
- assert client.connect() is False
- assert client.last_failure is not None
- assert client.last_failure.kind is kind
- assert client.last_failure.code == code
- # The underlying text is kept too -- the sentence is for the operator, the
- # detail is for whoever reads the log next to it.
- assert str(error)[:20] in client.last_failure.detail
- def test_the_cooloff_skip_is_its_own_kind():
- """ "We did not try" is not the same failure as "we tried and it broke"."""
- BambuFTPClient._handshake_blocked_until[IP] = time.monotonic() + 300
- client = BambuFTPClient(IP, "12345678")
- assert client.connect() is False
- assert client.last_failure is not None
- assert client.last_failure.kind is FtpFailureKind.COOLOFF
- @pytest.mark.parametrize(
- ("reply", "kind"),
- [
- ("553 Could not create file.", FtpFailureKind.STORAGE),
- ("552 Storage quota exceeded.", FtpFailureKind.STORAGE),
- ("550 Permission denied.", FtpFailureKind.NOT_FOUND),
- ("500 Unknown command.", FtpFailureKind.UNKNOWN),
- ],
- ids=["553", "552", "550", "500"],
- )
- def test_upload_classifies_the_printers_reply_code(reply, kind, tmp_path):
- """553 and 552 are the printer talking about its own storage.
- That is the one case where naming the SD card is worth anything, and it is
- the case the blanket message was written for before it was applied to
- every failure alike.
- """
- local = tmp_path / "job.3mf"
- local.write_bytes(b"x" * 16)
- client = BambuFTPClient(IP, "12345678")
- client._ftp = MagicMock()
- client._ftp.transfercmd.side_effect = ftplib.error_perm(reply)
- assert client.upload_file(local, "/job.3mf") is False
- assert client.last_failure is not None
- assert client.last_failure.kind is kind
- assert client.last_failure.code == reply[:3]
- def test_a_successful_upload_leaves_no_failure_behind(tmp_path):
- """Otherwise a later failure inherits an earlier one's reason."""
- local = tmp_path / "job.3mf"
- local.write_bytes(b"x" * 16)
- client = BambuFTPClient(IP, "12345678")
- client._ftp = MagicMock()
- client.last_failure = FtpFailure(FtpFailureKind.STORAGE, "553 stale", "553")
- assert client.upload_file(local, "/job.3mf") is True
- assert client.last_failure is None
- # ---------------------------------------------------------------------------
- # The reason reaches the caller
- # ---------------------------------------------------------------------------
- class TestTheReportReachesTheCaller:
- @pytest.fixture()
- def refusing_printer(self):
- transport = MagicMock()
- transport.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
- yield transport
- async def test_upload_file_async_fills_the_slot(self, refusing_printer, tmp_path):
- local = tmp_path / "job.3mf"
- local.write_bytes(b"x" * 16)
- report = FtpFailureReport()
- assert await upload_file_async(IP, "12345678", local, "/job.3mf", timeout=5.0, failure=report) is False
- assert report.failure is not None
- assert report.failure.kind is FtpFailureKind.HANDSHAKE
- async def test_it_survives_the_retry_loop(self, refusing_printer, tmp_path):
- """with_ftp_retry forwards the slot untouched, so the last try wins.
- The last attempt is the one that decided the outcome, so its reason is
- the one the operator should be given.
- """
- local = tmp_path / "job.3mf"
- local.write_bytes(b"x" * 16)
- report = FtpFailureReport()
- result = await with_ftp_retry(
- upload_file_async,
- IP,
- "12345678",
- local,
- "/job.3mf",
- timeout=5.0,
- respect_handshake_cooloff=False,
- failure=report,
- max_retries=2,
- retry_delay=0.01,
- )
- assert result is None
- assert report.failure is not None
- assert report.failure.kind is FtpFailureKind.HANDSHAKE
- async def test_two_callers_do_not_cross(self, refusing_printer, tmp_path):
- """The slot belongs to the caller, not to the printer.
- A per-IP dict on the client would be the obvious way to do this, and
- it is the way that breaks: a background timelapse fetch running beside
- a dispatch would overwrite the dispatch's reason with its own, and
- report the wrong cause with total confidence.
- """
- local = tmp_path / "job.3mf"
- local.write_bytes(b"x" * 16)
- mine, theirs = FtpFailureReport(), FtpFailureReport()
- await upload_file_async(IP, "12345678", local, "/a.3mf", timeout=5.0, failure=mine)
- assert theirs.failure is None
- assert mine.failure is not None
- async def test_a_caller_that_does_not_ask_is_unaffected(self, refusing_printer, tmp_path):
- """Every other caller passes nothing and must keep working."""
- local = tmp_path / "job.3mf"
- local.write_bytes(b"x" * 16)
- assert await upload_file_async(IP, "12345678", local, "/job.3mf", timeout=5.0) is False
- # ---------------------------------------------------------------------------
- # The wording
- # ---------------------------------------------------------------------------
- class TestTheWording:
- def test_only_a_storage_reply_sends_anyone_to_the_card(self):
- """Advice about the card, not mention of it.
- The handshake message names the card too, to rule it out -- that is
- the opposite of what this is guarding against, so the marker is the
- instruction ("formatted FAT32 or exFAT"), not the noun.
- """
- advising = [k for k in FtpFailureKind if "FAT32" in describe_upload_failure(FtpFailure(k, "detail"))]
- assert advising == [FtpFailureKind.STORAGE]
- def test_no_other_failure_asks_anyone_to_touch_the_card(self):
- """Anything that is not a storage reply must not send them there.
- Checked as "do something to the card" rather than "say the words",
- since ruling the card out is exactly what the handshake message does.
- """
- for kind in FtpFailureKind:
- if kind is FtpFailureKind.STORAGE:
- continue
- message = describe_upload_failure(FtpFailure(kind, "detail"))
- assert "Check that its SD card" not in message, kind
- assert "inserted" not in message, kind
- @pytest.mark.parametrize(
- ("kind", "must_say"),
- [
- (FtpFailureKind.COOLOFF, "clears on its own"),
- (FtpFailureKind.HANDSHAKE, "not with TLS"),
- (FtpFailureKind.AUTH, "access code"),
- (FtpFailureKind.TIMEOUT, "did not respond in time"),
- (FtpFailureKind.STORAGE, "FAT32"),
- (FtpFailureKind.NOT_FOUND, "Bambuddy-side"),
- (FtpFailureKind.NETWORK, "server log"),
- (FtpFailureKind.UNKNOWN, "server log"),
- ],
- )
- def test_every_kind_says_something_of_its_own(self, kind, must_say):
- """One line per branch, so none can quietly collapse into the generic.
- Without this, deleting the access-code branch or the timeout branch
- leaves every other assertion here passing -- they only check that the
- card is not named, which the generic message also satisfies.
- """
- assert must_say in describe_upload_failure(FtpFailure(kind, "detail", "553"))
- def test_the_access_code_hint_names_a_screen_that_exists(self):
- """The Access Code field is on the printer form on the Printers page.
- Naming a screen that is not there would be its own version of this
- bug: confident, specific, and a waste of the reader's time.
- """
- message = describe_upload_failure(FtpFailure(FtpFailureKind.AUTH, "530 Login incorrect.", "530"))
- assert "Printers page" in message
- def test_a_handshake_failure_says_the_card_is_not_involved(self):
- message = describe_upload_failure(FtpFailure(FtpFailureKind.HANDSHAKE, "WRONG_VERSION_NUMBER"))
- assert "not with TLS" in message
- assert "SD card is not involved" in message
- def test_it_does_not_prescribe_a_power_cycle(self):
- """#2780 removed that advice from the log because it does not work.
- The reporter of this issue restarted a printer on the strength of the
- user-facing string, so the string has to carry the same restraint.
- """
- for kind in FtpFailureKind:
- message = describe_upload_failure(FtpFailure(kind, "detail"))
- assert "restart the printer" not in message.lower(), kind
- assert "reboot" not in message.lower(), kind
- def test_an_unclassified_failure_points_at_the_log_rather_than_guessing(self):
- for failure in (None, FtpFailure(FtpFailureKind.UNKNOWN, "500 what")):
- message = describe_upload_failure(failure)
- assert "server log" in message
- assert "SD card" not in message
- def test_the_storage_message_carries_the_reply_code(self):
- """So a support bundle and the queue entry can be lined up."""
- message = describe_upload_failure(FtpFailure(FtpFailureKind.STORAGE, "553 Could not create file.", "553"))
- assert "553" in message
- assert "FAT32" in message
- # ---------------------------------------------------------------------------
- # End to end: what the queue entry says
- # ---------------------------------------------------------------------------
- @pytest.fixture
- async def dispatch_case(tmp_path):
- """Minimal one-printer, one-queued-job database for ``_start_print``."""
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- from backend.app.core.database import Base
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- session_maker = async_sessionmaker(engine, expire_on_commit=False)
- base_dir = tmp_path / "case"
- archive_rel = Path("archives") / "job.3mf"
- archive_abs = base_dir / archive_rel
- archive_abs.parent.mkdir(parents=True, exist_ok=True)
- archive_abs.write_bytes(b"archive payload")
- async with session_maker() as db:
- printer = Printer(
- name="Bambulab P2S-4",
- serial_number="SERIAL",
- ip_address=IP,
- access_code="12345678",
- model="P2S",
- )
- db.add(printer)
- await db.flush()
- archive = PrintArchive(
- printer_id=printer.id,
- filename="job.3mf",
- file_path=str(archive_rel),
- file_size=archive_abs.stat().st_size,
- status="completed",
- )
- db.add(archive)
- await db.flush()
- item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
- db.add(item)
- await db.commit()
- item_id = item.id
- try:
- yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, item_id=item_id)
- finally:
- await engine.dispose()
- async def _dispatch_failing_with(dispatch_case, failure: FtpFailure | None):
- """Run one dispatch whose upload fails with *failure*.
- Returns the queue item's message and the reason the notification carried,
- which have to agree -- a push saying something different from the screen is
- its own small bug.
- """
- import backend.app.services.print_scheduler as scheduler_module
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.services.print_scheduler import PrintScheduler
- from backend.tests._fixtures.background_tasks import discarding_spawn_patch
- async def _upload(*_args, **kwargs):
- # Stands in for the real wrapper: fills the caller's slot, then fails.
- # Reading kwargs["failure"] rather than accepting it as a parameter is
- # deliberate -- if the dispatch ever stops passing the slot, every
- # message below falls back to the generic one and these tests fail.
- if failure is not None and kwargs.get("failure") is not None:
- kwargs["failure"].failure = failure
- return False
- notify = AsyncMock()
- scheduler = PrintScheduler()
- async with dispatch_case.session_maker() as db:
- item = await db.get(PrintQueueItem, dispatch_case.item_id)
- patches = [
- patch.object(scheduler_module.settings, "base_dir", dispatch_case.base_dir),
- patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
- patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
- patch(
- "backend.app.services.print_scheduler.get_ftp_retry_settings",
- AsyncMock(return_value=(False, 0, 0, 1.0)),
- ),
- patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
- patch("backend.app.services.print_scheduler.upload_file_async", _upload),
- patch("backend.app.services.print_scheduler.notification_service.on_queue_job_failed", notify),
- discarding_spawn_patch(),
- patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
- patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
- patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
- ]
- with ExitStack() as stack:
- for p in patches:
- stack.enter_context(p)
- await scheduler._start_print(db, item)
- refreshed = await db.get(PrintQueueItem, dispatch_case.item_id)
- assert refreshed.status == "failed"
- return refreshed.error_message or "", notify.await_args.kwargs["reason"]
- class TestWhatTheQueueEntrySays:
- async def test_a_handshake_failure_does_not_send_anyone_to_the_sd_card(self, dispatch_case):
- """The report's own case: a TLS failure, answered with card advice.
- The reporter acted on it and restarted the printer. Nothing in that
- path reaches the printer's filesystem, and the cool-off that made the
- next dispatch fail identically lives in Bambuddy's memory, where
- power-cycling a printer does not reach.
- """
- message, reason = await _dispatch_failing_with(
- dispatch_case, FtpFailure(FtpFailureKind.HANDSHAKE, "WRONG_VERSION_NUMBER")
- )
- assert "inserted" not in message, message
- assert "FAT32" not in message, message
- assert "not with TLS" in message, message
- assert reason == message
- async def test_a_553_still_gets_the_card_advice(self, dispatch_case):
- """The advice was written for this case and belongs to it.
- Removing it everywhere would trade one wrong message for a vaguer one;
- the point is to attach it where the printer actually said storage.
- """
- message, reason = await _dispatch_failing_with(
- dispatch_case, FtpFailure(FtpFailureKind.STORAGE, "553 Could not create file.", "553")
- )
- assert "FAT32" in message, message
- assert "553" in message, message
- assert reason == message
- async def test_an_unclassified_failure_points_at_the_log(self, dispatch_case):
- """No reason recorded means no reason invented."""
- message, reason = await _dispatch_failing_with(dispatch_case, None)
- assert "server log" in message, message
- assert "SD card" not in message, message
- assert reason == message
|